@@ -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
3952def 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
254312def _emit_misplaced_tool_result_summary (
0 commit comments