Download internal/upstream/tool_pairing.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/tool_pairing.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/internal/upstream/tool_pairing.go
-
curl -L -o tool_pairing.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/tool_pairing.go
13.8 kB
| // tool_pairing.go 出站请求体的孤儿 tool_call↔tool 配对清理 + tool 结果块重排 | |
| // (吸收参考仓库 sse.ts:91-123 resolveToolPairing 语义,适配网关的 OpenAI wire 消息形态)。 | |
| // | |
| // 背景:OpenAI 兼容协议要求带 tool_calls 的 assistant 消息,其每一个 tool_call id | |
| // 都必须有对应的一条 role:tool 结果消息;反之 role:tool 消息也必须有对应的前置 | |
| // tool_call。缺任一侧,上游都会以 HTTP 400 拒绝整个请求。 | |
| // | |
| // 工具执行失败时(参数非法、超时、工具不存在……)客户端会把 assistant 的 tool_calls | |
| // 持久化进会话历史,却写不回结果消息。这条坏历史随后被每次请求原样重放——上游对之后 | |
| // 每一条用户消息都返回 400,整条会话报废。网关是最后一道防线:发出请求前剔除无法配对 | |
| // 的条目让会话自愈,宁可丢一轮工具上下文,也好过整条会话死亡。 | |
| package upstream | |
| // repackToolResultBlocks 把插在 assistant.tool_calls 与其 tool 结果之间的非 tool 消息 | |
| // 挪到整组之后,保证同一批 tool_call 的结果在 wire 上连续。 | |
| // | |
| // 背景:Codex 的 image_resize_notice 特性会把 <image_resize_notice> 作为一条 | |
| // developer/system 消息插在 tool 输出后面。并行调用时它插在两份 tool 结果中间: | |
| // | |
| // assistant tool_calls=[c00 c01] | |
| // tool c00 | |
| // developer <image_resize_notice> <- 插在中间 | |
| // tool c01 | |
| // | |
| // OpenAI 兼容协议要求 tool 结果紧跟 assistant,中间插任何消息都算配对断裂,上游判 | |
| // 11148(tool_call_sequence_broken)并顶死整条会话。这里只调顺序、不改内容: | |
| // | |
| // assistant tool_calls=[c00 c01] | tool c00 | X | tool c01 | |
| // → assistant tool_calls=[c00 c01] | tool c00 | tool c01 | X | |
| // | |
| // 结果顺序保持不变(同批 tool_call 的原相对顺序 = 结果顺序),不引入新的顺序敏感 | |
| // 问题。无插入消息时零改动零分配(返回原 slice)。 | |
| func repackToolResultBlocks(messages []any) ([]any, bool) { | |
| if len(messages) < 3 { | |
| return messages, false | |
| } | |
| out := make([]any, 0, len(messages)) | |
| changed := false | |
| i := 0 | |
| for i < len(messages) { | |
| m, ok := messages[i].(map[string]any) | |
| if !ok || m["role"] != "assistant" { | |
| out = append(out, messages[i]) | |
| i++ | |
| continue | |
| } | |
| tcs, hasCalls := m["tool_calls"].([]any) | |
| if !hasCalls || len(tcs) == 0 { | |
| out = append(out, messages[i]) | |
| i++ | |
| continue | |
| } | |
| want := map[string]bool{} | |
| for _, tci := range tcs { | |
| if tc, ok := tci.(map[string]any); ok { | |
| if id, _ := tc["id"].(string); id != "" { | |
| want[id] = true | |
| } | |
| } | |
| } | |
| // 收集紧随其后(允许被其他消息打断)的同批 tool 结果,按原相对顺序。 | |
| out = append(out, messages[i]) | |
| i++ | |
| var results []any | |
| var between []any | |
| sawNonTool := false | |
| for i < len(messages) { | |
| mm, ok := messages[i].(map[string]any) | |
| if !ok { | |
| break | |
| } | |
| role, _ := mm["role"].(string) | |
| if role == "tool" { | |
| id, _ := mm["tool_call_id"].(string) | |
| if !want[id] { | |
| break | |
| } | |
| results = append(results, messages[i]) | |
| if sawNonTool { | |
| changed = true | |
| } | |
| i++ | |
| continue | |
| } | |
| if len(results) == 0 { | |
| break // assistant 后没有结果:交由 cleanupOrphanToolCalls 处理 | |
| } | |
| // 下一组 assistant.tool_calls 是新的组头,绝不能当插入物吞掉:一旦被收进 | |
| // between,它永远不再被外层循环当作组头处理,它自己那批结果也就永远得不 | |
| // 到重排(真实会话 msg[181] 正是这样漏掉的)。必须 break 交还外层循环。 | |
| if role == "assistant" { | |
| if next, _ := mm["tool_calls"].([]any); len(next) > 0 { | |
| break | |
| } | |
| } | |
| // 同批结果尚未收齐时,中间消息视为插入物,暂存待后移。 | |
| between = append(between, messages[i]) | |
| sawNonTool = true | |
| i++ | |
| } | |
| out = append(out, results...) | |
| out = append(out, between...) | |
| } | |
| if !changed { | |
| return messages, false | |
| } | |
| return out, true | |
| } | |
| // cleanupOrphanToolCalls 剔除无法配对的 tool_call 与 tool 结果(所有模型,独立于 | |
| // deepseek-only 的 sanitize 开关)。语义对齐参考仓库 resolveToolPairing: | |
| // | |
| // - 收集全线 role:tool 消息的 tool_call_id(结果集)与 assistant.tool_calls[].id(调用集); | |
| // - 一批 assistant.tool_calls 按 keepCalls 对称裁剪:只留有结果配对的调用(部分保留 | |
| // 不会留下无结果的 tool_call),过滤后为空才删掉整个 tool_calls 键; | |
| // - role:tool 只在对应 tool_call 被保留时才保留,否则删除整条消息; | |
| // - 无任何工具流量 → 原 slice 原样返回,changed=false(零分配零改动)。 | |
| // | |
| // 这是「让请求通过」的安全网:只要存在合法配对就整段保留这些字段,绝不吞掉正确配对。 | |
| // 返回清理后的 slice(无改动时等于原 slice,勿依赖其是否新分配)及是否发生删除。 | |
| func cleanupOrphanToolCalls(messages []any) ([]any, bool) { | |
| if len(messages) == 0 { | |
| return messages, false | |
| } | |
| callIDs := map[string]bool{} | |
| resultIDs := map[string]bool{} | |
| hasTraffic := false | |
| for _, m := range messages { | |
| msg, ok := m.(map[string]any) | |
| if !ok { | |
| continue | |
| } | |
| switch msg["role"] { | |
| case "tool": | |
| if id, ok := msg["tool_call_id"].(string); ok && id != "" { | |
| resultIDs[id] = true | |
| hasTraffic = true | |
| } | |
| case "assistant": | |
| if tcs, ok := msg["tool_calls"].([]any); ok { | |
| for _, tci := range tcs { | |
| tc, ok := tci.(map[string]any) | |
| if !ok { | |
| continue | |
| } | |
| if id, ok := tc["id"].(string); ok && id != "" { | |
| callIDs[id] = true | |
| hasTraffic = true | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if !hasTraffic { | |
| return messages, false | |
| } | |
| // keepCalls:调用 id 是否双侧齐全(调用存在且结果存在)。重复 id 与乱序均按集合处理。 | |
| keepCalls := map[string]bool{} | |
| for id := range callIDs { | |
| if resultIDs[id] { | |
| keepCalls[id] = true | |
| } | |
| } | |
| changed := false | |
| // 1) assistant.tool_calls:按 keepCalls 对称裁剪——只留有结果的调用,过滤后为空则删键。 | |
| // | |
| // 历史实现是「批内每个 id 都齐才整批保留,否则删掉整个 tool_calls 键」。那会留下 | |
| // 无主结果:批 [c1,c2] 只回了 c1 时,调用侧整批被删,而 tool{c1} 仍按 id 命中 | |
| // keepCalls 得以保留 —— 出站载荷于是变成「无 tool_calls 的 assistant + 孤儿 tool」, | |
| // 上游判 11148(tool calls and tool results do not match)并顶死整条会话。 | |
| // 现在两侧共用同一份 keepCalls 按 id 对称裁剪(与 2) 的删除侧同口径), | |
| // 任何输入都不会再产生半截配对。 | |
| for _, m := range messages { | |
| msg, ok := m.(map[string]any) | |
| if !ok { | |
| continue | |
| } | |
| if role, _ := msg["role"].(string); role != "assistant" { | |
| continue | |
| } | |
| tcs, ok := msg["tool_calls"].([]any) | |
| if !ok || len(tcs) == 0 { | |
| continue | |
| } | |
| keptCalls := make([]any, 0, len(tcs)) | |
| for _, tci := range tcs { | |
| tc, ok := tci.(map[string]any) | |
| if !ok { | |
| continue | |
| } | |
| if id, _ := tc["id"].(string); keepCalls[id] { | |
| keptCalls = append(keptCalls, tc) | |
| } | |
| } | |
| if len(keptCalls) == len(tcs) { | |
| continue // 整批齐全:零改动 | |
| } | |
| changed = true | |
| if len(keptCalls) == 0 { | |
| delete(msg, "tool_calls") | |
| continue | |
| } | |
| msg["tool_calls"] = keptCalls | |
| } | |
| // 2) role:tool 结果:只有对应 tool_call 被保留才保留;孤儿结果整条删除。 | |
| kept := make([]any, 0, len(messages)) | |
| for _, m := range messages { | |
| msg, ok := m.(map[string]any) | |
| if !ok { | |
| kept = append(kept, m) | |
| continue | |
| } | |
| if role, _ := msg["role"].(string); role == "tool" { | |
| id, _ := msg["tool_call_id"].(string) | |
| if !keepCalls[id] { | |
| changed = true | |
| continue | |
| } | |
| } | |
| kept = append(kept, m) | |
| } | |
| if !changed { | |
| return messages, false | |
| } | |
| return kept, true | |
| } | |
| // mergeAdjacentToolCalls 把**背靠背**的 assistant.tool_calls 消息合成一条(tool_calls 依原序拼接)。 | |
| // | |
| // 2026-09-20 实机定位并复现的 11148 事故根因:部分 OpenAI 兼容 agent 客户端回放历史时把 | |
| // 同一批并行工具调用拆成多条紧邻的独立 assistant 消息,出站载荷长成: | |
| // | |
| // assistant tool_calls=[c00] | |
| // assistant tool_calls=[c01] | |
| // tool c00 | |
| // tool c01 | |
| // | |
| // 上游要求「声明 tool_calls 的 assistant 之后必须紧跟它自己的结果」——紧随的若是另一条带 | |
| // tool_calls 的 assistant,即返回 400 code=11148(extError tool_call_sequence_broken, | |
| // "tool calls and tool results do not match, please start a new conversation and retry"), | |
| // 整条会话报废:客户端每次重试重放同一条历史,池侧换号也无效(不是账号问题)。 | |
| // | |
| // 对照实验(2026-09-20 在线上网关实测,同一批调用): | |
| // - 拆成两条 assistant(Codex Desktop 报文形状)+ deepseek-v4.1-flash → 503 / 11148 | |
| // - 合成一条 assistant(=本函数产物) + deepseek-v4.1-flash → 200 | |
| // - 拆成两条 assistant + glm-5.3-flash → 200(该模型宽容) | |
| // | |
| // 即:**行为本身合法**(上述第一种形态在 OpenAI 规范里也说得通),是上游 deepseek 系模型的 | |
| // 校验更严。网关作为最后一道防线按最严口径归一,客户端不必感知。 | |
| // | |
| // 合并条件从严,避免引入新语义: | |
| // - 两条消息**相邻**(中间隔着任何消息都不合并——隔着消息说明不是同一批声明, | |
| // 凭猜测合并会改变语义,这类形态按其原样交给 repackToolResultBlocks 处理); | |
| // - 后一条 content 为空(content 非空无法无损拼接,不猜语义); | |
| // - 前一条本身必须是带 tool_calls 的 assistant(否则不合并,例如 assistant 文本 + 独立工具调用消息)。 | |
| // | |
| // reasoning_content(thinking.go 的多轮回填字段)不丢:后一条有则搬到合并结果,两边都有则换行拼接 | |
| // (deepseek 多轮要求 assistant 带思维链回填,丢弃会换一个错误)。 | |
| func mergeAdjacentToolCalls(messages []any) ([]any, bool) { | |
| if len(messages) < 2 { | |
| return messages, false | |
| } | |
| out := make([]any, 0, len(messages)) | |
| changed := false | |
| for _, m := range messages { | |
| msg, ok := m.(map[string]any) | |
| if !ok { | |
| out = append(out, m) | |
| continue | |
| } | |
| if role, _ := msg["role"].(string); role == "assistant" && len(out) > 0 { | |
| // 形态一:本条是带 tool_calls 的 assistant 且没有正文 → 并入上一条 | |
| // 同为 assistant 且带 tool_calls 的消息(背靠背的并行调用声明)。 | |
| if tcs, ok := msg["tool_calls"].([]any); ok && len(tcs) > 0 && emptyContent(msg["content"]) { | |
| if prev, ok := out[len(out)-1].(map[string]any); ok { | |
| if prevRole, _ := prev["role"].(string); prevRole == "assistant" { | |
| if prevCalls, ok := prev["tool_calls"].([]any); ok && len(prevCalls) > 0 { | |
| prev["tool_calls"] = append(prevCalls, tcs...) | |
| mergeReasoningContent(prev, msg) | |
| changed = true | |
| continue // 本条已并入上一条,不再单独出站 | |
| } | |
| } | |
| } | |
| } | |
| // 形态二(反向):本条是纯正文 assistant,上一条是带 tool_calls 但没正文的 | |
| // assistant → 把正文折进上一条,合成 assistant(正文 + tool_calls)。这样 | |
| // "声明 tool_calls 的 assistant 紧跟它自己的结果"在两种拆分顺序下都成立。 | |
| // | |
| // 谁会产出这个顺序:部分 OpenAI 兼容 agent 客户端的回放顺序(正文与 | |
| // 调用声明拆成两条独立 assistant)。 | |
| // | |
| // 条件同样从严:只认**字符串正文**(数组正文可能含多模态块,拼接会丢结构, | |
| // 交给 repackToolResultBlocks 原样处理);上一条必须自身无正文。 | |
| if _, hasCalls := msg["tool_calls"]; !hasCalls { | |
| if txt, ok := msg["content"].(string); ok && txt != "" { | |
| if prev, ok := out[len(out)-1].(map[string]any); ok { | |
| if prevRole, _ := prev["role"].(string); prevRole == "assistant" { | |
| if prevCalls, ok := prev["tool_calls"].([]any); ok && len(prevCalls) > 0 && emptyContent(prev["content"]) { | |
| prev["content"] = txt | |
| mergeReasoningContent(prev, msg) | |
| changed = true | |
| continue // 正文已折进上一条 | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| out = append(out, m) | |
| } | |
| if !changed { | |
| return messages, false | |
| } | |
| return out, true | |
| } | |
| // mergeReasoningContent 把 src 的 reasoning_content 并入 dst(两边都有则换行拼接)。 | |
| // deepseek 多轮要求 assistant 带思维链回填,合并时丢弃会换一个错误。 | |
| func mergeReasoningContent(dst, src map[string]any) { | |
| rc, _ := src["reasoning_content"].(string) | |
| if rc == "" { | |
| return | |
| } | |
| if prevRC, _ := dst["reasoning_content"].(string); prevRC != "" { | |
| dst["reasoning_content"] = prevRC + "\n" + rc | |
| return | |
| } | |
| dst["reasoning_content"] = rc | |
| } | |
| // emptyContent content 是否为空(缺失 / nil / 空串 / 空数组)。 | |
| // 空数组也必须算空:有客户端把"没有正文"发成 `content: []` 而不是 null,此前按 | |
| // "非字符串一律非空"处理 → 背靠背的两条 assistant(tool_calls) 不合并 → 上游 | |
| // deepseek 系判 11148(正是 mergeAdjacentToolCalls 要挡的形态,条件漏了 [])。 | |
| // 非空数组仍视为有内容:宁可漏合并,也不丢内容。 | |
| func emptyContent(v any) bool { | |
| switch c := v.(type) { | |
| case nil: | |
| return true | |
| case string: | |
| return c == "" | |
| case []any: | |
| return len(c) == 0 | |
| } | |
| return false | |
| } | |