File size: 13,848 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 | // 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
}
|