Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 55 additions & 16 deletions src/coding/proxy/convert/vendor_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 的内容块.

Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/coding/proxy/routing/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/coding/proxy/vendors/zhipu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions tests/test_vendor_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 == []
4 changes: 2 additions & 2 deletions tests/test_vendors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions tests/test_zhipu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading