Skip to content

Commit 158de95

Browse files
authored
Merge pull request #202 from ThreeFish-AI/vk/89ae-fix-zhipu-anthro
fix(vendor-channels): 修复 anthropic→zhipu 跨供应商转换缺失导致 zhipu 400 错误
2 parents cf5a3c2 + 2933381 commit 158de95

6 files changed

Lines changed: 498 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
## [Unreleased]
66

7+
### Bug Fixes
8+
9+
- fix(vendor-channels): 新增 `anthropic → zhipu` 跨供应商转换通道,修复 Anthropic beta 功能(web search, computer use)产生的 `server_tool_use` 块导致 zhipu 400 错误的问题;
10+
- fix(error-classifier): 增强语义拒绝检测,识别 zhipu 等供应商返回的中文错误消息(如「API 调用参数有误」code=1210),确保正确触发故障转移;
11+
- fix(vendor-channels): `_remove_vendor_blocks` 增加空内容占位保护,防止内容块全部剥离后消息结构不合法。
12+
713
## [v0.3.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.3.0) — 2026-04-20
814

915
> [!IMPORTANT]

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

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
zhipu → anthropic : prepare_zhipu_to_anthropic (剥离 thinking + tool pairing)
1111
zhipu → copilot : prepare_zhipu_to_copilot (剥离 thinking + cache_control + tool pairing)
1212
copilot → zhipu : prepare_copilot_to_zhipu (剥离 thinking + cache_control + 移除 thinking 参数 + tool pairing)
13+
anthropic → zhipu : prepare_anthropic_to_zhipu (剥离 server_tool_use + thinking + cache_control + 移除 thinking 参数 + tool pairing)
1314
"""
1415

1516
from __future__ import annotations
@@ -284,7 +285,13 @@ def _remove_vendor_blocks(body: dict[str, Any], block_types: set[str]) -> int:
284285
removed += 1
285286
continue
286287
new_content.append(block)
287-
if removed:
288+
if content != new_content:
289+
if not new_content:
290+
new_content = [{"type": "text", "text": "[vendor_block_removed]"}]
291+
logger.info(
292+
"Inserted placeholder text block after stripping "
293+
"vendor blocks to avoid empty message content",
294+
)
288295
message["content"] = new_content
289296
return removed
290297

@@ -358,16 +365,17 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
358365
"""从请求 body 内容推断源供应商(仅在无会话上下文时作为兜底).
359366
360367
启发式(按置信度排序):
361-
- 出现 ``srvtoolu_*`` 格式的 ``tool_use.id`` → zhipu
362-
- 出现 ``server_tool_use`` / ``server_tool_use_delta`` 类型的 content block → zhipu
368+
- 出现 ``srvtoolu_*`` 格式的 ID → zhipu
369+
- 出现 ``server_tool_use_delta`` 类型的 content block → zhipu
370+
- 出现 ``server_tool_use`` 块 + ``toolu_*`` ID → anthropic(beta 功能产物)
363371
364372
原则: 只读扫描不修改 body;无匹配返回 None(视作纯净无需跨供应商清洗)。
365373
366374
Args:
367375
body: Anthropic Messages 请求体。
368376
369377
Returns:
370-
推断的源供应商名称(当前仅支持 ``"zhipu"``),无法推断返回 None。
378+
推断的源供应商名称(``"zhipu"`` 或 ``"anthropic"``),无法推断返回 None。
371379
"""
372380
for message in body.get("messages", []):
373381
if not isinstance(message, dict):
@@ -379,18 +387,35 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
379387
if not isinstance(block, dict):
380388
continue
381389
block_type = block.get("type")
382-
if block_type in _ZHIPU_SERVER_TOOL_USE_TYPES:
383-
return "zhipu"
384390
block_id = block.get("id")
391+
tool_use_id = block.get("tool_use_id")
392+
393+
# Zhipu: server_tool_use_delta 是 zhipu 私有流式块(无歧义)
394+
if block_type == "server_tool_use_delta":
395+
return "zhipu"
396+
397+
# srvtoolu_* ID(无论 block type)→ zhipu
385398
if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
386399
block_id
387400
):
388401
return "zhipu"
389-
tool_use_id = block.get("tool_use_id")
390402
if isinstance(tool_use_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
391403
tool_use_id
392404
):
393405
return "zhipu"
406+
407+
# server_tool_use 块 + toolu_* ID → Anthropic beta 功能
408+
if (
409+
block_type == "server_tool_use"
410+
and isinstance(block_id, str)
411+
and _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
412+
):
413+
return "anthropic"
414+
415+
# server_tool_use 块 + 非 toolu_/srvtoolu_ ID → 按类型兜底归 zhipu
416+
if block_type == "server_tool_use":
417+
return "zhipu"
418+
394419
return None
395420

396421

@@ -438,6 +463,61 @@ def prepare_copilot_to_zhipu(
438463
return prepared, adaptations
439464

440465

466+
# ── anthropic → zhipu 转换通道 ────────────────────────────────────
467+
468+
# Anthropic beta 特有的 server_tool_use 块类型(web search, computer use 等).
469+
# 这些块在 Anthropic API 中有效,但 zhipu GLM-5 的兼容端点不支持。
470+
# 注意: 这与 zhipu 自己的 server_tool_use(使用 srvtoolu_* ID)是不同的概念,
471+
# 但它们共用同一个 type 名称 "server_tool_use"。
472+
_ANTHROPIC_BETA_BLOCK_TYPES = {"server_tool_use"}
473+
474+
475+
def prepare_anthropic_to_zhipu(
476+
body: dict[str, Any],
477+
) -> tuple[dict[str, Any], list[str]]:
478+
"""anthropic → zhipu 转换: 清理 anthropic 产物以适配 GLM-5.
479+
480+
Anthropic API 可能产生的非兼容产物:
481+
- ``server_tool_use`` blocks(web search / computer use 等 beta 功能)
482+
- ``thinking`` / ``redacted_thinking`` blocks(含 Anthropic 签发的 signature)
483+
- ``cache_control`` 字段
484+
- 顶层 ``thinking`` / ``extended_thinking`` 参数
485+
486+
Returns:
487+
(prepared_body, adaptations) — adaptations 为应用的变换描述列表。
488+
"""
489+
prepared = copy.deepcopy(body)
490+
adaptations: list[str] = []
491+
492+
# Step 1: 剥离 anthropic 的 server_tool_use blocks(web search, computer use 等)
493+
removed_stu = _remove_vendor_blocks(prepared, _ANTHROPIC_BETA_BLOCK_TYPES)
494+
if removed_stu:
495+
adaptations.append(f"removed_{removed_stu}_server_tool_use_blocks")
496+
497+
# Step 2: 剥离 thinking/redacted_thinking blocks
498+
stripped = strip_thinking_blocks(prepared)
499+
if stripped:
500+
adaptations.append(f"stripped_{stripped}_thinking_blocks")
501+
502+
# Step 3: 移除 cache_control 字段
503+
removed_cc = _strip_cache_control(prepared)
504+
if removed_cc:
505+
adaptations.append(f"removed_{removed_cc}_cache_control_fields")
506+
507+
# Step 4: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持)
508+
for param in ("thinking", "extended_thinking"):
509+
if param in prepared:
510+
del prepared[param]
511+
adaptations.append(f"removed_{param}_param")
512+
513+
# Step 5: 强制 tool_use/tool_result 配对
514+
pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", []))
515+
if pairing_fixes:
516+
adaptations.extend(pairing_fixes)
517+
518+
return prepared, adaptations
519+
520+
441521
# ── zhipu → copilot 转换通道 ─────────────────────────────────────
442522

443523

@@ -544,3 +624,4 @@ def prepare_zhipu_to_anthropic(
544624
VENDOR_TRANSITIONS[("zhipu", "anthropic")] = prepare_zhipu_to_anthropic
545625
VENDOR_TRANSITIONS[("zhipu", "copilot")] = prepare_zhipu_to_copilot
546626
VENDOR_TRANSITIONS[("copilot", "zhipu")] = prepare_copilot_to_zhipu
627+
VENDOR_TRANSITIONS[("anthropic", "zhipu")] = prepare_anthropic_to_zhipu

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@
2424
}
2525
)
2626

27+
# 中文语义拒绝标记(zhipu 等供应商的 400 错误消息)。
28+
# 使用原始大小写匹配,因为中文无大小写之分。
29+
_VENDOR_CN_SEMANTIC_REJECTION_MARKERS: frozenset[str] = frozenset(
30+
{
31+
"API 调用参数有误",
32+
"参数不合法",
33+
"请求参数错误",
34+
"请求格式错误",
35+
}
36+
)
37+
2738

2839
def extract_error_payload_from_http_status(
2940
exc: httpx.HTTPStatusError,
@@ -91,6 +102,9 @@ def is_semantic_rejection(
91102
"can only be in",
92103
"bad request", # 覆盖 Copilot 等返回纯文本 "Bad Request" 的场景
93104
)
105+
) or any(
106+
marker in (error_message or "")
107+
for marker in _VENDOR_CN_SEMANTIC_REJECTION_MARKERS
94108
)
95109

96110

‎tests/test_error_classifier.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,44 @@ def test_structural_tool_use_error_not_semantic(self):
175175
is False
176176
)
177177

178+
def test_400_with_chinese_api_error(self):
179+
"""zhipu 返回中文「API 调用参数有误」应被识别为语义拒绝."""
180+
assert (
181+
is_semantic_rejection(
182+
status_code=400,
183+
error_message="API 调用参数有误",
184+
)
185+
is True
186+
)
187+
188+
def test_400_with_chinese_validation_error(self):
189+
assert (
190+
is_semantic_rejection(
191+
status_code=400,
192+
error_message="请求参数错误",
193+
)
194+
is True
195+
)
196+
197+
def test_400_with_chinese_format_error(self):
198+
assert (
199+
is_semantic_rejection(
200+
status_code=400,
201+
error_message="请求格式错误",
202+
)
203+
is True
204+
)
205+
206+
def test_chinese_error_not_matched_at_non_400(self):
207+
"""非 400 状态码的中文错误不应被视为语义拒绝."""
208+
assert (
209+
is_semantic_rejection(
210+
status_code=500,
211+
error_message="API 调用参数有误",
212+
)
213+
is False
214+
)
215+
178216

179217
# --- extract_error_payload_from_http_status 测试 ---
180218

‎tests/test_router_executor.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,17 +1609,14 @@ def test_returns_none_for_no_source(self):
16091609
is None
16101610
)
16111611

1612-
def test_returns_none_when_no_registered_transition(self):
1613-
"""会话历史中有 vendor 但无对应已注册转换 → 无源 vendor.
1614-
1615-
例如 anthropic → zhipu 未注册,不会触发转换。
1616-
"""
1612+
def test_returns_session_vendor_with_registered_transition_anthropic_to_zhipu(self):
1613+
"""anthropic → zhipu 已注册转换,应返回 anthropic 作为源 vendor."""
16171614
session_record = MagicMock()
16181615
session_record.provider_state = {"anthropic": {}}
16191616

16201617
assert (
16211618
_RouteExecutor._determine_source_vendor("zhipu", None, session_record)
1622-
is None
1619+
== "anthropic"
16231620
)
16241621

16251622
def test_returns_none_when_session_is_none(self):
@@ -1890,16 +1887,22 @@ def test_returns_body_when_no_source_vendor(self):
18901887
assert result is body
18911888
assert len(result["messages"][0]["content"]) == 2
18921889

1893-
def test_returns_body_for_unregistered_transition(self):
1894-
"""未注册的转换对(如 anthropic → zhipu)→ 原样返回."""
1890+
def test_applies_anthropic_to_zhipu_transition(self):
1891+
"""anthropic → zhipu 已注册转换,应清理 thinking blocks."""
18951892
tier = MagicMock()
18961893
tier.name = "zhipu"
18971894

18981895
exec_inst = _executor([])
18991896
body = self._body_with_thinking()
19001897
result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="anthropic")
19011898

1902-
assert result is body
1899+
# thinking blocks 应被剥离
1900+
assert result is not body
1901+
assert all(
1902+
b.get("type") not in ("thinking", "redacted_thinking")
1903+
for b in result["messages"][0]["content"]
1904+
)
1905+
assert len(result["messages"][0]["content"]) >= 1
19031906

19041907
def test_returns_body_for_unknown_tier(self):
19051908
"""未知 tier(无注册转换)→ 原样返回."""

0 commit comments

Comments
 (0)