From 8892b62a13e7fb18d4433db43eeddf84310db771 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 29 Apr 2026 22:03:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(vendor-channels):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20zhipu=20=E7=9B=AE=E6=A0=87=E8=BD=AC=E6=8D=A2=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E6=9C=89=E5=AE=B3=E6=AD=A5=E9=AA=A4=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20500=20=E7=BA=A7=E8=81=94=E6=95=85=E9=9A=9C;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 prepare_copilot_to_zhipu、prepare_anthropic_to_zhipu、prepare_zhipu_self_cleanup 三个通道中移除 enforce_anthropic_tool_pairing(tool_result 搬迁触发 zhipu 500)、 _inject_tool_result_id_for_zhipu(zhipu 类不读取 JSON id,注入无效)、 _strip_cache_control(GLM-5 原生支持 cache_control)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/issue.md | 54 +++++++++---- src/coding/proxy/convert/vendor_channels.py | 84 ++++++-------------- tests/test_router_executor.py | 21 ++--- tests/test_vendor_channels.py | 86 ++++++++------------- 4 files changed, 108 insertions(+), 137 deletions(-) diff --git a/docs/issue.md b/docs/issue.md index f73780e..bac2f7e 100644 --- a/docs/issue.md +++ b/docs/issue.md @@ -152,30 +152,58 @@ zhipu GLM-5 在处理含 `tool_result` 块的会话时持续返回 500 错误, ``` WARNING zhipu stream error: status=500 body='...message":"\'ClaudeContentBlockToolResult\' object has no attribute \'id\'"}' -WARNING Tier zhipu zhipu tool_result format error (500), treating as format incompatibility without circuit breaker penalty -INFO Failover: zhipu → copilot (reason: HTTP 500) ``` **表因** -zhipu 后端在解析 `tool_result` 内容块时错误地访问 `.id` 属性。但 Anthropic API 规范中 `tool_result` 块只有 `tool_use_id` 字段(用于关联对应的 `tool_use`),没有 `id` 字段(`id` 是 `tool_use` 块的属性)。 +zhipu 后端在解析 `tool_result` 内容块时错误地访问 `.id` 属性。但 Anthropic API 规范中 `tool_result` 块只有 `tool_use_id` 字段(用于关联对应的 `tool_use`),没有 `id` 字段。 -**根因** +**根因**(2026-04-29 复盘更新) -所有 targeting zhipu 的转换通道(`prepare_zhipu_self_cleanup`、`prepare_copilot_to_zhipu`、`prepare_anthropic_to_zhipu`)在完成 `enforce_anthropic_tool_pairing` 后,没有为 `tool_result` 块补上 zhipu 后端期望的 `id` 字段。搬迁或合成的 `tool_result` 块仅有 `tool_use_id`,缺少 `id`。 +**初始诊断**(已推翻):认为 zhipu 后端期望 `tool_result` 有 `id` 字段,通过 `_inject_tool_result_id_for_zhipu` 注入 `id = tool_use_id` 可绕过。 -**处理方式** +**实际根因**:转换通道本身引入的问题。具体因果链: + +1. **转换前**:zhipu 偶发在 assistant 消息中内联输出 `tool_result`(违反 Anthropic 规范),但 zhipu 后端对 assistant 消息中内联的 `tool_result` **不做 `.id` 属性访问**,因此不触发 500。 +2. **转换后**:所有 zhipu 目标通道执行 `enforce_anthropic_tool_pairing`,将 assistant 内联的 `tool_result` 搬迁到紧随的 user 消息。zhipu 后端对 user 消息中的 `tool_result` **执行 `.id` 属性访问**(代码路径不同),触发 `AttributeError` → 500。 +3. **`_inject_tool_result_id_for_zhipu` 无效**:该函数往 JSON dict 注入 `"id": tool_use_id`,但 zhipu 后端的 `ClaudeContentBlockToolResult` Python 类不从 JSON 读取 `id` 字段(类定义中无此属性),注入的值在反序列化时被丢弃。 + +**实证依据**:用户确认「转换通道之前 zhipu 正常,转换通道之后才出现 500 错误」。 + +**处理方式**(2026-04-29 更新) + +从所有 zhipu 目标转换通道中移除以下三个步骤: + +| 移除项 | 原因 | +|--------|------| +| `enforce_anthropic_tool_pairing` | 搬迁 `tool_result` 到 user 消息触发 zhipu 500 | +| `_inject_tool_result_id_for_zhipu` | zhipu 类不读取注入的 `id`,无效且可能干扰 | +| `_strip_cache_control` | zhipu 原生支持 `cache_control`(cache_read 已实证),剥离反损性能 | + +保留的必要步骤: + +| 保留项 | 原因 | +|--------|------| +| `strip_thinking_blocks` | copilot/anthropic 的 thinking 签名 zhipu 无法验证 | +| 移除 `thinking`/`extended_thinking` 顶层参数 | zhipu 不支持 | +| `_remove_vendor_blocks(server_tool_use_delta)` | zhipu 自身流式残块 | +| `_remove_vendor_blocks(server_tool_use)` | Anthropic beta 块,zhipu 不支持 | + +**涉及变更的转换通道**: +- `prepare_copilot_to_zhipu` — 移除 cache_control / tool pairing / id 注入 +- `prepare_anthropic_to_zhipu` — 移除 cache_control / tool pairing / id 注入 +- `prepare_zhipu_self_cleanup` — 移除 tool pairing / id 注入 -- 在 `vendor_channels.py` 新增 `_inject_tool_result_id_for_zhipu` 辅助函数:扫描所有消息中的 `tool_result` 块,将 `tool_use_id` 值复制为 `id` 字段(仅注入尚无 `id` 的块,保持幂等) -- 在三个 targeting zhipu 的转换通道末尾统一调用此辅助函数 -- 保留 executor 中已有的 500 错误检测作为纵深防御 +**注意**: `prepare_zhipu_to_anthropic` 和 `prepare_zhipu_to_copilot` 不受影响(目标是 anthropic/copilot,不是 zhipu),仍保留 `enforce_anthropic_tool_pairing`。 **后续防范** -- 其他 `NativeAnthropicVendor` 子类若出现类似的「后端期望非标准字段」问题,可参考此模式在对应的转换通道中注入兼容字段。 -- 当 zhipu 后端修复此 bug(不再访问 `.id`)后,此 workaround 仍安全保留(多一个 `id` 字段不影响 Anthropic API 语义)。 +- **转换通道的「最小干预」原则**:跨供应商转换应仅清理目标供应商**确认不支持**的特性。未经验证的「预防性清理」(如剥离 cache_control)可能误伤供应商原生支持的功能,甚至引入新的故障。 +- **workaround 须验证有效**:`_inject_tool_result_id_for_zhipu` 虽有注释说明目的,但未经验证其有效性即合入。后续 workaround 须附带验证证据(如 curl 复现、上游确认)。 +- **zhipu 后端 bug 跟踪**:`ClaudeContentBlockToolResult` 类缺少 `id` 属性是 zhipu 上游 bug。若 zhipu 修复此 bug,可考虑恢复 tool pairing 以获得更严格的消息结构校验。 **同类问题影响与处理注意事项** -- `enforce_anthropic_tool_pairing` 合成的 `is_error=True` 占位块只有 `tool_use_id`,同样需要 `id` 注入——辅助函数在配对后统一处理,无需在合成逻辑中单独添加。 -- `tool_result.id` 的值设为与 `tool_use_id` 相同,语义上可视为「内容块标识符」,对 zhipu 后端足够区分不同 tool_result 块。 +- `NativeAnthropicVendor` 子类的自清理通道应**精确剪裁**:仅修复 vendor 自身拒绝的产物,不做跨供应商的全量清理。 +- 当 zhipu 后端出现新的 400 拒绝(如 inline tool_result 再次被拒),应优先调查是 zhipu 后端变更还是请求格式问题,而非立即加回 tool pairing(可能重新触发 500)。 +- `_inject_tool_result_id_for_zhipu` 函数暂时保留在代码中(未删除),标记为 deprecated,待确认不需要后清理。 diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py index dd3f3c7..65b669b 100644 --- a/src/coding/proxy/convert/vendor_channels.py +++ b/src/coding/proxy/convert/vendor_channels.py @@ -572,13 +572,18 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None: def prepare_copilot_to_zhipu( body: dict[str, Any], ) -> tuple[dict[str, Any], list[str]]: - """copilot → zhipu 转换: 清理 copilot 产物以适配 GLM-5. + """copilot → zhipu 转换: 仅清理 copilot 产物中 zhipu 确认不支持的部分. - GLM-5 的 Anthropic 兼容端点对以下特性支持不完整: - - thinking / redacted_thinking 块 (signature 由非 Anthropic 签发) - - cache_control 字段 - - 跨供应商产物 (misplaced tool_result, 非标准 tool_use ID) - - 顶层 thinking / extended_thinking 参数 + GLM-5 的 Anthropic 兼容端点: + - ✗ thinking / redacted_thinking 块 (signature 由非 Anthropic 签发) + - ✓ cache_control 字段 (cache_read 已在生产实证) + - ✓ tool_result 在 assistant 消息中内联 (zhipu 自身偶发产出,可自行消化) + - ✗ 顶层 thinking / extended_thinking 参数 + + 注意: 不再执行 enforce_anthropic_tool_pairing 和 _inject_tool_result_id_for_zhipu。 + 实证表明 tool_result 重定位会触发 zhipu 后端 ``'ClaudeContentBlockToolResult' + object has no attribute 'id'`` 500 错误;id 注入对 zhipu 的 Python 类 + (不读取 JSON 中的 id 字段) 亦无效。详见 docs/issue.md。 Returns: (prepared_body, adaptations) — adaptations 为应用的变换描述列表。 @@ -591,27 +596,12 @@ def prepare_copilot_to_zhipu( if stripped: adaptations.append(f"stripped_{stripped}_thinking_blocks") - # Step 2: 移除 cache_control 字段 - removed_cc = _strip_cache_control(prepared) - if removed_cc: - adaptations.append(f"removed_{removed_cc}_cache_control_fields") - - # Step 3: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持) + # Step 2: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持) for param in ("thinking", "extended_thinking"): if param in prepared: del prepared[param] adaptations.append(f"removed_{param}_param") - # Step 4: 强制 tool_use/tool_result 配对 - pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) - if pairing_fixes: - adaptations.extend(pairing_fixes) - - # Step 5: 为 tool_result 块注入 id 字段(zhipu 后端 bug workaround) - injected = _inject_tool_result_id_for_zhipu(prepared) - if injected: - adaptations.append(f"injected_{injected}_tool_result_id_fields") - return prepared, adaptations @@ -632,9 +622,11 @@ def prepare_anthropic_to_zhipu( Anthropic API 可能产生的非兼容产物: - ``server_tool_use`` blocks(web search / computer use 等 beta 功能) - ``thinking`` / ``redacted_thinking`` blocks(含 Anthropic 签发的 signature) - - ``cache_control`` 字段 - 顶层 ``thinking`` / ``extended_thinking`` 参数 + 注意: 不再移除 cache_control (GLM-5 支持) ,不再执行 tool pairing 和 + id 注入。原因同 prepare_copilot_to_zhipu 的 docstring。 + Returns: (prepared_body, adaptations) — adaptations 为应用的变换描述列表。 """ @@ -651,27 +643,12 @@ def prepare_anthropic_to_zhipu( if stripped: adaptations.append(f"stripped_{stripped}_thinking_blocks") - # Step 3: 移除 cache_control 字段 - removed_cc = _strip_cache_control(prepared) - if removed_cc: - adaptations.append(f"removed_{removed_cc}_cache_control_fields") - - # Step 4: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持) + # Step 3: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持) for param in ("thinking", "extended_thinking"): if param in prepared: del prepared[param] adaptations.append(f"removed_{param}_param") - # Step 5: 强制 tool_use/tool_result 配对 - pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) - if pairing_fixes: - adaptations.extend(pairing_fixes) - - # Step 6: 为 tool_result 块注入 id 字段(zhipu 后端 bug workaround) - injected = _inject_tool_result_id_for_zhipu(prepared) - if injected: - adaptations.append(f"injected_{injected}_tool_result_id_fields") - return prepared, adaptations @@ -782,25 +759,22 @@ def prepare_zhipu_to_anthropic( def prepare_zhipu_self_cleanup( body: dict[str, Any], ) -> tuple[dict[str, Any], list[str]]: - """zhipu → zhipu 自清理: 仅修复 zhipu 自身无法消化的产物. + """zhipu → zhipu 自清理: 仅剥离 zhipu 自身的流式残块. - GLM-5 偶发地在 assistant 消息中输出 ``tool_result`` 块(违反 Anthropic 规范), - 或在流式响应中暴露 ``server_tool_use_delta`` 私有块。当 Claude Code 将这些 - 产物原样回送下一轮请求时,zhipu 的 Anthropic 兼容端点会以 400 拒绝 - (表现为 "400 + tool_results" 偶发,进而触发到 copilot 的降级)。 + GLM-5 在流式响应中偶发暴露 ``server_tool_use_delta`` 私有块。当 Claude Code + 将这些产物原样回送下一轮请求时,zhipu 的 Anthropic 兼容端点会拒绝。 - 本通道仅修复 zhipu 自身拒绝的两类产物,**保留** 所有 zhipu 原生支持的特性: + 本通道**保留**所有 zhipu 原生支持的特性: - ✓ ``srvtoolu_*`` ID 与 ``server_tool_use`` 类型(zhipu 原生) - ✓ thinking blocks 的 zhipu 自签 signature - ✓ ``cache_control`` 字段(GLM Anthropic 端点支持,cache_read 已实证) - ✓ 顶层 ``thinking`` / ``extended_thinking`` 参数 + - ✓ tool_result 在 assistant 消息中内联(zhipu 自身偶发产出,可自行消化) - 清理操作(顺序、就地、幂等): - 1. 剥离 ``server_tool_use_delta`` 流式残块 - 2. 强制 tool_use/tool_result 配对(关键: 把 assistant 内联的 tool_result - 搬迁到紧随的 user 消息) - 3. 为 ``tool_result`` 块注入 ``id`` 字段(zhipu 后端错误访问 ``.id`` 属性) + 注意: 不再执行 enforce_anthropic_tool_pairing 和 _inject_tool_result_id_for_zhipu。 + 实证表明 tool_result 重定位会触发 zhipu 后端 500 错误。 + 详见 docs/issue.md。 Returns: (prepared_body, adaptations) — adaptations 为应用的变换描述列表。 @@ -813,16 +787,6 @@ def prepare_zhipu_self_cleanup( if removed_vendor_blocks: adaptations.append(f"removed_{removed_vendor_blocks}_zhipu_vendor_blocks") - # Step 2: 强制 tool_use/tool_result 配对 - pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) - if pairing_fixes: - adaptations.extend(pairing_fixes) - - # Step 3: 为 tool_result 块注入 id 字段(zhipu 后端 bug workaround) - injected = _inject_tool_result_id_for_zhipu(prepared) - if injected: - adaptations.append(f"injected_{injected}_tool_result_id_fields") - return prepared, adaptations diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 7f3193f..f4344a4 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -2034,7 +2034,12 @@ class TestPrepareBodyForTierSelfTransition: """验证 zhipu → zhipu 自转换通道在 _prepare_body_for_tier 中的应用行为.""" def test_applies_zhipu_self_cleanup(self): - """source=zhipu, target=zhipu → 剥离 server_tool_use_delta + tool pairing.""" + """source=zhipu, target=zhipu → 仅剥离 server_tool_use_delta. + + 不再做 tool pairing(搬迁 tool_result 会触发 zhipu 500), + 也不做 id 注入(zhipu 类不读取 JSON 中的 id)。 + inline tool_result 保留在 assistant 消息中,zhipu 可自行消化。 + """ tier = MagicMock() tier.name = "zhipu" @@ -2067,18 +2072,16 @@ def test_applies_zhipu_self_cleanup(self): assert result is not body assert len(body["messages"][0]["content"]) == 3 - # delta 块被剥离, tool_result 被搬迁出 assistant + # delta 块被剥离 assistant_content = result["messages"][0]["content"] - assert all( - b.get("type") not in ("server_tool_use_delta", "tool_result") - for b in assistant_content - ) - # tool_result 已搬到下一个 user 消息 - assert result["messages"][1]["role"] == "user" + assert all(b.get("type") != "server_tool_use_delta" for b in assistant_content) + # inline tool_result 保留在 assistant 中(不再搬迁) assert any( b.get("type") == "tool_result" and b.get("tool_use_id") == "srvtoolu_a" - for b in result["messages"][1]["content"] + for b in assistant_content ) + # 不应插入额外的 user 消息 + assert len(result["messages"]) == 1 def test_self_cleanup_preserves_srvtoolu_ids(self): """回归保护: 自清理通道不得改写 zhipu 原生 srvtoolu_* ID.""" diff --git a/tests/test_vendor_channels.py b/tests/test_vendor_channels.py index b89db15..8c8065c 100644 --- a/tests/test_vendor_channels.py +++ b/tests/test_vendor_channels.py @@ -244,7 +244,8 @@ def test_strips_thinking_blocks(self): # 原始 body 未被修改 assert body["messages"][0]["content"][0]["type"] == "thinking" - def test_removes_cache_control(self): + def test_preserves_cache_control(self): + """copilot → zhipu 不再移除 cache_control(zhipu 原生支持).""" body = { "system": [ {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}, @@ -252,8 +253,8 @@ def test_removes_cache_control(self): "messages": [], } prepared, adaptations = prepare_copilot_to_zhipu(body) - assert any("cache_control" in a for a in adaptations) - assert "cache_control" not in prepared["system"][0] + assert not any("cache_control" in a for a in adaptations) + assert "cache_control" in prepared["system"][0] def test_removes_thinking_params(self): body = { @@ -267,7 +268,8 @@ def test_removes_thinking_params(self): assert "removed_thinking_param" in adaptations assert "removed_extended_thinking_param" in adaptations - def test_enforces_tool_pairing(self): + def test_does_not_relocate_tool_results(self): + """copilot → zhipu 不再执行 tool pairing(避免触发 zhipu 500).""" body = { "messages": [ { @@ -288,14 +290,15 @@ def test_enforces_tool_pairing(self): ], } prepared, adaptations = prepare_copilot_to_zhipu(body) + # user 消息内容不变(无 synthesized tool_result) user_content = prepared["messages"][1]["content"] tool_results = [ b for b in user_content if isinstance(b, dict) and b.get("type") == "tool_result" ] - assert len(tool_results) == 1 - assert tool_results[0]["tool_use_id"] == "toolu_1" + assert len(tool_results) == 0 + assert not any("misplaced" in a for a in adaptations) def test_combined_transformations(self): body = { @@ -327,15 +330,17 @@ def test_combined_transformations(self): b.get("type") not in ("thinking", "redacted_thinking") for b in prepared["messages"][0]["content"] ) - assert "cache_control" not in prepared["system"][0] + # cache_control 保留 + assert "cache_control" in prepared["system"][0] assert "thinking" not in prepared + # tool pairing 不执行(user 消息内容不变) user_content = prepared["messages"][1]["content"] tool_results = [ b for b in user_content if isinstance(b, dict) and b.get("type") == "tool_result" ] - assert len(tool_results) == 1 + assert len(tool_results) == 0 def test_preserves_original_body(self): body = { @@ -758,8 +763,8 @@ def test_strips_server_tool_use_delta(self): assert all(b.get("type") != "server_tool_use_delta" for b in content) assert any("zhipu_vendor_blocks" in a for a in adaptations) - def test_relocates_misplaced_tool_result(self): - """assistant 内联 tool_result 应被搬迁到下一个 user 消息.""" + def test_preserves_inline_tool_result_in_assistant(self): + """assistant 内联 tool_result 保留原位(不再搬迁,避免触发 zhipu 500).""" body = { "messages": [ { @@ -783,49 +788,19 @@ def test_relocates_misplaced_tool_result(self): } prepared, adaptations = prepare_zhipu_self_cleanup(body) - # assistant 消息中应不再包含 tool_result + # assistant 消息中 tool_result 保留原位 assistant_content = prepared["messages"][0]["content"] - assert all(b.get("type") != "tool_result" for b in assistant_content) - # tool_result 已搬到下一个 user 消息 - user_content = prepared["messages"][1]["content"] assert any( b.get("type") == "tool_result" and b.get("tool_use_id") == "srvtoolu_a" - for b in user_content + for b in assistant_content ) - assert "misplaced_tool_result_relocated" in adaptations - - def test_injects_id_on_relocated_tool_result(self): - """搬迁后的 tool_result 块应具有 id 字段(zhipu 后端 bug workaround).""" - body = { - "messages": [ - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_001", - "name": "bash", - "input": {}, - }, - { - "type": "tool_result", - "tool_use_id": "toolu_001", - "content": "ok", - }, - ], - }, - {"role": "user", "content": []}, - ], - } - prepared, adaptations = prepare_zhipu_self_cleanup(body) - - user_content = prepared["messages"][1]["content"] - tr = next(b for b in user_content if b.get("type") == "tool_result") - assert tr["id"] == "toolu_001" - assert any("injected" in a and "tool_result_id" in a for a in adaptations) + # 不应有 tool pairing 相关的 adaptations + assert not any("misplaced" in a for a in adaptations) + assert not any("orphaned" in a for a in adaptations) + assert not any("injected" in a for a in adaptations) - def test_injects_id_on_existing_user_tool_result(self): - """user 消息中已有的 tool_result 也应被注入 id 字段.""" + def test_no_id_injection(self): + """自清理通道不再注入 id 字段(zhipu 类不读取,注入无效).""" body = { "messages": [ { @@ -2689,7 +2664,8 @@ def test_strips_thinking_blocks(self): {"type": "text", "text": "response"}, ] - def test_removes_cache_control(self): + def test_preserves_cache_control(self): + """anthropic → zhipu 不再移除 cache_control(zhipu 原生支持).""" body = { "system": [ {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}, @@ -2697,8 +2673,8 @@ def test_removes_cache_control(self): "messages": [], } prepared, adaptations = prepare_anthropic_to_zhipu(body) - assert any("cache_control" in a for a in adaptations) - assert "cache_control" not in prepared["system"][0] + assert not any("cache_control" in a for a in adaptations) + assert "cache_control" in prepared["system"][0] def test_removes_thinking_params(self): body = { @@ -2712,7 +2688,8 @@ def test_removes_thinking_params(self): assert "removed_thinking_param" in adaptations assert "removed_extended_thinking_param" in adaptations - def test_enforces_tool_pairing(self): + def test_does_not_relocate_tool_results(self): + """anthropic → zhipu 不再执行 tool pairing(避免触发 zhipu 500).""" body = { "messages": [ { @@ -2730,14 +2707,13 @@ def test_enforces_tool_pairing(self): ], } prepared, adaptations = prepare_anthropic_to_zhipu(body) - assert "orphaned_tool_use_repaired" in adaptations + assert not any("orphaned" in a for a in adaptations) user_results = [ b for b in prepared["messages"][1]["content"] if isinstance(b, dict) and b.get("type") == "tool_result" ] - assert len(user_results) == 1 - assert user_results[0]["tool_use_id"] == "toolu_1" + assert len(user_results) == 0 def test_preserves_original_body(self): body = { From 712cf3b22abc7480b25891f2a1170d5ea2d657df Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 29 Apr 2026 22:12:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(vendor-channels):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20zhipu=20=E7=A7=BB=E9=99=A4=20id=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E5=90=8E=E9=81=97=E7=95=99=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 处测试的断言仍检查旧行为(id 被注入 / cache_control 被移除 / tool_result 被搬迁), 导致 KeyError 或 AssertionError。统一更新为验证新语义:id 不注入、cache_control 保留、 inline tool_result 保留在 assistant 原位。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- tests/test_vendor_channels.py | 63 ++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/tests/test_vendor_channels.py b/tests/test_vendor_channels.py index 8c8065c..631ea98 100644 --- a/tests/test_vendor_channels.py +++ b/tests/test_vendor_channels.py @@ -397,8 +397,8 @@ def test_idempotency(self): assert prepared2 == prepared1 assert adaptations2 == [] - def test_injects_id_on_tool_result_for_zhipu(self): - """copilot → zhipu 转换后 tool_result 应包含 id 字段.""" + def test_no_id_injection_on_tool_result(self): + """copilot → zhipu 转换不再注入 id 字段(zhipu 类不读取,注入无效).""" body = { "messages": [ { @@ -426,8 +426,8 @@ def test_injects_id_on_tool_result_for_zhipu(self): } prepared, adaptations = prepare_copilot_to_zhipu(body) tr = prepared["messages"][1]["content"][0] - assert tr["id"] == "toolu_001" - assert any("injected" in a and "tool_result_id" in a for a in adaptations) + assert "id" not in tr + assert not any("injected" in a for a in adaptations) # ── zhipu → anthropic 转换通道测试 ──────────────────────────────── @@ -829,11 +829,11 @@ def test_no_id_injection(self): prepared, adaptations = prepare_zhipu_self_cleanup(body) tr = prepared["messages"][1]["content"][0] - assert tr["id"] == "toolu_001" - assert any("injected" in a and "tool_result_id" in a for a in adaptations) + assert "id" not in tr + assert not any("injected" in a for a in adaptations) - def test_skips_id_injection_when_already_present(self): - """tool_result 已有 id 字段时不应重复注入.""" + def test_preserves_existing_id(self): + """tool_result 已有 id 字段时应原样保留,不被修改.""" body = { "messages": [ { @@ -853,7 +853,7 @@ def test_skips_id_injection_when_already_present(self): { "type": "tool_result", "tool_use_id": "toolu_001", - "id": "toolu_001", + "id": "original_id", "content": "ok", }, ], @@ -861,7 +861,8 @@ def test_skips_id_injection_when_already_present(self): ], } prepared, adaptations = prepare_zhipu_self_cleanup(body) - # 不应产生注入 adaptation(id 已存在) + tr = prepared["messages"][1]["content"][0] + assert tr["id"] == "original_id" assert not any("injected" in a for a in adaptations) def test_preserves_srvtoolu_ids(self): @@ -1035,11 +1036,11 @@ def test_does_not_mutate_input(self): assert body == original def test_combined_artifacts(self): - """端到端: server_tool_use_delta 被剥, server_tool_use 保留, 错位 tool_result 搬迁. + """端到端: server_tool_use_delta 被剥, 其余保留原位. - 典型场景: Claude Code 的客户端工具 (Bash/Read 等) 以 ``tool_use`` 形式 - emit, 其错位的 ``tool_result`` 应被重定位; zhipu 原生 ``server_tool_use`` - 块不需要客户端 tool_result, 仅需保留原状. + 典型场景: zhipu 偶发在 assistant 消息中产出多种块。 + server_tool_use_delta 被剥离,其余块(含 inline tool_result)保留原位, + 不再做 tool pairing 和 id 注入。 """ body = { "messages": [ @@ -1073,8 +1074,11 @@ def test_combined_artifacts(self): assistant_content = prepared["messages"][0]["content"] # delta 被剥离 assert all(b.get("type") != "server_tool_use_delta" for b in assistant_content) - # 错位 tool_result 被搬出 assistant - assert all(b.get("type") != "tool_result" for b in assistant_content) + # inline tool_result 保留在 assistant 中(不再搬迁) + assert any( + b.get("type") == "tool_result" and b.get("tool_use_id") == "toolu_bash_001" + for b in assistant_content + ) # server_tool_use 与其 srvtoolu_* ID 完整保留 srv_block = next( b for b in assistant_content if b.get("type") == "server_tool_use" @@ -1085,15 +1089,13 @@ def test_combined_artifacts(self): b for b in assistant_content if b.get("type") == "tool_use" ) assert tool_use_block["id"] == "toolu_bash_001" - # 后续 user 消息已被插入并包含 tool_result - assert prepared["messages"][1]["role"] == "user" - assert any( - b.get("type") == "tool_result" and b.get("tool_use_id") == "toolu_bash_001" - for b in prepared["messages"][1]["content"] - ) - # 关键 adaptation 标签均出现 + # 不插入额外 user 消息 + assert len(prepared["messages"]) == 1 + # 关键 adaptation 标签 assert any("zhipu_vendor_blocks" in a for a in adaptations) - assert "misplaced_tool_result_relocated" in adaptations + # 不应有 tool pairing / id 注入 相关 adaptation + assert not any("misplaced" in a for a in adaptations) + assert not any("injected" in a for a in adaptations) # ── 转换注册表测试 ──────────────────────────────────────────── @@ -2823,7 +2825,7 @@ def test_inserts_placeholder_when_all_blocks_stripped(self): ] def test_combined_server_tool_use_and_thinking(self): - """server_tool_use + thinking + cache_control 的组合清洗.""" + """server_tool_use + thinking 的组合清洗, cache_control 保留.""" body = { "system": [ {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}, @@ -2850,13 +2852,14 @@ def test_combined_server_tool_use_and_thinking(self): b.get("type") not in ("thinking", "redacted_thinking", "server_tool_use") for b in prepared["messages"][0]["content"] ) - assert "cache_control" not in prepared["system"][0] + # cache_control 保留(zhipu 原生支持) + assert "cache_control" in prepared["system"][0] assert "thinking" not in prepared assert any("server_tool_use" in a for a in adaptations) assert any("thinking_blocks" in a for a in adaptations) - def test_injects_id_on_tool_result_for_zhipu(self): - """anthropic → zhipu 转换后 tool_result 应包含 id 字段.""" + def test_no_id_injection_on_tool_result(self): + """anthropic → zhipu 转换不再注入 id 字段(zhipu 类不读取,注入无效).""" body = { "messages": [ { @@ -2884,5 +2887,5 @@ def test_injects_id_on_tool_result_for_zhipu(self): } prepared, adaptations = prepare_anthropic_to_zhipu(body) tr = prepared["messages"][1]["content"][0] - assert tr["id"] == "toolu_001" - assert any("injected" in a and "tool_result_id" in a for a in adaptations) + assert "id" not in tr + assert not any("injected" in a for a in adaptations)