Skip to content

Commit 3a06c09

Browse files
committed
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 异常。 - 新增 `enforce_anthropic_tool_pairing()` 函数(单遍、自包含、幂等) - 新增 `_needs_tool_pairing_enforcement()` 扩展触发条件,覆盖会话历史含非 Anthropic 供应商的场景 - 新增 13 个测试用例覆盖全部场景(含真实 bug 复现) 🤖 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 a1ab8d9 commit 3a06c09

4 files changed

Lines changed: 699 additions & 10 deletions

File tree

‎CHANGELOG.md‎

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

55
## [Unreleased]
66

7+
- 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 异常;
8+
79
## [v0.2.3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.2.3) — 2026-04-16
810

911
- feat(dashboard): 新增实时 Web Dashboard 页面,聚合展示流量与用量统计;

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

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -233,38 +233,36 @@ def _prepare_body_for_tier(
233233
"""为指定 tier 准备请求体,必要时应用 Anthropic 专属修复(Phase 2).
234234
235235
仅当 tier 为 Anthropic 时才执行以下处理:
236-
1. tool_result 重定位 + 孤儿修复(需 normalization.has_anthropic_fixes)
236+
1. 跨供应商 tool_use/tool_result 配对强制修复(单遍自包含扫描)
237237
2. 条件化 thinking block 剥离(仅跨供应商场景)
238238
239239
确保 Zhipu 等其他 vendor 不受影响。
240240
"""
241241
if tier.name != "anthropic":
242242
return body
243243

244-
needs_tool_fixes = (
245-
normalization is not None and normalization.has_anthropic_fixes
244+
needs_tool_pairing = self._needs_tool_pairing_enforcement(
245+
normalization, session_record
246246
)
247247
needs_thinking_strip = self._needs_thinking_strip(normalization, session_record)
248248

249-
if not needs_tool_fixes and not needs_thinking_strip:
249+
if not needs_tool_pairing and not needs_thinking_strip:
250250
return body
251251

252252
from ..server.request_normalizer import (
253-
apply_anthropic_specific_fixes,
253+
enforce_anthropic_tool_pairing,
254254
strip_thinking_blocks,
255255
)
256256

257257
body_for_vendor = copy.deepcopy(body)
258258

259-
if needs_tool_fixes:
260-
fixes = apply_anthropic_specific_fixes(
259+
if needs_tool_pairing:
260+
fixes = enforce_anthropic_tool_pairing(
261261
body_for_vendor.get("messages", []),
262-
normalization.misplaced_tool_results,
263-
normalization.misplaced_log_info,
264262
)
265263
if fixes:
266264
logger.debug(
267-
"Applied Anthropic-specific fixes for tier %s: %s",
265+
"Applied tool pairing enforcement for tier %s: %s",
268266
tier.name,
269267
", ".join(fixes),
270268
)
@@ -279,6 +277,40 @@ def _prepare_body_for_tier(
279277

280278
return body_for_vendor
281279

280+
@staticmethod
281+
def _needs_tool_pairing_enforcement(
282+
normalization: Any, session_record: Any
283+
) -> bool:
284+
"""判断是否需要强制执行 Anthropic tool_use/tool_result 配对修复.
285+
286+
此方法扩展了原有 ``has_anthropic_fixes`` 的触发条件,覆盖以下场景:
287+
288+
1. 请求体中检测到跨供应商产物(如非标准 ID、misplaced tool_result)
289+
2. Phase 1 检测到需要 Anthropic 修复(misplaced 或 ID 重写)
290+
3. 会话历史中存在非 Anthropic 供应商记录(如 zhipu)
291+
4. 无会话追踪能力时安全回退
292+
293+
条件 3 和 4 确保即使请求体本身无跨供应商产物(如 zhipu 使用标准
294+
``toolu_*`` ID 时),只要会话曾经过非 Anthropic 供应商,仍会执行配对修复。
295+
"""
296+
# Signal 1: 当前请求体有跨供应商产物
297+
if normalization is not None and normalization.has_cross_vendor_signals:
298+
return True
299+
# Signal 2: Phase 1 检测到需要 Anthropic 修复
300+
if normalization is not None and normalization.has_anthropic_fixes:
301+
return True
302+
# Signal 3: 无会话追踪 → 安全回退
303+
if session_record is None:
304+
return True
305+
# Signal 4: 会话历史中有非 Anthropic 供应商
306+
if session_record.provider_state:
307+
non_anthropic = {
308+
v for v in session_record.provider_state if v != "anthropic"
309+
}
310+
if non_anthropic:
311+
return True
312+
return False
313+
282314
@staticmethod
283315
def _needs_thinking_strip(normalization: Any, session_record: Any) -> bool:
284316
"""判断是否需要剥离 thinking blocks(仅跨供应商场景).

‎src/coding/proxy/server/request_normalizer.py‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,150 @@ def _repair_orphaned_tool_use(
487487
return repaired
488488

489489

490+
# ── Phase 2: 跨供应商 tool_use/tool_result 配对强制修复 ─────────
491+
492+
493+
def enforce_anthropic_tool_pairing(
494+
messages_list: list[dict[str, Any]],
495+
) -> list[str]:
496+
"""为跨供应商场景强制保证 Anthropic tool_use/tool_result 配对约束.
497+
498+
单次正向遍历所有消息,对每个 assistant 消息执行:
499+
500+
1. 剥离所有 tool_result 块(跨供应商产物,如 GLM-5 内联的 tool_result)
501+
2. 收集所有 tool_use ID
502+
3. 确保紧邻的下一条消息是 user 消息且包含所有必需的 tool_result
503+
4. 将剥离的 tool_result 重定位到正确的 user 消息
504+
5. 为仍缺失的 tool_result 合成 ``is_error=True`` 的占位块
505+
506+
此函数是一个**自包含的单遍处理**,不依赖 Phase 1 收集的 misplaced 信息,
507+
通过直接扫描消息列表确保处理的完备性。替代此前多步串联管线
508+
(剥离 → 重定位 → 孤儿修复)因步骤间隐式依赖导致的漏修问题。
509+
510+
仅在请求实际发送给 Anthropic tier 且检测到跨供应商信号时调用,
511+
确保 Zhipu 等其他 vendor 不受影响。
512+
513+
Args:
514+
messages_list: 消息列表(就地修改)。
515+
516+
Returns:
517+
新增的 adaptation 标签列表。
518+
"""
519+
adaptations: list[str] = []
520+
relocated_count = 0
521+
synthesized_ids: list[str] = []
522+
523+
i = 0
524+
while i < len(messages_list):
525+
msg = messages_list[i]
526+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
527+
i += 1
528+
continue
529+
530+
content = msg.get("content")
531+
if not isinstance(content, list):
532+
i += 1
533+
continue
534+
535+
# ── A. 从 assistant 消息中剥离所有 tool_result 块 ─────────
536+
extracted_tool_results: dict[str, dict[str, Any]] = {} # tool_use_id → block
537+
retained_content: list[Any] = []
538+
for block in content:
539+
if isinstance(block, dict) and block.get("type") == "tool_result":
540+
tid = block.get("tool_use_id")
541+
if tid:
542+
extracted_tool_results[tid] = block
543+
relocated_count += 1
544+
# 无 tool_use_id 的 tool_result 直接丢弃(无效块)
545+
else:
546+
retained_content.append(block)
547+
548+
if extracted_tool_results:
549+
msg["content"] = retained_content
550+
551+
# ── B. 收集所有 tool_use ID ───────────────────────────────
552+
tool_use_ids: list[str] = [
553+
b["id"]
554+
for b in (
555+
msg.get("content") if isinstance(msg.get("content"), list) else []
556+
)
557+
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
558+
]
559+
if not tool_use_ids:
560+
# 无 tool_use 块:若剥离后 content 为空,插入占位
561+
current_content = msg.get("content")
562+
if isinstance(current_content, list) and not current_content:
563+
msg["content"] = [{"type": "text", "text": ""}]
564+
i += 1
565+
continue
566+
567+
# ── C. 确保 messages[i+1] 是 user 消息 ───────────────────
568+
next_idx = i + 1
569+
if (
570+
next_idx < len(messages_list)
571+
and isinstance(messages_list[next_idx], dict)
572+
and messages_list[next_idx].get("role") == "user"
573+
):
574+
user_msg = messages_list[next_idx]
575+
else:
576+
# 插入合成 user 消息
577+
user_msg: dict[str, Any] = {"role": "user", "content": []}
578+
messages_list.insert(next_idx, user_msg)
579+
580+
# ── D. 确保 user_msg.content 是 list ─────────────────────
581+
user_content = user_msg.get("content")
582+
if isinstance(user_content, str):
583+
user_msg["content"] = [{"type": "text", "text": user_content}]
584+
elif not isinstance(user_content, list):
585+
user_msg["content"] = []
586+
587+
# ── E. 收集 user 消息中已有的 tool_result IDs ─────────────
588+
existing_result_ids: set[str] = {
589+
b["tool_use_id"]
590+
for b in user_msg["content"]
591+
if isinstance(b, dict)
592+
and b.get("type") == "tool_result"
593+
and b.get("tool_use_id")
594+
}
595+
596+
# ── F. 为每个 tool_use_id 确保 tool_result 存在 ──────────
597+
for uid in tool_use_ids:
598+
if uid in existing_result_ids:
599+
continue # 已有匹配的 tool_result
600+
601+
if uid in extracted_tool_results:
602+
# 从 assistant 剥离的 tool_result 重定位到 user
603+
user_msg["content"].append(extracted_tool_results[uid])
604+
else:
605+
# 完全缺失:合成 is_error=True 占位块
606+
user_msg["content"].append(
607+
{
608+
"type": "tool_result",
609+
"tool_use_id": uid,
610+
"content": "",
611+
"is_error": True,
612+
}
613+
)
614+
synthesized_ids.append(uid)
615+
616+
i += 1
617+
618+
# ── 构建 adaptation 标签与日志 ────────────────────────────
619+
if relocated_count:
620+
adaptations.append("misplaced_tool_result_relocated")
621+
if synthesized_ids:
622+
adaptations.append("orphaned_tool_use_repaired")
623+
logger.warning(
624+
"Vendor degradation adaptation: synthesized %d tool_result block(s) "
625+
"for orphaned tool_use to satisfy Anthropic pairing constraint. "
626+
"Affected tool_use_ids: %s",
627+
len(synthesized_ids),
628+
", ".join(synthesized_ids),
629+
)
630+
631+
return adaptations
632+
633+
490634
# ── Phase 2: Thinking block 剥离 ──────────────────────────────
491635

492636
# 需要从 assistant messages 中剥离的 thinking block 类型

0 commit comments

Comments
 (0)