Skip to content

Commit bcd95cb

Browse files
committed
refactor(request-normalizer): 拆分 Phase 1/Phase 2 两阶段规范化,隔离 Anthropic 专属修复;
将 normalize_anthropic_request() 拆分为两个阶段: - Phase 1(vendor-agnostic):ID 重写、vendor block 移除、收集 misplaced tool_result 信息 - Phase 2(Anthropic-only):misplaced tool_result 重定位 + 孤儿 tool_use 合成修复 Phase 2 仅在 executor 发送给 Anthropic tier 时执行(deep copy 后),确保 Zhipu 等 其他 vendor 不受 Anthropic 专属变换(合成 is_error tool_result、重定位块)的影响。 🤖 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 eb0431c commit bcd95cb

5 files changed

Lines changed: 332 additions & 128 deletions

File tree

src/coding/proxy/routing/executor.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import copy
910
import logging
1011
import time
1112
from collections.abc import AsyncIterator
@@ -222,10 +223,43 @@ def __init__(
222223

223224
# ── 公开执行入口 ──────────────────────────────────────
224225

226+
def _prepare_body_for_tier(
227+
self,
228+
body: dict[str, Any],
229+
tier: VendorTier,
230+
normalization: Any = None,
231+
) -> dict[str, Any]:
232+
"""为指定 tier 准备请求体,必要时应用 Anthropic 专属修复(Phase 2).
233+
234+
仅当 tier 为 Anthropic 且 NormalizationResult 标记需要修复时,
235+
才执行 deep copy + Phase 2 修复,确保 Zhipu 等其他 vendor 不受影响。
236+
"""
237+
if normalization is None or not normalization.has_anthropic_fixes:
238+
return body
239+
if tier.name != "anthropic":
240+
return body
241+
242+
from ..server.request_normalizer import apply_anthropic_specific_fixes
243+
244+
body_for_vendor = copy.deepcopy(body)
245+
fixes = apply_anthropic_specific_fixes(
246+
body_for_vendor.get("messages", []),
247+
normalization.misplaced_tool_results,
248+
normalization.misplaced_log_info,
249+
)
250+
if fixes:
251+
logger.debug(
252+
"Applied Anthropic-specific fixes for tier %s: %s",
253+
tier.name,
254+
", ".join(fixes),
255+
)
256+
return body_for_vendor
257+
225258
async def execute_stream(
226259
self,
227260
body: dict[str, Any],
228261
headers: dict[str, str],
262+
normalization: Any = None,
229263
) -> AsyncIterator[tuple[bytes, str]]:
230264
"""路由流式请求,按优先级尝试各层级."""
231265
last_idx = len(self._tiers) - 1
@@ -257,7 +291,8 @@ async def execute_stream(
257291
usage: dict[str, Any] = {}
258292

259293
try:
260-
async for chunk in tier.vendor.send_message_stream(body, headers):
294+
body_for_tier = self._prepare_body_for_tier(body, tier, normalization)
295+
async for chunk in tier.vendor.send_message_stream(body_for_tier, headers):
261296
parse_usage_from_chunk(
262297
chunk,
263298
usage,
@@ -389,6 +424,7 @@ async def execute_message(
389424
self,
390425
body: dict[str, Any],
391426
headers: dict[str, str],
427+
normalization: Any = None,
392428
) -> VendorResponse:
393429
"""路由非流式请求,按优先级尝试各层级."""
394430
last_idx = len(self._tiers) - 1
@@ -417,7 +453,8 @@ async def execute_message(
417453
continue
418454

419455
try:
420-
resp = await tier.vendor.send_message(body, headers)
456+
body_for_tier = self._prepare_body_for_tier(body, tier, normalization)
457+
resp = await tier.vendor.send_message(body_for_tier, headers)
421458

422459
if resp.status_code < 400:
423460
duration = int((time.monotonic() - start) * 1000)

src/coding/proxy/routing/router.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,20 @@ async def route_stream(
134134
self,
135135
body: dict[str, Any],
136136
headers: dict[str, str],
137+
normalization: Any = None,
137138
) -> AsyncIterator[tuple[bytes, str]]:
138139
"""路由流式请求,按优先级尝试各层级."""
139-
async for chunk, vendor_name in self._executor.execute_stream(body, headers):
140+
async for chunk, vendor_name in self._executor.execute_stream(body, headers, normalization=normalization):
140141
yield chunk, vendor_name
141142

142143
async def route_message(
143144
self,
144145
body: dict[str, Any],
145146
headers: dict[str, str],
147+
normalization: Any = None,
146148
) -> Any:
147149
"""路由非流式请求,按优先级尝试各层级."""
148-
return await self._executor.execute_message(body, headers)
150+
return await self._executor.execute_message(body, headers, normalization=normalization)
149151

150152
# ── 生命周期 ───────────────────────────────────────────
151153

src/coding/proxy/server/request_normalizer.py

Lines changed: 125 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,38 @@ class NormalizationResult:
3030
body: dict[str, Any]
3131
adaptations: list[str] = field(default_factory=list)
3232
fatal_reasons: list[str] = field(default_factory=list)
33+
# Phase 2 上下文(仅 Anthropic tier 使用)
34+
tool_id_map: dict[str, str] = field(default_factory=dict)
35+
misplaced_tool_results: list[tuple[int, dict[str, Any]]] = field(
36+
default_factory=list
37+
)
38+
misplaced_log_info: list[tuple[str, int, int, str]] = field(
39+
default_factory=list
40+
)
3341

3442
@property
3543
def recoverable(self) -> bool:
3644
return not self.fatal_reasons
3745

46+
@property
47+
def has_anthropic_fixes(self) -> bool:
48+
"""是否需要应用 Anthropic 专属修复(重定位 + 孤儿修复)."""
49+
return bool(self.misplaced_tool_results) or bool(self.tool_id_map)
50+
3851

3952
def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult:
4053
"""清洗供应商私有块,尽量恢复为合法 Anthropic Messages 请求.
4154
55+
这是 vendor-agnostic 的 Phase 1 规范化:对所有 vendor 均适用。
56+
4257
处理策略:
4358
1. 移除供应商私有块(如 server_tool_use_delta)
4459
2. 重写无效/非标准的 tool_use / tool_result ID
45-
3. **重定位错位的 tool_result 块**:Anthropic API 要求 ``tool_result`` 只能出现在
46-
``user`` 消息中。当检测到非 user 消息中存在 ``tool_result`` 时,
47-
将其重定位到紧邻的下一个 user 消息中,以保持 ``tool_use`` / ``tool_result``
48-
配对关系,防止上游返回 ``400 invalid_request_error``。
49-
4. **修复孤儿 tool_use 块**:当 assistant 消息中的 ``tool_use`` 在紧邻的 user 消息中
50-
没有对应的 ``tool_result`` 时(如跨供应商降级导致对话结构不完整),
51-
合成一个 ``is_error=true`` 的占位 ``tool_result`` 以满足 API 约束。
60+
3. **收集**(但不应用)错位的 tool_result 块信息,供 Phase 2 使用
61+
62+
Phase 2(Anthropic 专属修复:重定位 + 孤儿修复)由
63+
:func:`apply_anthropic_specific_fixes` 独立执行,仅在请求实际发送给
64+
Anthropic tier 时调用,确保 Zhipu 等其他 vendor 不受影响。
5265
"""
5366
normalized = copy.deepcopy(body)
5467
adaptations: list[str] = []
@@ -61,9 +74,9 @@ def next_tool_id() -> str:
6174
normalized_counter += 1
6275
return f"toolu_normalized_{normalized_counter}"
6376

64-
# 收集本轮被重定位的 misplaced tool_result 块及日志信息
65-
relocated_results: list[tuple[int, dict[str, Any]]] = [] # (source_msg_idx, block)
66-
relocated_log_info: list[
77+
# 收集本轮 misplaced tool_result 块(Phase 2 延迟到 Anthropic tier 执行)
78+
collected_misplaced: list[tuple[int, dict[str, Any]]] = [] # (source_msg_idx, block)
79+
misplaced_log_info: list[
6780
tuple[str, int, int, str]
6881
] = [] # (role, msg_idx, blk_idx, tool_use_id)
6982

@@ -143,28 +156,24 @@ def normalize_content_block(
143156
return None
144157
return normalized_block
145158

146-
# tool_result 出现在非 user 消息中(如 assistant)—— 重定位到紧邻的 user 消息。
147-
# 典型触发场景:跨供应商降级时(如 Zhipu GLM → Anthropic),
148-
# GLM-5 在 assistant 响应中同时包含 tool_use 和 tool_result 内容块,
149-
# Claude Code 将此响应当作对话历史存储后,tool_result 出现在 assistant 角色消息中。
150-
# 直接剥离会导致 tool_use 成为孤儿块(无配对 tool_result),触发上游 400 错误。
151-
# 因此改为重定位:将 tool_result 移至紧邻的下一个 user 消息中。
159+
# tool_result 出现在非 user 消息中(如 assistant)—— 仅收集供 Phase 2 使用。
160+
# Phase 2 由 apply_anthropic_specific_fixes() 执行,仅在 Anthropic tier 时调用。
161+
# 对于 Zhipu 等其他 vendor,misplaced 块保留在原位不变。
152162
normalized_block = dict(block)
153163
tool_use_id = normalized_block.get("tool_use_id")
154164
if isinstance(tool_use_id, str) and tool_use_id in tool_id_map:
155165
normalized_block["tool_use_id"] = tool_id_map[tool_use_id]
156166
adaptations.append("tool_result_tool_use_id_rewritten")
157-
adaptations.append("misplaced_tool_result_relocated")
158-
relocated_results.append((message_index, normalized_block))
159-
relocated_log_info.append(
167+
collected_misplaced.append((message_index, normalized_block))
168+
misplaced_log_info.append(
160169
(
161170
message_role,
162171
message_index,
163172
block_index,
164173
normalized_block.get("tool_use_id", "N/A"),
165174
)
166175
)
167-
return None
176+
return normalized_block
168177

169178
return dict(block)
170179

@@ -187,51 +196,104 @@ def normalize_content_block(
187196
new_content.append(normalized_block)
188197
message["content"] = new_content
189198

190-
# ── 重定位 misplaced tool_result 到紧邻的 user 消息 ──────────
191-
# 按源消息索引降序处理,避免插入新消息时索引偏移。
192-
messages_list = normalized.get("messages", [])
193-
for source_idx, result_block in sorted(
194-
relocated_results, key=lambda x: x[0], reverse=True
195-
):
196-
target_user_idx = None
197-
for j in range(source_idx + 1, len(messages_list)):
198-
if (
199-
isinstance(messages_list[j], dict)
200-
and messages_list[j].get("role") == "user"
201-
):
202-
target_user_idx = j
203-
break
204-
if target_user_idx is not None:
205-
# 追加到已有 user 消息的 content 末尾
206-
target_content = messages_list[target_user_idx].get("content")
207-
if isinstance(target_content, list):
208-
target_content.append(result_block)
209-
elif isinstance(target_content, str):
210-
# string content 转为 text block 后追加,避免丢失原始文本
211-
messages_list[target_user_idx]["content"] = [
212-
{"type": "text", "text": target_content},
213-
result_block,
214-
]
199+
return NormalizationResult(
200+
body=normalized,
201+
adaptations=sorted(set(adaptations)),
202+
fatal_reasons=fatal_reasons,
203+
tool_id_map=tool_id_map,
204+
misplaced_tool_results=collected_misplaced,
205+
misplaced_log_info=misplaced_log_info,
206+
)
207+
208+
209+
def apply_anthropic_specific_fixes(
210+
messages_list: list[dict[str, Any]],
211+
misplaced_results: list[tuple[int, dict[str, Any]]],
212+
misplaced_log_info: list[tuple[str, int, int, str]],
213+
) -> list[str]:
214+
"""应用 Anthropic 专属修复(重定位 + 孤儿修复).
215+
216+
仅在请求实际发送给 Anthropic tier 时调用,确保 Zhipu 等其他 vendor 不受影响。
217+
Phase 1(normalize_anthropic_request)仅收集 misplaced 信息,将实际修复延迟到此函数。
218+
219+
Args:
220+
messages_list: 消息列表(就地修改)。
221+
misplaced_results: Phase 1 收集的 misplaced tool_result 列表,
222+
每个元素为 (source_msg_idx, block)。
223+
misplaced_log_info: Phase 1 收集的日志信息列表,
224+
每个元素为 (role, msg_idx, blk_idx, tool_use_id)。
225+
226+
Returns:
227+
新增的 adaptation 标签列表。
228+
"""
229+
adaptations: list[str] = []
230+
231+
if misplaced_results:
232+
# ── 1. 从源消息中移除 misplaced tool_result 块 ───────────
233+
to_remove: dict[int, set[str]] = {}
234+
for source_idx, block in misplaced_results:
235+
tid = block.get("tool_use_id", "")
236+
if tid:
237+
to_remove.setdefault(source_idx, set()).add(tid)
238+
239+
for msg_idx, tids in to_remove.items():
240+
if msg_idx < len(messages_list):
241+
msg = messages_list[msg_idx]
242+
if isinstance(msg, dict):
243+
content = msg.get("content")
244+
if isinstance(content, list):
245+
msg["content"] = [
246+
b
247+
for b in content
248+
if not (
249+
isinstance(b, dict)
250+
and b.get("type") == "tool_result"
251+
and b.get("tool_use_id") in tids
252+
)
253+
]
254+
255+
# ── 2. 重定位到紧邻的 user 消息 ──────────────────────────
256+
# 按源消息索引降序处理,避免插入新消息时索引偏移。
257+
for source_idx, result_block in sorted(
258+
misplaced_results, key=lambda x: x[0], reverse=True
259+
):
260+
target_user_idx = None
261+
for j in range(source_idx + 1, len(messages_list)):
262+
if (
263+
isinstance(messages_list[j], dict)
264+
and messages_list[j].get("role") == "user"
265+
):
266+
target_user_idx = j
267+
break
268+
269+
if target_user_idx is not None:
270+
target_content = messages_list[target_user_idx].get("content")
271+
if isinstance(target_content, list):
272+
target_content.append(result_block)
273+
elif isinstance(target_content, str):
274+
# string content 转为 text block 后追加,避免丢失原始文本
275+
messages_list[target_user_idx]["content"] = [
276+
{"type": "text", "text": target_content},
277+
result_block,
278+
]
279+
else:
280+
messages_list[target_user_idx]["content"] = [result_block]
215281
else:
216-
messages_list[target_user_idx]["content"] = [result_block]
217-
else:
218-
# 无后续 user 消息:插入一条合成 user 消息
219-
messages_list.insert(
220-
source_idx + 1,
221-
{
222-
"role": "user",
223-
"content": [result_block],
224-
},
225-
)
282+
# 无后续 user 消息:插入一条合成 user 消息
283+
messages_list.insert(
284+
source_idx + 1,
285+
{
286+
"role": "user",
287+
"content": [result_block],
288+
},
289+
)
226290

227-
# ── 汇总日志:misplaced tool_result 重定位 ──────────────────
228-
if relocated_log_info:
229-
_emit_misplaced_tool_result_summary(relocated_log_info)
291+
adaptations.append("misplaced_tool_result_relocated")
230292

231-
# ── 修复通道:为孤儿 tool_use 合成 tool_result ──────────────
232-
# Anthropic API 严格要求每个 tool_use 必须在紧邻的 user 消息中有对应的 tool_result。
233-
# 当 tool_result 完全缺失时(如跨供应商降级导致对话结构不完整),
234-
# 合成一个 is_error=true 的占位 tool_result 以满足 API 约束。
293+
if misplaced_log_info:
294+
_emit_misplaced_tool_result_summary(misplaced_log_info)
295+
296+
# ── 3. 修复通道:为孤儿 tool_use 合成 tool_result ──────────────
235297
repaired = _repair_orphaned_tool_use(messages_list)
236298
if repaired:
237299
adaptations.append("orphaned_tool_use_repaired")
@@ -244,11 +306,7 @@ def normalize_content_block(
244306
", ".join(sorted(repaired)),
245307
)
246308

247-
return NormalizationResult(
248-
body=normalized,
249-
adaptations=sorted(set(adaptations)),
250-
fatal_reasons=fatal_reasons,
251-
)
309+
return adaptations
252310

253311

254312
def _emit_misplaced_tool_result_summary(

src/coding/proxy/server/routes.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
logger = logging.getLogger(__name__)
2525

2626

27-
async def _stream_proxy(router: Any, body: dict, headers: dict) -> Any:
27+
async def _stream_proxy(router: Any, body: dict, headers: dict, normalization: Any = None) -> Any:
2828
"""流式代理生成器."""
2929
try:
30-
async for chunk, vendor_name in router.route_stream(body, headers):
30+
async for chunk, vendor_name in router.route_stream(body, headers, normalization=normalization):
3131
yield chunk
3232
except NoCompatibleVendorError as exc:
3333
yield (
@@ -78,13 +78,13 @@ async def messages(request: Request) -> Response:
7878

7979
if is_streaming:
8080
return StreamingResponse(
81-
_stream_proxy(router, body, headers),
81+
_stream_proxy(router, body, headers, normalization=normalization),
8282
media_type="text/event-stream",
8383
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
8484
)
8585

8686
try:
87-
resp = await router.route_message(body, headers)
87+
resp = await router.route_message(body, headers, normalization=normalization)
8888
except NoCompatibleVendorError as exc:
8989
return json_error_response(
9090
400,

0 commit comments

Comments
 (0)