首页
/ caveman SDK Parity 契约:一份 fixtures.json 如何成为 TS 与 Python 双 SDK 的发布门禁

caveman SDK Parity 契约:一份 fixtures.json 如何成为 TS 与 Python 双 SDK 的发布门禁

2026-09-06 16:46:51作者:柯茵沙

packages/sdk/parity 目录是 caveman 仓库中跨语言 SDK 一致性(parity)测试的核心:一个语言中立的 fixtures.json 被 TypeScript(@caveman-ai/sdk)和 Python(caveman_cloud)两个 SDK 各自完整执行一遍。它的定位不是文档,而是发布门禁(release gate)——只要某个字段在一个 SDK 中存在而在另一个 SDK 中缺失,CI 上必然有一侧变红。读完后你将理解:parity 契约文件的完整结构(config、命名 header 集、operations 列表)、两侧测试如何 mock 传输层并断言精确的 wire 请求与派生结果、以及维护双语言 SDK 时必须遵守的五条编辑规则。

1. 定位:release gate,不是文档

目录说明(CLAUDE.md,其内容与 AGENTS.md 一致)开篇即给出这条契约的"承重诚实属性"(load-bearing honesty property):

线协议契约只有一个东西,用两种语言表达(@caveman-ai/sdk TS + caveman_cloud Python)。一个字段在一个 SDK 中存在、在另一个中缺失,就会使某一侧的断言失败——因此这个文件夹是发布门禁,而不是文档。

目录布局非常简单:

  • fixtures.json —— 契约本体(当前含 29 个 operation,version: 1);
  • runtime-policy.fixtures.json —— 运行策略客户端的配套共享 fixture(由两侧 runtime-policy 测试驱动,不在本文展开)。

两侧的驱动器:

语言 驱动文件 mock 对象
TypeScript parity.runtime.mjs 全局 fetch
Python test_parity.py urllib.request.urlopen

每一侧都实现了 per-operation handler:执行真实的 SDK 调用、捕获 wire 请求、把结果规范化为 canonical snake-key 值。两侧都遍历每一个 operation——缺少 handler 是失败(failure),绝不跳过(never a skip)。

2. fixtures.json 的结构:config + 命名 header 集 + operations

契约文件顶部是一个固定的 config(即"一个 Cave",两侧 SDK 客户端都从它构造):

{
  "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"
}

接着是命名 header 集,供各 operation 的 expect.wire.headers 以字符串引用(TS 侧在 fixtures[op.expect.wire.headers] 解引用,Python 侧同理)。契约文档明确列出 std_headers / std_headers_traced / std_headers_async_traced / otlp_headers 四组;实际 fixture 中还存在第五组 std_headers_artifact_traced(artifact 上报操作使用),其差异点是额外携带 x-cave-artifact-envelope: value-v1x-cave-artifact-source: tool:fetch。各组语义:

  • std_headers —— 基础六件套:content-typeauthorization: Bearer …x-cave-agentx-cave-workflowx-cave-retentionx-cave-user-hash
  • std_headers_traced —— 在基础集上追加 x-cave-trace-id: aaaabbbbccccddddeeeeffff00001111x-cave-parent-span-id: 1122334455667788
  • std_headers_async_traced —— traced 集上再追加 x-cave-async: true
  • std_headers_artifact_traced —— traced 集上追加 artifact envelope/source 两键;
  • otlp_headers —— OTLP 导出专用:content-type + x-cave-api-key(而非 Bearer)+ agent/workflow/retention/user-hash。

注意 trace id / span id 是硬编码常量而非 SDK 生成的值——这是"无任何随机值进入断言"规则的体现,见第 5 节。

3. 单个 operation 的解剖

每个 operation 是一个对象,包含四部分:name(handler 映射键)、input(喂给真实 SDK 调用的参数)、response(mock 传输层返回的罐头响应)或 transport: "error"(强制字节安全直通),以及 expect 块。expect 又分 wire 与结果两部分:

  • expect.wiremethodpathheaders(命名集或字面量对象),body 二选一——精确的 body 或仅校验键集合的 body_keys;另可选 base: "control" 表示请求发往 control_url 而非 base_url
  • expect.result:期望的派生结果(canonical snake-key 值),或 result_from: "response" 表示结果必须逐字节等于罐头响应。

tool_search 为例(取自 fixtures.json 第 168–217 行):

{
  "name": "tool_search",
  "input": {
    "catalog": [
      { "name": "search", "description": "Search things", "input_schema": { "type": "object", "properties": {} }, "read_only": true, "idempotent": true, "always_load": false },
      { "name": "fetch", "description": "Fetch a thing", "input_schema": { "type": "object", "properties": {} }, "read_only": false, "idempotent": false, "always_load": true }
    ],
    "query": "find a thing",
    "context": "parity test",
    "max_tools": 5,
    "session_id": "tool-session-1"
  },
  "response": {
    "session_id": "tool-session-1",
    "tools": [{ "name": "search", "description": "Search things" }],
    "sent_schema_tokens": 120,
    "full_schema_tokens": 840,
    "deferred_count": 1,
    "token_basis": "estimated_bytes_div_4",
    "method": "lexical-hit-rate"
  },
  "expect": {
    "wire": {
      "method": "POST",
      "path": "/sdk/v1/tool-search",
      "headers": "std_headers",
      "body": { "tools": [ ... ], "query": "find a thing", "context": "parity test", "max_tools": 5, "session_id": "tool-session-1" }
    },
    "result": {
      "sent_schema_tokens": 120,
      "full_schema_tokens": 840,
      "deferred_count": 1,
      "method": "lexical-hit-rate",
      "token_basis": "estimated_bytes_div_4",
      "basis": "inferred",
      "session_id": "tool-session-1",
      "saved_tokens": 720,
      "reduction_pct": 85.7,
      "tool_count": 1
    }
  }
}

这里同时验证了三件事:请求体与 input 语义一致地落到 wire 上;派生值(saved_tokens: 840-120=720reduction_pct: 85.7)由 SDK 本地计算且必须精确相等;结果被规范化为 snake-key。

4. 29 个 operation 覆盖的 SDK 面

当前 fixture 的 operations 列表按能力域覆盖如下(顺序即文件内顺序):

能力域 operation 名 断言重点
上下文装配 context_assemble / context_assemble_self / context_assemble_none 纯本地:request(model/tools/system/messages)、x-cave-assembly header(v1;slots=4;prefix=15506e067a67;vbb=1)、prefix_hash(64 位 hex)、breakpointsstable_tokens: 56emit_cache_hints: "self" 变体额外在 tools[0] 上产出 cache_control: {type: "ephemeral"} 并记录 breakpoint
工具检索 tool_search / tool_search_embeddings / tools_builder_search POST /sdk/v1/tool-search;embeddings ranker 原样透传;builder 变体额外返回 strategyinitial_tool_names
压缩 compress / compress_toon / compress_passthrough / compress_bad_report / compress_optimistic_ratio / compress_unchanged_false_claim POST /sdk/v1/compresstransport: "error" 与坏报告(tokens_before: "not-a-number")都触发字节安全直通:输出=原输入、ratio: 0.0token_count_basis: "unavailable";乐观 ratio(服务端报 0.9 但 before/after 不符)被纠正为 0.2
优化计划 cave_plan GET /sdk/v1/cave-plan,走 otlp_headers,计划逐字节透传(result_from: "response"
检查点 checkpoint / checkpoint_expand POST /sdk/v1/checkpoints(body 含 workflow 注入);expand 路径做 URL 编码:/sdk/v1/checkpoints/tenant%20a%2Fref%2B1/expand
上下文打包 context_pack POST /sdk/v1/context/packdeferred_ids: ["billing"] 精确断言被挤出的项
工具事件 event_tool_call 只断言 body_keysduration_msnameoptionsoutcomesequencespan_typetagsworkflow),容忍服务端补字段
工件 artifacts_page / artifacts_get page 返回定格式字符串:[cave-artifact id=art_123 source=tool:fetch type=application/json] … [/cave-artifact]
模型调用 model_create_async / model_create_traced / provider_create_untraced 同样 POST /openai/v1/responses:异步变体带 x-cave-async: true,traced 变体带 trace 双头,裸 cave.openai() 变体带任何连续性 id
Bedrock 路由 bedrock / bedrock_mantle 无网络的路由描述符:默认 gateway_prefix: /bedrock,mantle 为 /bedrock/anthropicinstrumented: truesdk_only: false
OTLP 导出 otlp_export / otlp_export_traced POST /v1/traces;完整断言 resourceSpans 结构(service.namecave.agent 资源属性,gen_ai.* span 属性,纳秒时间戳为字符串,kind: 3status.code: 1);traced 变体把 parentSpanId 钉在注入的 root span 上
预留能力 jobs_unavailable cave.jobs.submit 本地失败,error_code: cave_async_jobs_unavailable,不发任何网络请求
循环熔断 retry_loop_breaker / retry_loop_breaker_key_order RetryLoopBreaker 在第 2 次相同调用触发;key order 变体断言 {a:1,b:2}{b:2,a:1} 视为同一调用

其中 context_assemble*bedrock*jobs_unavailableretry_loop_breaker* 不带 expect.wire,断言逻辑会转而要求 captured.length == 0——纯本地 API 也必须是"零 wire 请求",防止误发网络调用。

5. 如何强制:两侧驱动的断言流水线

TypeScript 侧parity.runtime.mjs,用 node --test 运行,构建后 import 自 dist/):

  1. installMock(op) 替换 globalThis.fetch:捕获 {url, method, headers, body}op.transport === "error" 时抛 simulated transport error,否则返回罐头 op.response
  2. for (const op of fixtures.operations) 为每个 operation 生成一个 test("parity: " + op.name)handlers[op.name] 不存在即断言失败,错误信息为 "the SDK is missing this capability";
  3. header 断言前用 lowerKeys() 把实际请求头转小写,与 fixture 的命名集 deepStrictEqual——因为 Python 的 urllib 会首字母大写化 header,跨语言比较只能比"键小写后的集合 + 值";
  4. body 有 body 时做 deepStrictEqual 精确比对,只有 body_keys 时按排序后的键集合比对;
  5. 结果断言:result_from === "response" 取罐头响应,否则取 expect.result,与实际结果 deepStrictEqual

Python 侧test_parity.py)结构完全对称:@pytest.mark.parametrize("op", OPS) 逐条驱动;patch("urllib.request.urlopen", side_effect=fake_urlopen) 捕获请求并在 transport == "error" 时抛 urllib.error.URLErrorHANDLERS 字典与 TS 一一对应;每个 handler 把 camel/对象结果显式映射回 canonical snake-key 字典再与期望比较。Python 侧还有一条额外守卫 test_tool_events_are_traced_while_bare_provider_calls_stay_untraced:直接从 fixture 断言 event_tool_callstd_headers_tracedprovider_create_untracedstd_headers——把"trace 内带连续性头、裸 provider 调用不带"这条契约也钉死在数据上。

两侧各有一个防退化守卫测试parity: fixtures cover the documented operations / test_parity_fixtures_cover_surface):operations 长度必须 ≥ 10,且每个 operation 都必须有 handler,防止有人用空 fixture 或短 fixture 让门禁"静默通过"。TS 侧还额外验证 bedrock 描述符对未知 endpoint 抛 /runtime or mantle/ 错误。

6. 五条编辑规则(来自契约文档,必须遵守)

CLAUDE.md 的 "Editing rules" 一节给出了维护这份契约的操作规程,逐条展开:

  1. 单边改字段/方法 → 先加 operation(或 body key)。加到 fixture 后,另一个 SDK 的那一侧会立刻变红,直到它对齐为止——"That red is the point."(红就是目的)。也就是说,变更流程是 fixture 先行,红 CI 驱动另一侧实现。
  2. Header 按键小写比较(Python 的 urllib 会把 header 首字母大写化);跨语言必须匹配的是键集合 + 值,而不是键的原始大小写。
  3. 只用两种语言编码一致的取值。文档明确点名 expand 路径中避开 ! ( ) *:JS 的 encodeURIComponent 与 Python 的 quote 对这几个字符的百分号编码行为不一致。这正是 checkpoint_expand 用例只使用空格、/+(编码为 %20%2F%2B,两者行为一致)的原因。
  4. 任何随机值不得进入断言。SDK 本来会自己铸造的 id(trace id、span id)全部通过 operation 的 input 注入,并钉死在期望 header 集里——otlp_export 钉 span id 用的是同一套手法。
  5. 运行方式:文档给出的命令是 make product-test PRODUCT=sdk-ts && make product-test PRODUCT=sdk-python(当前仓库根目录未包含 Makefile,该命令应由外层构建环境提供)。不依赖外层 make 时,两侧测试文件头部给出了直跑方式:TS 为 pnpm buildnode --test tests/parity.runtime.mjs,Python 为在本目录直接 pytest(要求 Python ≥ 3.13,dependencies = [],见 pyproject.toml)。

7. 与两个 SDK 文档的关系:镜像约定是双向的

parity 契约并不是孤立存在的——它只是把两个 SDK 文档中"mirror"约定机制化

  • TS 侧(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";
  • Python 侧(CLAUDE.md)对称写着 "sdk-python and sdk-ts mirror the same field names and /sdk/v1/* contract — enforced by the shared parity suite … A divergence is a CI failure"。

两侧文档还把若干具体契约与 fixture 对应起来:x-cave-workflow 永远不省略(缺省 unlabeled-workflow)、延迟工具检索的 session_id / x-cave-tool-session 交接("update sdk-python + parity fixtures with any change")、reduction_pct 保留一位小数且 saved_tokens 是本地推导值等。理解这些对应关系后,parity 目录的实际角色就很清楚:它是两份 SDK 文档共享事实(shared truth)的唯一数据源,测试代码只负责执行与断言,而契约本身是可读、可 diff、可审计的 JSON。

8. 小结:从契约到门禁的最小闭环

把整套机制压缩成一句话:一个 JSON 文件 + 两份逐条执行它的测试。新增能力时的标准动作是:在 fixtures.json 加 operation → 在 parity.runtime.mjstest_parity.py 各加 handler → 跑两侧测试,直到都绿。这套设计对任何维护多语言 SDK 的团队都有直接参考价值:用"共享 fixture + 强制全覆盖(缺 handler 即失败)+ 随机值注入 + 编码一致性约束"四件事,就能让"两个语言的 SDK 行为完全一致"从口头约定变成 CI 里可验证的事实。

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