首页
/ Caveman SDK Parity 机制:用一份共享 fixtures 把 TypeScript 与 Python SDK 焊成同一份线协议

Caveman SDK Parity 机制:用一份共享 fixtures 把 TypeScript 与 Python SDK 焊成同一份线协议

2026-09-06 14:39:44作者:谭伦延

packages/sdk/parity/ 是 Caveman 项目中 TypeScript SDK(@caveman-ai/sdk)与 Python SDK(caveman_cloud)之间的跨语言一致性契约目录:一份语言中立的 fixtures 文件被两个 SDK 的测试各自完整执行,任何一个字段在一侧存在、另一侧缺失,都会让对应那一侧的断言失败——因此它本质上是发布门禁(release gate),而不是文档。读完本文,你将理解这份契约文件的完整结构、双侧测试 harness 如何逐操作校验"线上请求 + 派生结果",以及修改任一侧 SDK 时必须遵守的编辑规则与 CI 运行方式。

一、Parity 目录的定位:一份契约,两种表达

项目的双 SDK 共享同一个网关(gateway),其线协议(wire contract)只有一份,但用两种语言各表达一次。parity/AGENTS.md 对这一点给出了原话式的定义:

"One language-neutral fixture file, run by both SDKs. The load-bearing honesty property of the SDKs: the wire contract is one thing, expressed twice."

目录内容非常克制,只有三个文件:

其"诚实属性"的机制很直白:把同一份操作列表分别喂给 TS 和 Python 两个真实 SDK 客户端,在 mock 传输层上捕获真实发出的请求,再与预期逐字段比对。一侧实现落后或超前,CI 变红——"That red is the point"(变红本身就是目的)。

二、fixtures.json:契约文件的完整结构

2.1 顶层布局

fixtures.json 顶层由五部分组成:

{
  "version": 1,
  "description": "Cross-language SDK parity fixtures. ... change one SDK without the other and CI goes red. Both SDKs MUST implement a handler for every operation; a missing handler is a failure, never a skip.",
  "config": {
    "api_key": "cave_live_parity_key",
    "base_url": "http://gateway.test",
    "control_url": "http://control.test",
    "agent": "parity-agent",
    "default_workflow": "parity-workflow",
    "retention": "metadata",
    "user": "parity-user-hash"
  },
  "std_headers": { ... },
  "std_headers_traced": { ... },
  "std_headers_artifact_traced": { ... },
  "std_headers_async_traced": { ... },
  "otlp_headers": { ... },
  "operations": [ ... ]
}

其中:

  • config 描述"一个 Cave"(客户端配置):api_keybase_urlcontrol_urlagentdefault_workflowretentionuser 七个字段,两个 SDK 的 harness 用它分别构造 new Cave({...})(TS)与 Cave(api_key=..., base_url=..., ...)(Python);
  • 命名 header 集合operations[].expect.wire.headers 按名字引用(见 2.2 节);
  • operations 是一个有序列表,每个操作是一条完整的"输入 → 预期线请求 → 预期派生结果"三元组。

description 字段本身就是一份可执行声明:两个 SDK 必须为每一个操作实现 handler,缺失 handler 是失败而非跳过;改了某一个 SDK 而没改另一个,CI 变红。

2.2 命名 Header 集合

AGENTS.md 文档列出四个命名 header 集合(std_headers / std_headers_traced / std_headers_async_traced / otlp_headers),而 fixtures 实际还包含第五个 std_headers_artifact_traced,供 artifacts_page 操作使用。各集合的语义如下表:

集合名 相对基础集合的增量 使用的操作
std_headers content-typeauthorization: Bearer ...x-cave-agentx-cave-workflowx-cave-retentionx-cave-user-hash 所有普通网关调用(tool-search、compress、context/pack、artifacts get 等)
std_headers_traced 增加 x-cave-trace-id: aaaabbbbccccddddeeeeffff00001111x-cave-parent-span-id: 1122334455667788 trace 发起的调用(checkpoint、events、artifacts、model create、checkpoint expand)
std_headers_artifact_traced traced 基础上再增加 x-cave-artifact-envelope: value-v1x-cave-artifact-source: tool:fetch artifacts_page
std_headers_async_traced traced 基础上增加 x-cave-async: true model_create_async(设置了 latency_class 的模型调用)
otlp_headers 不含 authorization,改用 x-cave-api-key cave_planGET /sdk/v1/cave-plan)与 otlp_export*POST /v1/traces

这些集合把"哪些请求必须携带 trace 连续性 id、哪些不带"固化成了断言对象:例如"直接挂在 Cave 上(而非 trace 内)的 provider 调用必须不带 x-cave-trace-id"这条规则,就是通过 provider_create_untraced 操作引用 std_headers 而非 std_headers_traced 来锁定的。

2.3 单个 Operation 的结构

每个操作是一个 name + input + 可选 response / transport + expect 的对象:

  • input:该操作的输入参数(snake_case,与网关线字段一致);SDK 内部需要生成的 id(trace id、span id)也在这里显式注入(见 4.4 节);
  • response:预制(canned)的网关响应;若改为 "transport": "error",则 mock 传输层直接抛错,用于强制 SDK 走字节安全的直通(byte-safe pass-through)分支;
  • expect.wire:对线上请求的断言,含 methodpathheaders(命名集合名或字面量对象)、以及二选一的 body(完整精确匹配)或 body_keys(只断言键集合)。部分操作可加 base: "control",表示请求应发往 control_url 而非 base_url
  • expect.result:对 SDK 派生结果的断言(规范化的 snake 键值对象);或 expect.result_from: "response",表示"SDK 必须逐字节透传预制响应"(如 cave_planotlp_exportmodel_create_* 这类纯透传操作)。

AGENTS.md 对 layout 的原始描述与此一致:"Each operation carries its input, a canned response (or transport: \"error\" to force a byte-safe pass-through), and an expect block: wire ... plus a result (or result_from: \"response\")."

2.4 操作全集:25 个操作覆盖 SDK 表面

当前 fixtures 包含 25 个操作(harness 中还有"操作数 ≥ 10"的保底断言,防止 fixture 文件被清空后悄悄通过门禁)。按功能分组:

分组 操作名 锁定的契约点
上下文组装(本地) context_assemblecontext_assemble_selfcontext_assemble_none emit_cache_hintsgateway/self/none 三态对 cache_control 断点的影响、x-cave-assembly 头、prefix_hashstable_tokens
工具检索 tool_searchtool_search_embeddingstools_builder_search POST /sdk/v1/tool-search 线请求体、ranker 透传、saved_tokens/reduction_pct 派生、always_load 工具的初始集
压缩 compresscompress_tooncompress_passthroughcompress_bad_reportcompress_optimistic_ratiocompress_unchanged_false_claim POST /sdk/v1/compress、传输失败/坏报告/虚报压缩率的降级行为(见 4.3 节)
节省计划 cave_plan GET /sdk/v1/cave-planotlp_headers、响应逐字透传
检查点 checkpointcheckpoint_expand POST /sdk/v1/checkpointsGET /sdk/v1/checkpoints/{ref}/expand 的 URL 编码
上下文打包 context_pack POST /sdk/v1/context/pack 的 items/options 原样上送、deferred_ids 返回
工件 artifacts_pageartifacts_get POST /sdk/v1/artifactsworkflow 注入与 [cave-artifact] 信封、GET /sdk/v1/artifacts/{id}
工具事件 event_tool_call POST /sdk/v1/events 的 body 键集合(body_keys 断言)
模型调用 model_create_asyncmodel_create_tracedprovider_create_untraced x-cave-async 头、trace 头有无的组合(对应 2.2 节三个集合)
Bedrock 路由 bedrockbedrock_mantle 无网络的路由描述符:/bedrock/bedrock/anthropic 前缀、sdk_only 字段
OTLP 导出 otlp_exportotlp_export_traced POST /v1/traces 的完整 OTLP/JSON body(gen_ai.* 属性映射、span id 固定)
异步作业(占位) jobs_unavailable 本地即失败 cave_async_jobs_unavailable,不发任何网络请求
重试熔断 retry_loop_breakerretry_loop_breaker_key_order 连续相同工具调用计数、参数键序无关的比较

三、双侧 Harness:TS 与 Python 如何强制执行

3.1 TypeScript 半边

TS 半边位于 packages/sdk/typescript/tests/parity.runtime.mjs,它 mock globalThis.fetch(文档中的相对路径 ../typescript/tests/parity.runtime.mjs 即此文件),核心流程:

  1. 读取 packages/sdk/parity/fixtures.json,用 config 构造真实的 Cave 实例;
  2. 每个 fixture 操作对应一个 handler,handler 执行真实的 SDK 调用(cave.toolSearchcave.compresscave.trace(...) 回调等),并把 camelCase 结果字段映射回规范 snake 键(如 sentSchemaTokenssent_schema_tokensgatewayPrefixgateway_prefix);
  3. mock fetch 捕获 { url, method, headers, body };若 op.transport === "error" 则抛错;
  4. 逐操作驱动:
for (const op of fixtures.operations) {
  test(`parity: ${op.name}`, async () => {
    const handler = handlers[op.name];
    assert.ok(handler, `no TS parity handler for operation "${op.name}" — the SDK is missing this capability`);

    installMock(op);
    const actual = await handler(makeCave(), op.input);

    if (op.expect.wire) {
      assert.equal(captured.length, 1, ...);            // 必须恰好一次线请求
      assert.equal(req.url, base + op.expect.wire.path, ...);
      assert.equal(req.method, op.expect.wire.method, ...);
      assert.deepStrictEqual(lowerKeys(req.headers), expHeaders, ...); // 键小写后比对
      if (op.expect.wire.body !== undefined) {
        assert.deepStrictEqual(req.body, op.expect.wire.body, ...);
      } else if (op.expect.wire.body_keys !== undefined) {
        assert.deepStrictEqual(Object.keys(req.body ?? {}).sort(),
          [...op.expect.wire.body_keys].sort(), ...);
      }
    } else {
      assert.equal(captured.length, 0, ...);            // 本地操作必须零网络
    }

    const expected = op.expect.result_from === "response" ? op.response : op.expect.result;
    assert.deepStrictEqual(actual, expected, ...);
  });
}

值得注意的两个细节:

  • URL 断言的是完整地址base + path,其中 basewire.base === "control"control_urlbase_url 之间选择——这锁定了"哪些端点走 control 通道";
  • 文件尾部还有两条守卫测试parity: fixtures cover the documented operations 断言操作数 ≥ 10 且每个操作都有 handler("Guard against an empty/short fixture file silently passing the gate");另外 bedrock descriptor rejects an unknown endpoint 验证描述符对未知 endpoint 抛出 /runtime or mantle/ 错误。

3.2 Python 半边

Python 半边位于 packages/sdk/python/tests/test_parity.py,mock 对象是 urllib.request.urlopen(Python SDK 为纯标准库实现,无任何第三方依赖)。结构与 TS 半边严格镜像:

@pytest.mark.parametrize("op", OPS, ids=[o["name"] for o in OPS])
def test_parity(op: dict[str, Any]) -> None:
    handler = HANDLERS.get(op["name"])
    assert handler is not None, f'no Python parity handler for operation "{op["name"]}" — the SDK is missing this capability'
    ...
    with patch("urllib.request.urlopen", side_effect=fake_urlopen):
        actual = handler(_make_cave(), op["input"])
    ...
    expected = op["response"] if op["expect"].get("result_from") == "response" else op["expect"].get("result")
    assert actual == expected, f'{op["name"]}: result'

fake_urlopen 捕获 req.full_urlreq.get_method()键小写化后的 header({k.lower(): v for k, v in dict(req.headers).items()})以及 JSON 解析后的 body;transport == "error" 时抛 urllib.error.URLError。同样的"操作数 ≥ 10 + 全量 handler 覆盖"守卫也存在于 test_parity_fixtures_cover_surface。此外还有一条元测试 test_tool_events_are_traced_while_bare_provider_calls_stay_untraced,直接从 fixtures 断言 event_tool_call 必须引用 std_headers_tracedprovider_create_untraced 必须引用 std_headers——把"trace 连续性规则"本身也纳入了契约。

3.3 双侧共同的判定语义

综合两个 harness,parity 的判定规则可以概括为四句话(与 AGENTS.md "How it's enforced" 一节完全对应):

  1. 执行真实调用:handler 不是重复实现一遍协议,而是调用 SDK 公共 API 并捕获其真实输出,因此它同时校验"线请求"和"派生结果"两层;
  2. 结果规范化:两侧各自把语言特有命名(camelCase / snake_case)映射到同一套 canonical snake 键值再比较,避免命名风格差异污染断言;
  3. 全量遍历:两侧都遍历每一个操作,"a missing handler is a failure, never a skip";
  4. 零网络:TS mock fetch、Python mock urlopen,测试永远不打真实网络。

四、从典型操作看契约锁定的细节

4.1 context_assemble 家族:缓存断点的三态

context_assemblecontext_assemble_selfcontext_assemble_none 三个操作共享同一组 slots(一个 volatile 的 turn、一个 stable 的 tools、一个 stable 的 system、一个 session 级的 playbook),只改变 emit_cache_hints(缺省 / "self" / "none"),断言派生结果的三个维度:

"headers": { "x-cave-assembly": "v1;slots=4;prefix=15506e067a67;vbb=1" },
"prefix_hash": "15506e067a6722f449496ebe3eed6f340bcfefc53483026603d0b1d97b16eced",
"breakpoints": ["tools[0]"],      // 仅 emit_cache_hints="self" 时非空
"stable_tokens": 56,
"token_basis": "estimated_bytes_div_4",
"basis": "inferred",
"volatile_below_breakpoint": true
  • emit_cache_hints: "self" 时,tools[0] 获得 cache_control: {"type": "ephemeral"}breakpoints["tools[0]"]
  • 其余两态 breakpoints 为空数组;
  • prefix_hash 前 12 位与 x-cave-assembly 头中的 prefix=15506e067a67 保持一致,锁定了"头里的短前缀就是完整哈希前缀"这一约定;
  • 该操作没有 wire 段(纯本地组装、零网络),走的是 harness 中"captured.length 必须为 0"的分支。

4.2 tool_search:派生字段的精确算术

tool_search 同时断言线请求与派生结果:

  • wirePOST /sdk/v1/tool-searchstd_headers,body 与 inputtools/query/context/max_tools/session_id 逐字段一致(camelCase 的 inputSchema 在 wire 上是 input_schema);
  • result:在透传网关的 sent_schema_tokens: 120full_schema_tokens: 840deferred_count: 1method: "lexical-hit-rate" 之外,额外断言 SDK 本地派生的字段:saved_tokens: 720(= 840 − 120)、reduction_pct: 85.7(保留一位小数)、tool_count: 1basis: "inferred"

tools_builder_search 则锁定 strategy: "deferred"initial_tool_names["always", "lazy"]always_load 工具 + 其余初始位)。这些数字断言确保两侧 SDK 的派生公式(而非仅透传)也保持一致。

4.3 compress 家族:故障降级是契约的一部分

压缩相关的六个操作把 SDK 的"诚实降级"策略逐条钉死:

操作 触发条件 锁定的行为
compress 正常 json 报告 逐字透传 Engine 报告(含 recovery_handle: "ccr_abc"method: "elision"lossless_to_model: false
compress_toon 正常 toon 报告 透传 method: "toon"lossless_to_model: true
compress_passthrough "transport": "error" 字节安全直通:output = 原始输入、ratio: 0.0recovery_handle: nulltoken_count_basis: "unavailable"
compress_bad_report 报告含 tokens_before: "not-a-number"ratio: "bad" 视为坏报告,同样降级为直通 + 全零 + null
compress_optimistic_ratio 报告自称 ratio: 0.9tokens_after: 80 / tokens_before: 100 SDK 重算 ratio 为 0.2,并把 basis"verified" 降回 "inferred"——不允许 SDK 采信虚高的压缩率
compress_unchanged_false_claim 报告声称压缩了但 output 与输入完全相同 判定为"虚报",全零 + null 降级

最后两个操作体现了该项目对 token 统计的一贯立场(与两侧 SDK 文档中"SDK 永不重造压缩器、永不臆造数字"的约定一致):ratio 可以由 SDK 依据 before/after 自行核算,basistoken_count_basis 必须如实披露计数依据。

4.4 checkpoint_expand:URL 编码的一致性

source_ref"tenant a/ref+1",预期 path 为:

/sdk/v1/checkpoints/tenant%20a%2Fref%2B1/expand

空格、/+ 全部百分号编码且两侧一致。这正是 AGENTS.md 编辑规则第三条的落地:"Keep values that encode identically in both languages (e.g. avoid ! ( ) * in expand refs — JS encodeURIComponent and Python quote disagree on those)"。JS 的 encodeURIComponent 与 Python 的 quote! ( ) * 这类字符的默认处理不一致,因此 fixture 刻意避开它们、并固化了两者都一致的 %20 / %2F / %2B 行为。

4.5 无随机性:id 注入 + 固定

OTLP 相关操作把"SDK 本应自己铸造 id"这件事变成了确定性断言:otlp_export 的 span 通过 input.span.trace_id / span_id 注入并原样出现在 body 的 traceId / spanId 中;otlp_export_traced 进一步验证 trace 内 exporter 复用 trace 的 id——traceId 是 trace 级的 aaaabbbb...,而 span 的 parentSpanId 是注入的 root_span_idcheckpointevent_tool_callartifacts_page 等操作同样通过 input.trace_id / input.span_id 注入 id,并让它们以固定值出现在 std_headers_traced 中。AGENTS.md 原文:"Nothing random may reach an assertion."

4.6 retry_loop_breakerjobs_unavailable:本地行为也进契约

  • retry_loop_breaker:阈值 2 下连续三次相同调用,断言 fired_at_call: 2repeats: 3threshold: 2retry_loop_breaker_key_order{"a":1,"b":2}{"b":2,"a":1} 交错,证明"相同参数"的判定与 JSON 键序无关
  • jobs_unavailablecave.jobs.submit(...) 必须本地抛出错误码 cave_async_jobs_unavailable,且无 wire 段——即该占位 API 在任何版本里都不得发起网络请求(两侧 SDK 文档均注明:持久加密存储、凭据托管与排空 worker 落地之前,JobsClient 只会在本地失败)。

五、编辑规则与运行方式(Release Gate 的日常)

修改这份契约必须遵循 AGENTS.md 的 "Editing rules":

  1. 新增字段/方法:给某一侧 SDK 加了字段或方法 → 在这里新增对应操作(或 body key)→ 另一侧的 harness 随即变红,直到它对齐。"That red is the point." 这是工作流的核心:红不是事故,是契约在起作用的证据;
  2. Header 键小写比对:因为 Python 的 urllib 会把 header 键大写化,两侧 harness 都在捕获时把键小写后再比较(lowerKeys / k.lower()),要求跨语言匹配的是键集合 + 值,大小写本身不参与契约;
  3. 编码一致性:fixture 中的取值必须保证在两种语言里编码后相同,避开 ! ( ) * 等两侧默认百分号编码规则不一致的字符(见 4.4 节的 checkpoint_expand 实例);
  4. 零随机:SDK 会自铸的 id(trace id、span id)一律经操作 input 注入,并固定写进预期 header 集合——otlp_export 固定 span id 也是同一手法;
  5. 运行门禁
make product-test PRODUCT=sdk-ts && make product-test PRODUCT=sdk-python

该命令模式与仓库内其他产品(engineproxymcp 等目录的 AGENTS.md / CLAUDE.md)的 make product-test PRODUCT=<name> 约定一致;TS 半边也可以 node --test tests/parity.runtime.mjs 直接驱动(需先构建 dist/),Python 半边直接 pytest

两侧 SDK 的开发者文档也都把 parity 列为硬性约定:packages/sdk/typescript/CLAUDE.md 的 Gotchas 写明 "mirror sdk-python: every field/method exists in both, enforced by the shared parity suite — a divergence is a CI failure, not a convention slip. Change one SDK, change both and the fixtures";packages/sdk/python/CLAUDE.md 则给出对称表述。本文开头引用的 See ../typescript/CLAUDE.md · ../python/CLAUDE.md 两个相对链接,在仓库中的全局路径即上述两个文件。

六、小结

packages/sdk/parity/ 用极小的面(一份 fixtures 文件 + 两份镜像 harness)解决了双语言 SDK 最容易腐化的问题:线协议漂移。它的要点可以浓缩为四条设计决策——

  • 契约即数据:method、path、header 集合、body(或 body 键集合)、派生结果全部是 fixtures.json 里的 JSON,语言中立;
  • 执行真实客户端:两侧 handler 调用的是 SDK 公共 API 而非协议复刻,mock 只替换传输层;
  • 失败即信号:缺 handler、少操作、字段漂移都会让某一侧 CI 变红,且 harness 自带"fixture 过短/覆盖不全"的守卫断言;
  • 确定性优先:id 注入、编码一致性、transport: "error" 触发降级分支,保证同一份契约在任何机器、任何语言运行时产生可复现的断言结果。

对维护者而言,日常动作就是:改一侧 SDK → 更新 fixtures.json → 运行 make product-test PRODUCT=sdk-ts && make product-test PRODUCT=sdk-python → 把另一侧改到绿为止。

登录后查看全文
热门项目推荐
相关项目推荐