Skip to content

Commit 3e7cc9d

Browse files
committed
fix(zhipu): 修复首选 tier 语义拒绝降级问题,剥离 GLM 不支持的 Anthropic 扩展参数;
根因: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<threefish.ai@gmail.com>
1 parent e326bf6 commit 3e7cc9d

6 files changed

Lines changed: 312 additions & 30 deletions

File tree

‎src/coding/proxy/convert/vendor_channels.py‎

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,56 @@ def _strip_cache_control(body: dict[str, Any]) -> int:
367367
return removed
368368

369369

370+
# ── zhipu 共享清洗函数 ──────────────────────────────────────────
371+
372+
# GLM 的 Anthropic 兼容端点不支持以下顶层参数,透传会导致 400 invalid_request_error。
373+
_ZHIPU_UNSUPPORTED_PARAMS: frozenset[str] = frozenset(
374+
{"thinking", "extended_thinking", "reasoning_effort"}
375+
)
376+
377+
378+
def normalize_for_zhipu(body: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
379+
"""为 zhipu GLM 的 Anthropic 兼容端点清洗请求体(就地,不 deep copy).
380+
381+
作为 zhipu 兼容性清洗的单一事实源,同时服务于:
382+
- 首选 tier 场景(source_vendor=None,无跨供应商转换触发)
383+
- 跨供应商转换通道 ``prepare_copilot_to_zhipu``
384+
385+
清洗内容:
386+
1. 剥离 cache_control 字段(GLM 不支持 Anthropic prompt caching)
387+
2. 移除不支持的顶层参数(thinking / extended_thinking / reasoning_effort)
388+
3. 强制 tool_use/tool_result 配对约束
389+
390+
不包含 thinking blocks 剥离:首选 tier 时 history 中的 thinking blocks 来自
391+
zhipu 自身(签名有效);跨供应商场景由调用方(``prepare_copilot_to_zhipu``)
392+
在调用本函数之前单独处理。
393+
394+
所有操作均为幂等,安全地在已清洗的请求体上重复调用。
395+
396+
Returns:
397+
(body, adaptations) — body 为就地修改后的同一引用,adaptations 为变换描述列表。
398+
"""
399+
adaptations: list[str] = []
400+
401+
# Step 1: 剥离 cache_control
402+
removed_cc = _strip_cache_control(body)
403+
if removed_cc:
404+
adaptations.append(f"removed_{removed_cc}_cache_control_fields")
405+
406+
# Step 2: 移除不支持的顶层参数
407+
for param in _ZHIPU_UNSUPPORTED_PARAMS:
408+
if param in body:
409+
del body[param]
410+
adaptations.append(f"removed_{param}_param")
411+
412+
# Step 3: 强制 tool_use/tool_result 配对
413+
pairing_fixes = enforce_anthropic_tool_pairing(body.get("messages", []))
414+
if pairing_fixes:
415+
adaptations.extend(pairing_fixes)
416+
417+
return body, adaptations
418+
419+
370420
def _remove_vendor_blocks(body: dict[str, Any], block_types: set[str]) -> int:
371421
"""从 messages[].content[] 中就地移除指定 type 的内容块.
372422
@@ -544,26 +594,14 @@ def prepare_copilot_to_zhipu(
544594
prepared = copy.deepcopy(body)
545595
adaptations: list[str] = []
546596

547-
# Step 1: 剥离 thinking/redacted_thinking 块
597+
# Step 1: 剥离 thinking/redacted_thinking 块(跨供应商签名失效)
548598
stripped = strip_thinking_blocks(prepared)
549599
if stripped:
550600
adaptations.append(f"stripped_{stripped}_thinking_blocks")
551601

552-
# Step 2: 移除 cache_control 字段
553-
removed_cc = _strip_cache_control(prepared)
554-
if removed_cc:
555-
adaptations.append(f"removed_{removed_cc}_cache_control_fields")
556-
557-
# Step 3: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持)
558-
for param in ("thinking", "extended_thinking"):
559-
if param in prepared:
560-
del prepared[param]
561-
adaptations.append(f"removed_{param}_param")
562-
563-
# Step 4: 强制 tool_use/tool_result 配对
564-
pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", []))
565-
if pairing_fixes:
566-
adaptations.extend(pairing_fixes)
602+
# Step 2: 共享清洗(cache_control、不支持的顶层参数、tool pairing)
603+
_, norm_adaptations = normalize_for_zhipu(prepared)
604+
adaptations.extend(norm_adaptations)
567605

568606
return prepared, adaptations
569607

‎src/coding/proxy/routing/executor.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,9 +602,11 @@ async def execute_message(
602602

603603
if not is_last and is_semantic:
604604
logger.warning(
605-
"Tier %s semantic rejection (%s), trying next tier without recording failure",
605+
"Tier %s semantic rejection (type=%s, msg=%s), "
606+
"trying next tier without recording failure",
606607
tier.name,
607608
resp.error_type or resp.status_code,
609+
(resp.error_message or "N/A")[:200],
608610
)
609611
failed_tier_name = tier.name
610612
continue

‎src/coding/proxy/vendors/zhipu.py‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
1-
"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点薄透传代理.
1+
"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点透传代理.
22
33
官方端点 (https://open.bigmodel.cn/api/anthropic) 已完整支持
4-
Anthropic Messages API 协议,本模块仅做两项最小适配:
4+
Anthropic Messages API 协议,本模块仅做三项最小适配:
55
1. 模型名映射(Claude -> GLM)
66
2. 认证头替换(x-api-key)
7+
3. 请求参数清洗(剥离 GLM 不支持的 Anthropic 扩展字段)
78
"""
89

910
from __future__ import annotations
1011

12+
import logging
13+
from typing import Any
14+
1115
from ..config.schema import FailoverConfig, ZhipuConfig
1216
from ..routing.model_mapper import ModelMapper
1317
from .native_anthropic import NativeAnthropicVendor
1418

19+
logger = logging.getLogger(__name__)
20+
1521

1622
class ZhipuVendor(NativeAnthropicVendor):
17-
"""智谱 GLM 原生 Anthropic 兼容端点供应商(薄透传).
23+
"""智谱 GLM 原生 Anthropic 兼容端点供应商.
1824
1925
通过官方 /api/anthropic 端点转发请求,
20-
仅替换模型名和认证头,其余原样透传。
26+
替换模型名和认证头,并剥离 GLM 不支持的 Anthropic 扩展参数:
27+
- cache_control 字段(GLM 不支持 Anthropic prompt caching)
28+
- thinking / extended_thinking / reasoning_effort 顶层参数
2129
"""
2230

2331
_vendor_name = "zhipu"
@@ -31,6 +39,29 @@ def __init__(
3139
) -> None:
3240
super().__init__(config, model_mapper, failover_config)
3341

42+
async def _prepare_request(
43+
self,
44+
request_body: dict[str, Any],
45+
headers: dict[str, str],
46+
) -> tuple[dict[str, Any], dict[str, str]]:
47+
"""深拷贝 + 模型映射 + 认证头替换 + GLM 兼容性清洗.
48+
49+
在父类 deep copy + model mapping + header 替换的基础上,
50+
增加剥离 GLM 不支持的 Anthropic 扩展参数。
51+
"""
52+
body, new_headers = await super()._prepare_request(request_body, headers)
53+
54+
from ..convert.vendor_channels import normalize_for_zhipu
55+
56+
_, adaptations = normalize_for_zhipu(body)
57+
if adaptations:
58+
logger.debug(
59+
"zhipu: applied first-tier normalization: %s",
60+
", ".join(adaptations),
61+
)
62+
63+
return body, new_headers
64+
3465

3566
# 向后兼容别名
3667
ZhipuBackend = ZhipuVendor

‎tests/test_vendor_channels.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
enforce_anthropic_tool_pairing,
2323
get_transition_channel,
2424
infer_source_vendor_from_body,
25+
normalize_for_zhipu,
2526
prepare_copilot_to_zhipu,
2627
prepare_zhipu_to_anthropic,
2728
prepare_zhipu_to_copilot,
@@ -2147,3 +2148,80 @@ def test_rewrites_srvtoolu_and_strips_vendor_delta(self):
21472148
assert prepared["messages"][1]["content"][0]["tool_use_id"] == new_id
21482149
assert any("zhipu_vendor_blocks" in a for a in adaptations)
21492150
assert any("srvtoolu_ids" in a for a in adaptations)
2151+
2152+
2153+
# ── normalize_for_zhipu 共享清洗函数 ────────────────────────
2154+
2155+
2156+
class TestNormalizeForZhipu:
2157+
"""normalize_for_zhipu 共享清洗函数测试."""
2158+
2159+
def test_strips_cache_control_and_params(self):
2160+
body = {
2161+
"model": "claude-sonnet-4-20250514",
2162+
"messages": [],
2163+
"thinking": {"type": "enabled", "budget_tokens": 5000},
2164+
"extended_thinking": {"type": "enabled"},
2165+
"reasoning_effort": "high",
2166+
"system": [
2167+
{
2168+
"type": "text",
2169+
"text": "sys",
2170+
"cache_control": {"type": "ephemeral"},
2171+
},
2172+
],
2173+
"tools": [
2174+
{
2175+
"name": "Bash",
2176+
"input_schema": {"type": "object"},
2177+
"cache_control": {"type": "ephemeral"},
2178+
},
2179+
],
2180+
}
2181+
result, adaptations = normalize_for_zhipu(body)
2182+
2183+
assert "thinking" not in result
2184+
assert "extended_thinking" not in result
2185+
assert "reasoning_effort" not in result
2186+
assert "cache_control" not in result["system"][0]
2187+
assert "cache_control" not in result["tools"][0]
2188+
assert any("cache_control" in a for a in adaptations)
2189+
assert any("thinking" in a for a in adaptations)
2190+
assert any("reasoning_effort" in a for a in adaptations)
2191+
2192+
def test_operates_in_place(self):
2193+
body = {"model": "x", "messages": []}
2194+
result, _ = normalize_for_zhipu(body)
2195+
assert result is body
2196+
2197+
def test_idempotent(self):
2198+
body = {
2199+
"model": "x",
2200+
"messages": [],
2201+
"thinking": {"type": "enabled"},
2202+
}
2203+
normalize_for_zhipu(body)
2204+
_, adaptations = normalize_for_zhipu(body)
2205+
assert adaptations == []
2206+
2207+
def test_no_deep_copy(self):
2208+
messages = [{"role": "user", "content": "hi"}]
2209+
body = {"model": "x", "messages": messages}
2210+
result, _ = normalize_for_zhipu(body)
2211+
assert result["messages"] is messages
2212+
2213+
def test_preserves_supported_params(self):
2214+
body = {
2215+
"model": "x",
2216+
"messages": [{"role": "user", "content": "hello"}],
2217+
"max_tokens": 1024,
2218+
"temperature": 0.7,
2219+
"stream": True,
2220+
"metadata": {"user_id": "test"},
2221+
}
2222+
result, adaptations = normalize_for_zhipu(body)
2223+
assert result["max_tokens"] == 1024
2224+
assert result["temperature"] == 0.7
2225+
assert result["stream"] is True
2226+
assert result["metadata"] == {"user_id": "test"}
2227+
assert adaptations == []

‎tests/test_vendors.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,8 @@ async def test_zhipu_prepare_request_preserves_metadata():
395395

396396

397397
@pytest.mark.asyncio
398-
async def test_zhipu_prepare_request_preserves_thinking():
399-
"""ZhipuVendor._prepare_request 应原样保留 thinking 字段(原生端点支持)."""
398+
async def test_zhipu_prepare_request_strips_thinking():
399+
"""ZhipuVendor._prepare_request 应剥离 GLM 不支持的 thinking 顶层参数."""
400400
mapper = ModelMapper([])
401401
zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key="sk-test"), mapper)
402402
body = {
@@ -405,9 +405,9 @@ async def test_zhipu_prepare_request_preserves_thinking():
405405
"thinking": {"type": "enabled", "budget_tokens": 10000},
406406
}
407407
prepared_body, _ = await zhipu_vendor._prepare_request(body, {})
408-
# thinking 原样透传,不再剥离任何字段
409-
assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 10000}
410-
# 原始 body 不应被修改
408+
# thinking 被 GLM 兼容性清洗剥离
409+
assert "thinking" not in prepared_body
410+
# 原始 body 不应被修改(deep copy)
411411
assert body["thinking"]["budget_tokens"] == 10000
412412

413413

0 commit comments

Comments
 (0)