From 3e7cc9dddc11a812ac313a40ce7a50781d88897f Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 23 May 2026 17:32:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(zhipu):=20=E4=BF=AE=E5=A4=8D=E9=A6=96?= =?UTF-8?q?=E9=80=89=20tier=20=E8=AF=AD=E4=B9=89=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E9=97=AE=E9=A2=98=EF=BC=8C=E5=89=A5=E7=A6=BB?= =?UTF-8?q?=20GLM=20=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=20Anthropic=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=8F=82=E6=95=B0;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:zhipu 作为首选 tier 时 source_vendor=None,不触发跨供应商转换通道, 原始请求体含 cache_control/thinking/reasoning_effort 等 GLM 不支持的参数, 导致 400 invalid_request_error 降级到 copilot 浪费 token。 改动: - 新增 normalize_for_zhipu() 共享清洗函数作为 zhipu 兼容性单一事实源 - ZhipuVendor._prepare_request() 覆写应用 GLM 兼容性清洗 - 重构 prepare_copilot_to_zhipu() 委托给 normalize_for_zhipu() 消除重复 - 增强 execute_message 语义拒绝日志输出 error_message 🤖 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 --- src/coding/proxy/convert/vendor_channels.py | 70 +++++++--- src/coding/proxy/routing/executor.py | 4 +- src/coding/proxy/vendors/zhipu.py | 39 +++++- tests/test_vendor_channels.py | 78 +++++++++++ tests/test_vendors.py | 10 +- tests/test_zhipu.py | 141 +++++++++++++++++++- 6 files changed, 312 insertions(+), 30 deletions(-) diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py index dadce2e..5559f71 100644 --- a/src/coding/proxy/convert/vendor_channels.py +++ b/src/coding/proxy/convert/vendor_channels.py @@ -367,6 +367,56 @@ def _strip_cache_control(body: dict[str, Any]) -> int: return removed +# ── zhipu 共享清洗函数 ────────────────────────────────────────── + +# GLM 的 Anthropic 兼容端点不支持以下顶层参数,透传会导致 400 invalid_request_error。 +_ZHIPU_UNSUPPORTED_PARAMS: frozenset[str] = frozenset( + {"thinking", "extended_thinking", "reasoning_effort"} +) + + +def normalize_for_zhipu(body: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """为 zhipu GLM 的 Anthropic 兼容端点清洗请求体(就地,不 deep copy). + + 作为 zhipu 兼容性清洗的单一事实源,同时服务于: + - 首选 tier 场景(source_vendor=None,无跨供应商转换触发) + - 跨供应商转换通道 ``prepare_copilot_to_zhipu`` + + 清洗内容: + 1. 剥离 cache_control 字段(GLM 不支持 Anthropic prompt caching) + 2. 移除不支持的顶层参数(thinking / extended_thinking / reasoning_effort) + 3. 强制 tool_use/tool_result 配对约束 + + 不包含 thinking blocks 剥离:首选 tier 时 history 中的 thinking blocks 来自 + zhipu 自身(签名有效);跨供应商场景由调用方(``prepare_copilot_to_zhipu``) + 在调用本函数之前单独处理。 + + 所有操作均为幂等,安全地在已清洗的请求体上重复调用。 + + Returns: + (body, adaptations) — body 为就地修改后的同一引用,adaptations 为变换描述列表。 + """ + adaptations: list[str] = [] + + # Step 1: 剥离 cache_control + removed_cc = _strip_cache_control(body) + if removed_cc: + adaptations.append(f"removed_{removed_cc}_cache_control_fields") + + # Step 2: 移除不支持的顶层参数 + for param in _ZHIPU_UNSUPPORTED_PARAMS: + if param in body: + del body[param] + adaptations.append(f"removed_{param}_param") + + # Step 3: 强制 tool_use/tool_result 配对 + pairing_fixes = enforce_anthropic_tool_pairing(body.get("messages", [])) + if pairing_fixes: + adaptations.extend(pairing_fixes) + + return body, adaptations + + def _remove_vendor_blocks(body: dict[str, Any], block_types: set[str]) -> int: """从 messages[].content[] 中就地移除指定 type 的内容块. @@ -544,26 +594,14 @@ def prepare_copilot_to_zhipu( prepared = copy.deepcopy(body) adaptations: list[str] = [] - # Step 1: 剥离 thinking/redacted_thinking 块 + # Step 1: 剥离 thinking/redacted_thinking 块(跨供应商签名失效) stripped = strip_thinking_blocks(prepared) 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 不支持) - 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 2: 共享清洗(cache_control、不支持的顶层参数、tool pairing) + _, norm_adaptations = normalize_for_zhipu(prepared) + adaptations.extend(norm_adaptations) return prepared, adaptations diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 9d33ca9..7eac6c3 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -602,9 +602,11 @@ async def execute_message( if not is_last and is_semantic: logger.warning( - "Tier %s semantic rejection (%s), trying next tier without recording failure", + "Tier %s semantic rejection (type=%s, msg=%s), " + "trying next tier without recording failure", tier.name, resp.error_type or resp.status_code, + (resp.error_message or "N/A")[:200], ) failed_tier_name = tier.name continue diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index 528cabf..2054af7 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -1,23 +1,31 @@ -"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点薄透传代理. +"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点透传代理. 官方端点 (https://open.bigmodel.cn/api/anthropic) 已完整支持 -Anthropic Messages API 协议,本模块仅做两项最小适配: +Anthropic Messages API 协议,本模块仅做三项最小适配: 1. 模型名映射(Claude -> GLM) 2. 认证头替换(x-api-key) + 3. 请求参数清洗(剥离 GLM 不支持的 Anthropic 扩展字段) """ from __future__ import annotations +import logging +from typing import Any + from ..config.schema import FailoverConfig, ZhipuConfig from ..routing.model_mapper import ModelMapper from .native_anthropic import NativeAnthropicVendor +logger = logging.getLogger(__name__) + class ZhipuVendor(NativeAnthropicVendor): - """智谱 GLM 原生 Anthropic 兼容端点供应商(薄透传). + """智谱 GLM 原生 Anthropic 兼容端点供应商. 通过官方 /api/anthropic 端点转发请求, - 仅替换模型名和认证头,其余原样透传。 + 替换模型名和认证头,并剥离 GLM 不支持的 Anthropic 扩展参数: + - cache_control 字段(GLM 不支持 Anthropic prompt caching) + - thinking / extended_thinking / reasoning_effort 顶层参数 """ _vendor_name = "zhipu" @@ -31,6 +39,29 @@ def __init__( ) -> None: super().__init__(config, model_mapper, failover_config) + async def _prepare_request( + self, + request_body: dict[str, Any], + headers: dict[str, str], + ) -> tuple[dict[str, Any], dict[str, str]]: + """深拷贝 + 模型映射 + 认证头替换 + GLM 兼容性清洗. + + 在父类 deep copy + model mapping + header 替换的基础上, + 增加剥离 GLM 不支持的 Anthropic 扩展参数。 + """ + body, new_headers = await super()._prepare_request(request_body, headers) + + from ..convert.vendor_channels import normalize_for_zhipu + + _, adaptations = normalize_for_zhipu(body) + if adaptations: + logger.debug( + "zhipu: applied first-tier normalization: %s", + ", ".join(adaptations), + ) + + return body, new_headers + # 向后兼容别名 ZhipuBackend = ZhipuVendor diff --git a/tests/test_vendor_channels.py b/tests/test_vendor_channels.py index 405fa30..f9c9bb5 100644 --- a/tests/test_vendor_channels.py +++ b/tests/test_vendor_channels.py @@ -22,6 +22,7 @@ enforce_anthropic_tool_pairing, get_transition_channel, infer_source_vendor_from_body, + normalize_for_zhipu, prepare_copilot_to_zhipu, prepare_zhipu_to_anthropic, prepare_zhipu_to_copilot, @@ -2147,3 +2148,80 @@ def test_rewrites_srvtoolu_and_strips_vendor_delta(self): assert prepared["messages"][1]["content"][0]["tool_use_id"] == new_id assert any("zhipu_vendor_blocks" in a for a in adaptations) assert any("srvtoolu_ids" in a for a in adaptations) + + +# ── normalize_for_zhipu 共享清洗函数 ──────────────────────── + + +class TestNormalizeForZhipu: + """normalize_for_zhipu 共享清洗函数测试.""" + + def test_strips_cache_control_and_params(self): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 5000}, + "extended_thinking": {"type": "enabled"}, + "reasoning_effort": "high", + "system": [ + { + "type": "text", + "text": "sys", + "cache_control": {"type": "ephemeral"}, + }, + ], + "tools": [ + { + "name": "Bash", + "input_schema": {"type": "object"}, + "cache_control": {"type": "ephemeral"}, + }, + ], + } + result, adaptations = normalize_for_zhipu(body) + + assert "thinking" not in result + assert "extended_thinking" not in result + assert "reasoning_effort" not in result + assert "cache_control" not in result["system"][0] + assert "cache_control" not in result["tools"][0] + assert any("cache_control" in a for a in adaptations) + assert any("thinking" in a for a in adaptations) + assert any("reasoning_effort" in a for a in adaptations) + + def test_operates_in_place(self): + body = {"model": "x", "messages": []} + result, _ = normalize_for_zhipu(body) + assert result is body + + def test_idempotent(self): + body = { + "model": "x", + "messages": [], + "thinking": {"type": "enabled"}, + } + normalize_for_zhipu(body) + _, adaptations = normalize_for_zhipu(body) + assert adaptations == [] + + def test_no_deep_copy(self): + messages = [{"role": "user", "content": "hi"}] + body = {"model": "x", "messages": messages} + result, _ = normalize_for_zhipu(body) + assert result["messages"] is messages + + def test_preserves_supported_params(self): + body = { + "model": "x", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 1024, + "temperature": 0.7, + "stream": True, + "metadata": {"user_id": "test"}, + } + result, adaptations = normalize_for_zhipu(body) + assert result["max_tokens"] == 1024 + assert result["temperature"] == 0.7 + assert result["stream"] is True + assert result["metadata"] == {"user_id": "test"} + assert adaptations == [] diff --git a/tests/test_vendors.py b/tests/test_vendors.py index f771e9b..3b9b79a 100644 --- a/tests/test_vendors.py +++ b/tests/test_vendors.py @@ -395,8 +395,8 @@ async def test_zhipu_prepare_request_preserves_metadata(): @pytest.mark.asyncio -async def test_zhipu_prepare_request_preserves_thinking(): - """ZhipuVendor._prepare_request 应原样保留 thinking 字段(原生端点支持).""" +async def test_zhipu_prepare_request_strips_thinking(): + """ZhipuVendor._prepare_request 应剥离 GLM 不支持的 thinking 顶层参数.""" mapper = ModelMapper([]) zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key="sk-test"), mapper) body = { @@ -405,9 +405,9 @@ async def test_zhipu_prepare_request_preserves_thinking(): "thinking": {"type": "enabled", "budget_tokens": 10000}, } prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - # thinking 原样透传,不再剥离任何字段 - assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 10000} - # 原始 body 不应被修改 + # thinking 被 GLM 兼容性清洗剥离 + assert "thinking" not in prepared_body + # 原始 body 不应被修改(deep copy) assert body["thinking"]["budget_tokens"] == 10000 diff --git a/tests/test_zhipu.py b/tests/test_zhipu.py index 2eceb41..4b510f9 100644 --- a/tests/test_zhipu.py +++ b/tests/test_zhipu.py @@ -99,11 +99,11 @@ async def test_body_passthrough_except_model(self, zhipu_vendor): assert prepared_body["temperature"] == 0.7 assert prepared_body["top_p"] == 0.9 assert prepared_body["stream"] is True - # thinking 不再被剥离 - assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 5000} - # metadata 不再被剥离 + # GLM 不支持的顶层参数被剥离 + assert "thinking" not in prepared_body + # metadata 保留 assert prepared_body["metadata"] == {"user_id": "test-user"} - # system 不被删除 + # system 保留 assert prepared_body["system"] == "You are a helpful assistant." # tools 不被截断或过滤 assert len(prepared_body["tools"]) == 3 @@ -292,3 +292,136 @@ def test_never_triggers_failover(self, zhipu_vendor): async def test_health_check_always_true(self, zhipu_vendor): result = await zhipu_vendor.check_health() assert result is True + + +# ── 请求参数清洗 ────────────────────────────────────────── + + +class TestRequestNormalization: + """验证 _prepare_request 中 GLM 兼容性清洗行为.""" + + @pytest.mark.asyncio + async def test_strips_cache_control_from_system(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "system": [ + { + "type": "text", + "text": "You are helpful", + "cache_control": {"type": "ephemeral"}, + }, + ], + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + system = prepared_body["system"] + assert isinstance(system, list) + assert "cache_control" not in system[0] + assert system[0]["text"] == "You are helpful" + + @pytest.mark.asyncio + async def test_strips_cache_control_from_tools(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "tools": [ + { + "name": "Bash", + "input_schema": {"type": "object"}, + "cache_control": {"type": "ephemeral"}, + }, + ], + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + assert "cache_control" not in prepared_body["tools"][0] + assert prepared_body["tools"][0]["name"] == "Bash" + + @pytest.mark.asyncio + async def test_strips_cache_control_from_messages(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hello", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + msg_content = prepared_body["messages"][0]["content"] + assert isinstance(msg_content, list) + assert "cache_control" not in msg_content[0] + assert msg_content[0]["text"] == "hello" + + @pytest.mark.asyncio + async def test_removes_thinking_param(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 5000}, + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + assert "thinking" not in prepared_body + + @pytest.mark.asyncio + async def test_removes_extended_thinking_param(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "extended_thinking": {"type": "enabled"}, + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + assert "extended_thinking" not in prepared_body + + @pytest.mark.asyncio + async def test_removes_reasoning_effort_param(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "reasoning_effort": "high", + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + assert "reasoning_effort" not in prepared_body + + @pytest.mark.asyncio + async def test_preserves_other_params(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 1024, + "temperature": 0.7, + "stream": True, + "stop_sequences": ["\n"], + "metadata": {"user_id": "test"}, + } + prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) + assert prepared_body["max_tokens"] == 1024 + assert prepared_body["temperature"] == 0.7 + assert prepared_body["stream"] is True + assert prepared_body["stop_sequences"] == ["\n"] + assert prepared_body["metadata"] == {"user_id": "test"} + + @pytest.mark.asyncio + async def test_original_body_not_mutated(self, zhipu_vendor): + body = { + "model": "claude-sonnet-4-20250514", + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 5000}, + "system": [ + { + "type": "text", + "text": "prompt", + "cache_control": {"type": "ephemeral"}, + }, + ], + } + await zhipu_vendor._prepare_request(body, {}) + assert body["model"] == "claude-sonnet-4-20250514" + assert body["thinking"] == {"type": "enabled", "budget_tokens": 5000} + assert "cache_control" in body["system"][0] From 500672440faecf46eba2ee0d15852fcddad2a448 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 23 May 2026 17:52:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?revert(zhipu):=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=9A=84=20=5Fprepare=5Frequest=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=B8=85=E6=B4=97=EF=BC=8C=E5=AE=9E=E6=B5=8B=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=20GLM=20=E5=8E=9F=E7=94=9F=E6=94=AF=E6=8C=81=20thinki?= =?UTF-8?q?ng=20=E5=B9=B6=E9=9D=99=E9=BB=98=E5=BF=BD=E7=95=A5=20cache=5Fco?= =?UTF-8?q?ntrol/reasoning=5Feffort;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 curl 实证测试确认 zhipu Anthropic 兼容端点对以下参数的处理: - thinking 参数:200 OK,GLM 原生支持并返回 thinking content block - cache_control 字段:200 OK,被静默忽略(GLM 使用隐式自动缓存) - reasoning_effort 参数:200 OK,被静默忽略 - extended_thinking 参数:200 OK,被静默忽略 - redacted_thinking block:200 OK,被静默忽略 - 空 messages:400 invalid_request_error [1214][输入不能为空] 根因需进一步诊断:保留 normalize_for_zhipu 共享函数和 executor 日志增强, 待收集到 zhipu 返回的完整错误响应体后定位真正的 400 原因。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](${S}) Co-Authored-By: Aurelius Huang --- src/coding/proxy/vendors/zhipu.py | 45 +++------- tests/test_vendors.py | 10 +-- tests/test_zhipu.py | 142 +----------------------------- 3 files changed, 17 insertions(+), 180 deletions(-) diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index 2054af7..fe1d053 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -1,31 +1,29 @@ -"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点透传代理. +"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点薄透传代理. 官方端点 (https://open.bigmodel.cn/api/anthropic) 已完整支持 -Anthropic Messages API 协议,本模块仅做三项最小适配: +Anthropic Messages API 协议,本模块仅做两项最小适配: 1. 模型名映射(Claude -> GLM) 2. 认证头替换(x-api-key) - 3. 请求参数清洗(剥离 GLM 不支持的 Anthropic 扩展字段) + +注意:实测验证 GLM 的 Anthropic 兼容端点对以下参数的处理方式: +- thinking 参数:原生支持(GLM 有自己的 thinking 机制) +- cache_control 字段:静默忽略(GLM 使用隐式自动缓存) +- reasoning_effort 参数:静默忽略 +以上参数均不会导致 400 错误,因此不需要在 _prepare_request 中剥离。 """ from __future__ import annotations -import logging -from typing import Any - from ..config.schema import FailoverConfig, ZhipuConfig from ..routing.model_mapper import ModelMapper from .native_anthropic import NativeAnthropicVendor -logger = logging.getLogger(__name__) - class ZhipuVendor(NativeAnthropicVendor): - """智谱 GLM 原生 Anthropic 兼容端点供应商. + """智谱 GLM 原生 Anthropic 兼容端点供应商(薄透传). 通过官方 /api/anthropic 端点转发请求, - 替换模型名和认证头,并剥离 GLM 不支持的 Anthropic 扩展参数: - - cache_control 字段(GLM 不支持 Anthropic prompt caching) - - thinking / extended_thinking / reasoning_effort 顶层参数 + 仅替换模型名和认证头,其余原样透传。 """ _vendor_name = "zhipu" @@ -39,29 +37,6 @@ def __init__( ) -> None: super().__init__(config, model_mapper, failover_config) - async def _prepare_request( - self, - request_body: dict[str, Any], - headers: dict[str, str], - ) -> tuple[dict[str, Any], dict[str, str]]: - """深拷贝 + 模型映射 + 认证头替换 + GLM 兼容性清洗. - - 在父类 deep copy + model mapping + header 替换的基础上, - 增加剥离 GLM 不支持的 Anthropic 扩展参数。 - """ - body, new_headers = await super()._prepare_request(request_body, headers) - - from ..convert.vendor_channels import normalize_for_zhipu - - _, adaptations = normalize_for_zhipu(body) - if adaptations: - logger.debug( - "zhipu: applied first-tier normalization: %s", - ", ".join(adaptations), - ) - - return body, new_headers - # 向后兼容别名 ZhipuBackend = ZhipuVendor diff --git a/tests/test_vendors.py b/tests/test_vendors.py index 3b9b79a..3ac0477 100644 --- a/tests/test_vendors.py +++ b/tests/test_vendors.py @@ -395,8 +395,8 @@ async def test_zhipu_prepare_request_preserves_metadata(): @pytest.mark.asyncio -async def test_zhipu_prepare_request_strips_thinking(): - """ZhipuVendor._prepare_request 应剥离 GLM 不支持的 thinking 顶层参数.""" +async def test_zhipu_prepare_request_preserves_thinking(): + """ZhipuVendor._prepare_request 应原样保留 thinking 字段(GLM 原生支持).""" mapper = ModelMapper([]) zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key="sk-test"), mapper) body = { @@ -405,9 +405,9 @@ async def test_zhipu_prepare_request_strips_thinking(): "thinking": {"type": "enabled", "budget_tokens": 10000}, } prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - # thinking 被 GLM 兼容性清洗剥离 - assert "thinking" not in prepared_body - # 原始 body 不应被修改(deep copy) + # thinking 原样透传(GLM 原生支持 thinking) + assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 10000} + # 原始 body 不应被修改 assert body["thinking"]["budget_tokens"] == 10000 diff --git a/tests/test_zhipu.py b/tests/test_zhipu.py index 4b510f9..eb36b31 100644 --- a/tests/test_zhipu.py +++ b/tests/test_zhipu.py @@ -94,20 +94,15 @@ async def test_body_passthrough_except_model(self, zhipu_vendor): # 仅 model 被映射 assert prepared_body["model"] == "glm-5.1" - # 其余字段原样保留 + # 其余字段原样保留(GLM 原生支持 thinking,静默忽略 cache_control) assert prepared_body["max_tokens"] == 1024 assert prepared_body["temperature"] == 0.7 assert prepared_body["top_p"] == 0.9 assert prepared_body["stream"] is True - # GLM 不支持的顶层参数被剥离 - assert "thinking" not in prepared_body - # metadata 保留 + assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 5000} assert prepared_body["metadata"] == {"user_id": "test-user"} - # system 保留 assert prepared_body["system"] == "You are a helpful assistant." - # tools 不被截断或过滤 assert len(prepared_body["tools"]) == 3 - # tool_choice 不被修改 assert prepared_body["tool_choice"] == {"type": "auto"} # 原始 body 未被修改(deep copy) assert body["model"] == "claude-sonnet-4-20250514" @@ -292,136 +287,3 @@ def test_never_triggers_failover(self, zhipu_vendor): async def test_health_check_always_true(self, zhipu_vendor): result = await zhipu_vendor.check_health() assert result is True - - -# ── 请求参数清洗 ────────────────────────────────────────── - - -class TestRequestNormalization: - """验证 _prepare_request 中 GLM 兼容性清洗行为.""" - - @pytest.mark.asyncio - async def test_strips_cache_control_from_system(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [], - "system": [ - { - "type": "text", - "text": "You are helpful", - "cache_control": {"type": "ephemeral"}, - }, - ], - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - system = prepared_body["system"] - assert isinstance(system, list) - assert "cache_control" not in system[0] - assert system[0]["text"] == "You are helpful" - - @pytest.mark.asyncio - async def test_strips_cache_control_from_tools(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [], - "tools": [ - { - "name": "Bash", - "input_schema": {"type": "object"}, - "cache_control": {"type": "ephemeral"}, - }, - ], - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - assert "cache_control" not in prepared_body["tools"][0] - assert prepared_body["tools"][0]["name"] == "Bash" - - @pytest.mark.asyncio - async def test_strips_cache_control_from_messages(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "hello", - "cache_control": {"type": "ephemeral"}, - }, - ], - }, - ], - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - msg_content = prepared_body["messages"][0]["content"] - assert isinstance(msg_content, list) - assert "cache_control" not in msg_content[0] - assert msg_content[0]["text"] == "hello" - - @pytest.mark.asyncio - async def test_removes_thinking_param(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [], - "thinking": {"type": "enabled", "budget_tokens": 5000}, - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - assert "thinking" not in prepared_body - - @pytest.mark.asyncio - async def test_removes_extended_thinking_param(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [], - "extended_thinking": {"type": "enabled"}, - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - assert "extended_thinking" not in prepared_body - - @pytest.mark.asyncio - async def test_removes_reasoning_effort_param(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [], - "reasoning_effort": "high", - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - assert "reasoning_effort" not in prepared_body - - @pytest.mark.asyncio - async def test_preserves_other_params(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "hello"}], - "max_tokens": 1024, - "temperature": 0.7, - "stream": True, - "stop_sequences": ["\n"], - "metadata": {"user_id": "test"}, - } - prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - assert prepared_body["max_tokens"] == 1024 - assert prepared_body["temperature"] == 0.7 - assert prepared_body["stream"] is True - assert prepared_body["stop_sequences"] == ["\n"] - assert prepared_body["metadata"] == {"user_id": "test"} - - @pytest.mark.asyncio - async def test_original_body_not_mutated(self, zhipu_vendor): - body = { - "model": "claude-sonnet-4-20250514", - "messages": [], - "thinking": {"type": "enabled", "budget_tokens": 5000}, - "system": [ - { - "type": "text", - "text": "prompt", - "cache_control": {"type": "ephemeral"}, - }, - ], - } - await zhipu_vendor._prepare_request(body, {}) - assert body["model"] == "claude-sonnet-4-20250514" - assert body["thinking"] == {"type": "enabled", "budget_tokens": 5000} - assert "cache_control" in body["system"][0] From 4578b9d9c167e9164a8760a714780d22b09d9826 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 23 May 2026 18:04:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(vendor-channels):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20normalize=5Ffor=5Fzhipu=20=E6=B3=A8=E9=87=8A=E4=B8=8E=20docs?= =?UTF-8?q?tring=EF=BC=8C=E6=B6=88=E9=99=A4=E4=B8=8E=20zhipu.py=20?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E7=BB=93=E8=AE=BA=E7=9A=84=E7=9F=9B=E7=9B=BE?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ZHIPU_UNSUPPORTED_PARAMS 注释错误声称参数会导致 400 错误,实际 GLM 原生支持 thinking 并静默忽略其余参数; docstring 声称服务于"首选 tier 场景"但无此类调用方,已更正为仅描述跨供应商转换通道的实际用途。 🤖 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 --- src/coding/proxy/convert/vendor_channels.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py index 5559f71..52b7f44 100644 --- a/src/coding/proxy/convert/vendor_channels.py +++ b/src/coding/proxy/convert/vendor_channels.py @@ -369,7 +369,8 @@ def _strip_cache_control(body: dict[str, Any]) -> int: # ── zhipu 共享清洗函数 ────────────────────────────────────────── -# GLM 的 Anthropic 兼容端点不支持以下顶层参数,透传会导致 400 invalid_request_error。 +# 跨供应商转换时主动剥离的顶层参数(首选 tier 场景由 _prepare_request 原样透传, +# GLM 原生支持 thinking / 静默忽略 cache_control 和 reasoning_effort,不会触发 400)。 _ZHIPU_UNSUPPORTED_PARAMS: frozenset[str] = frozenset( {"thinking", "extended_thinking", "reasoning_effort"} ) @@ -378,18 +379,18 @@ def _strip_cache_control(body: dict[str, Any]) -> int: def normalize_for_zhipu(body: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: """为 zhipu GLM 的 Anthropic 兼容端点清洗请求体(就地,不 deep copy). - 作为 zhipu 兼容性清洗的单一事实源,同时服务于: - - 首选 tier 场景(source_vendor=None,无跨供应商转换触发) - - 跨供应商转换通道 ``prepare_copilot_to_zhipu`` + 为跨供应商转换通道 ``prepare_copilot_to_zhipu`` 提供请求体清洗。 清洗内容: - 1. 剥离 cache_control 字段(GLM 不支持 Anthropic prompt caching) - 2. 移除不支持的顶层参数(thinking / extended_thinking / reasoning_effort) + 1. 剥离 cache_control 字段(GLM 静默忽略,主动剥离以减少噪音) + 2. 移除顶层 thinking/extended_thinking/reasoning_effort 参数(GLM 原生支持 + thinking、静默忽略 reasoning_effort,但跨供应商场景下这些参数来自原供应商 + 的协议语义,主动剥离以确保请求语义一致性) 3. 强制 tool_use/tool_result 配对约束 - 不包含 thinking blocks 剥离:首选 tier 时 history 中的 thinking blocks 来自 - zhipu 自身(签名有效);跨供应商场景由调用方(``prepare_copilot_to_zhipu``) - 在调用本函数之前单独处理。 + 不包含 thinking blocks 剥离:跨供应商场景下 history 中的 thinking blocks + 来自原供应商(签名失效),由调用方在调用本函数之前通过 + ``strip_thinking_blocks`` 单独处理。 所有操作均为幂等,安全地在已清洗的请求体上重复调用。