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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## [Unreleased]

- fix(request-normalizer): 重设计 zhipu→anthropic 跨供应商 tool_use/tool_result 配对修复——以单遍自包含 `enforce_anthropic_tool_pairing` 替代原有多步串联管线(剥离→重定位→孤儿修复),消除步骤间隐式依赖导致的孤儿 tool_use 漏修问题,彻底根治 `tool_use ids were found without tool_result blocks` 400 异常;

## [v0.2.3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.2.3) — 2026-04-16

- feat(dashboard): 新增实时 Web Dashboard 页面,聚合展示流量与用量统计;
Expand Down
52 changes: 42 additions & 10 deletions src/coding/proxy/routing/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,38 +233,36 @@ def _prepare_body_for_tier(
"""为指定 tier 准备请求体,必要时应用 Anthropic 专属修复(Phase 2).

仅当 tier 为 Anthropic 时才执行以下处理:
1. tool_result 重定位 + 孤儿修复(需 normalization.has_anthropic_fixes
1. 跨供应商 tool_use/tool_result 配对强制修复(单遍自包含扫描
2. 条件化 thinking block 剥离(仅跨供应商场景)

确保 Zhipu 等其他 vendor 不受影响。
"""
if tier.name != "anthropic":
return body

needs_tool_fixes = (
normalization is not None and normalization.has_anthropic_fixes
needs_tool_pairing = self._needs_tool_pairing_enforcement(
normalization, session_record
)
needs_thinking_strip = self._needs_thinking_strip(normalization, session_record)

if not needs_tool_fixes and not needs_thinking_strip:
if not needs_tool_pairing and not needs_thinking_strip:
return body

from ..server.request_normalizer import (
apply_anthropic_specific_fixes,
enforce_anthropic_tool_pairing,
strip_thinking_blocks,
)

body_for_vendor = copy.deepcopy(body)

if needs_tool_fixes:
fixes = apply_anthropic_specific_fixes(
if needs_tool_pairing:
fixes = enforce_anthropic_tool_pairing(
body_for_vendor.get("messages", []),
normalization.misplaced_tool_results,
normalization.misplaced_log_info,
)
if fixes:
logger.debug(
"Applied Anthropic-specific fixes for tier %s: %s",
"Applied tool pairing enforcement for tier %s: %s",
tier.name,
", ".join(fixes),
)
Expand All @@ -279,6 +277,40 @@ def _prepare_body_for_tier(

return body_for_vendor

@staticmethod
def _needs_tool_pairing_enforcement(
normalization: Any, session_record: Any
) -> bool:
"""判断是否需要强制执行 Anthropic tool_use/tool_result 配对修复.

此方法扩展了原有 ``has_anthropic_fixes`` 的触发条件,覆盖以下场景:

1. 请求体中检测到跨供应商产物(如非标准 ID、misplaced tool_result)
2. Phase 1 检测到需要 Anthropic 修复(misplaced 或 ID 重写)
3. 会话历史中存在非 Anthropic 供应商记录(如 zhipu)
4. 无会话追踪能力时安全回退

条件 3 和 4 确保即使请求体本身无跨供应商产物(如 zhipu 使用标准
``toolu_*`` ID 时),只要会话曾经过非 Anthropic 供应商,仍会执行配对修复。
"""
# Signal 1: 当前请求体有跨供应商产物
if normalization is not None and normalization.has_cross_vendor_signals:
return True
# Signal 2: Phase 1 检测到需要 Anthropic 修复
if normalization is not None and normalization.has_anthropic_fixes:
return True
# Signal 3: 无会话追踪 → 安全回退
if session_record is None:
return True
# Signal 4: 会话历史中有非 Anthropic 供应商
if session_record.provider_state:
non_anthropic = {
v for v in session_record.provider_state if v != "anthropic"
}
if non_anthropic:
return True
return False

@staticmethod
def _needs_thinking_strip(normalization: Any, session_record: Any) -> bool:
"""判断是否需要剥离 thinking blocks(仅跨供应商场景).
Expand Down
144 changes: 144 additions & 0 deletions src/coding/proxy/server/request_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,150 @@ def _repair_orphaned_tool_use(
return repaired


# ── Phase 2: 跨供应商 tool_use/tool_result 配对强制修复 ─────────


def enforce_anthropic_tool_pairing(
messages_list: list[dict[str, Any]],
) -> list[str]:
"""为跨供应商场景强制保证 Anthropic tool_use/tool_result 配对约束.

单次正向遍历所有消息,对每个 assistant 消息执行:

1. 剥离所有 tool_result 块(跨供应商产物,如 GLM-5 内联的 tool_result)
2. 收集所有 tool_use ID
3. 确保紧邻的下一条消息是 user 消息且包含所有必需的 tool_result
4. 将剥离的 tool_result 重定位到正确的 user 消息
5. 为仍缺失的 tool_result 合成 ``is_error=True`` 的占位块

此函数是一个**自包含的单遍处理**,不依赖 Phase 1 收集的 misplaced 信息,
通过直接扫描消息列表确保处理的完备性。替代此前多步串联管线
(剥离 → 重定位 → 孤儿修复)因步骤间隐式依赖导致的漏修问题。

仅在请求实际发送给 Anthropic tier 且检测到跨供应商信号时调用,
确保 Zhipu 等其他 vendor 不受影响。

Args:
messages_list: 消息列表(就地修改)。

Returns:
新增的 adaptation 标签列表。
"""
adaptations: list[str] = []
relocated_count = 0
synthesized_ids: list[str] = []

i = 0
while i < len(messages_list):
msg = messages_list[i]
if not isinstance(msg, dict) or msg.get("role") != "assistant":
i += 1
continue

content = msg.get("content")
if not isinstance(content, list):
i += 1
continue

# ── A. 从 assistant 消息中剥离所有 tool_result 块 ─────────
extracted_tool_results: dict[str, dict[str, Any]] = {} # tool_use_id → block
retained_content: list[Any] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
tid = block.get("tool_use_id")
if tid:
extracted_tool_results[tid] = block
relocated_count += 1
# 无 tool_use_id 的 tool_result 直接丢弃(无效块)
else:
retained_content.append(block)

if extracted_tool_results:
msg["content"] = retained_content

# ── B. 收集所有 tool_use ID ───────────────────────────────
tool_use_ids: list[str] = [
b["id"]
for b in (
msg.get("content") if isinstance(msg.get("content"), list) else []
)
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
]
if not tool_use_ids:
# 无 tool_use 块:若剥离后 content 为空,插入占位
current_content = msg.get("content")
if isinstance(current_content, list) and not current_content:
msg["content"] = [{"type": "text", "text": ""}]
i += 1
continue

# ── C. 确保 messages[i+1] 是 user 消息 ───────────────────
next_idx = i + 1
if (
next_idx < len(messages_list)
and isinstance(messages_list[next_idx], dict)
and messages_list[next_idx].get("role") == "user"
):
user_msg = messages_list[next_idx]
else:
# 插入合成 user 消息
user_msg: dict[str, Any] = {"role": "user", "content": []}
messages_list.insert(next_idx, user_msg)

# ── D. 确保 user_msg.content 是 list ─────────────────────
user_content = user_msg.get("content")
if isinstance(user_content, str):
user_msg["content"] = [{"type": "text", "text": user_content}]
elif not isinstance(user_content, list):
user_msg["content"] = []

# ── E. 收集 user 消息中已有的 tool_result IDs ─────────────
existing_result_ids: set[str] = {
b["tool_use_id"]
for b in user_msg["content"]
if isinstance(b, dict)
and b.get("type") == "tool_result"
and b.get("tool_use_id")
}

# ── F. 为每个 tool_use_id 确保 tool_result 存在 ──────────
for uid in tool_use_ids:
if uid in existing_result_ids:
continue # 已有匹配的 tool_result

if uid in extracted_tool_results:
# 从 assistant 剥离的 tool_result 重定位到 user
user_msg["content"].append(extracted_tool_results[uid])
else:
# 完全缺失:合成 is_error=True 占位块
user_msg["content"].append(
{
"type": "tool_result",
"tool_use_id": uid,
"content": "",
"is_error": True,
}
)
synthesized_ids.append(uid)

i += 1

# ── 构建 adaptation 标签与日志 ────────────────────────────
if relocated_count:
adaptations.append("misplaced_tool_result_relocated")
if synthesized_ids:
adaptations.append("orphaned_tool_use_repaired")
logger.warning(
"Vendor degradation adaptation: synthesized %d tool_result block(s) "
"for orphaned tool_use to satisfy Anthropic pairing constraint. "
"Affected tool_use_ids: %s",
len(synthesized_ids),
", ".join(synthesized_ids),
)

return adaptations


# ── Phase 2: Thinking block 剥离 ──────────────────────────────

# 需要从 assistant messages 中剥离的 thinking block 类型
Expand Down
Loading
Loading