diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py index dadce2e..52b7f44 100644 --- a/src/coding/proxy/convert/vendor_channels.py +++ b/src/coding/proxy/convert/vendor_channels.py @@ -367,6 +367,57 @@ def _strip_cache_control(body: dict[str, Any]) -> int: return removed +# ── zhipu 共享清洗函数 ────────────────────────────────────────── + +# 跨供应商转换时主动剥离的顶层参数(首选 tier 场景由 _prepare_request 原样透传, +# GLM 原生支持 thinking / 静默忽略 cache_control 和 reasoning_effort,不会触发 400)。 +_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). + + 为跨供应商转换通道 ``prepare_copilot_to_zhipu`` 提供请求体清洗。 + + 清洗内容: + 1. 剥离 cache_control 字段(GLM 静默忽略,主动剥离以减少噪音) + 2. 移除顶层 thinking/extended_thinking/reasoning_effort 参数(GLM 原生支持 + thinking、静默忽略 reasoning_effort,但跨供应商场景下这些参数来自原供应商 + 的协议语义,主动剥离以确保请求语义一致性) + 3. 强制 tool_use/tool_result 配对约束 + + 不包含 thinking blocks 剥离:跨供应商场景下 history 中的 thinking blocks + 来自原供应商(签名失效),由调用方在调用本函数之前通过 + ``strip_thinking_blocks`` 单独处理。 + + 所有操作均为幂等,安全地在已清洗的请求体上重复调用。 + + 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 +595,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 e6b4680..e7ed8c7 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -5,6 +5,12 @@ 1. 模型名映射(Claude -> GLM) 2. 认证头替换(x-api-key) +注意:实测验证 GLM 的 Anthropic 兼容端点对以下参数的处理方式: +- thinking 参数:原生支持(GLM 有自己的 thinking 机制) +- cache_control 字段:静默忽略(GLM 使用隐式自动缓存) +- reasoning_effort 参数:静默忽略 +以上参数均不会导致 400 错误,因此不需要在 _prepare_request 中剥离。 + 额外提供 429 Rate Limit 专用重试挽回机制: - max_attempt = 5(1 初始 + 4 重试) - 指数退避 + Full Jitter(1s → 2s → 4s → 8s) 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..3ac0477 100644 --- a/tests/test_vendors.py +++ b/tests/test_vendors.py @@ -396,7 +396,7 @@ async def test_zhipu_prepare_request_preserves_metadata(): @pytest.mark.asyncio async def test_zhipu_prepare_request_preserves_thinking(): - """ZhipuVendor._prepare_request 应原样保留 thinking 字段(原生端点支持).""" + """ZhipuVendor._prepare_request 应原样保留 thinking 字段(GLM 原生支持).""" mapper = ModelMapper([]) zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key="sk-test"), mapper) body = { @@ -405,7 +405,7 @@ async def test_zhipu_prepare_request_preserves_thinking(): "thinking": {"type": "enabled", "budget_tokens": 10000}, } prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) - # thinking 原样透传,不再剥离任何字段 + # 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 8d010b3..aef567a 100644 --- a/tests/test_zhipu.py +++ b/tests/test_zhipu.py @@ -103,20 +103,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 - # thinking 不再被剥离 assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 5000} - # metadata 不再被剥离 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"