Skip to content

Repair unpaired tool calls before strict Responses upstreams see them - #2447

Closed
JunkaiWang-TheoPhy wants to merge 1 commit into
jlcodes99:mainfrom
JunkaiWang-TheoPhy:fix/deepseek-toolcall-pairing
Closed

JunkaiWang-TheoPhy wants to merge 1 commit into
jlcodes99:mainfrom
JunkaiWang-TheoPhy:fix/deepseek-toolcall-pairing

Conversation

@JunkaiWang-TheoPhy

@JunkaiWang-TheoPhy JunkaiWang-TheoPhy commented Sep 15, 2026 •

Copy link
Copy Markdown

Part of #2433 (split out of #2434 for reviewability).

Problem

Codex's app-server can serialize the next sampling request before the last function_call_output of a parallel tool batch reaches conversation history (openai/codex#44604). The request then carries a function_call with no output, and DeepSeek rejects the whole turn:

400 {"error":{"message":"No tool output found for tool call call_...","type":"invalid_request_error"}}

The same rule has a second face: injected inputs (automations, send_message_to_thread, create_thread) arrive as a standalone named function_call_output — a shape upstream supports deliberately (openai/codex#39782, #39791) but strict providers reject for a different reason: 400 input: missing field 'call_id'. And adding only a call_id does not fix it — with no matching function_call the upstream answers No tool call found for tool output with call_id ... (reproduced at the wire level on macOS against https://api.deepseek.com).

Change

One repair pass on the provider gateway's request path, scoped to api.deepseek.com:

  • call without output → insert an explicit failure output right after the call (tool result unavailable: the local agent did not commit an output for this call). A placeholder rather than an empty success, so the model can decide whether to retry.
  • output without call → synthesize the function_call the injected output never had (reusing name/namespace, sharing one call_id) and keep the original content instead of dropping it.
  • complete requests → byte-identical passthrough; other gateways untouched.

The logic lives in a new provider_gateway_tool_pairing.go; provider_gateway.go gains only the call site plus a diagnostic event (provider_gateway_tool_call_pairing_repaired).

Verification

  • go test -count=1 . -run TestProviderGateway — 5 new cases: missing output injected, standalone output paired, other providers byte-identical, complete requests byte-identical, host predicate.
  • Live A/B against the running gateway: the same bodies that the released binary rejects with the two 400s above return 200 after this change.
  • Deployed on a live machine for a day; the paired-request fast path is byte-identical, so unaffected traffic cannot regress.

Not covered

/responses/compact and websocket paths; other strict upstreams (Kimi, Azure) are not verified — the predicate stays scoped to api.deepseek.com to keep the blast radius small.

Note

This PR is independent of #2448 and will be independent of the reasoning-text fix once that PR is opened. They share no functions.

Codex 的 app-server 可能在并行工具批次的最后一个 function_call_output 落盘前发出下一次
sampling 请求(openai/codex#44604),请求体里因此带着没有输出的 function_call。DeepSeek 的
Responses 上游逐个校验配对,直接以 400 `No tool output found for tool call ...` 拒掉整轮。

注入型输入是同一规则的另一面:上游把 standalone 命名 `function_call_output`(automation、
跨线程投递)当外部上下文支持(openai/codex#39782、#39791),但严格上游要求 call_id,而只补
call_id 仍然会被拒——没有配对 function_call 时返回 `No tool call found for tool output with
call_id ...`(已做 wire 级复现)。

本补丁在 provider gateway 出口做配对归一化:缺输出的 call 之后插入一条显式失败的占位结果
(不是空成功);孤立的 standalone 输出补出共享同一 call_id 的 function_call 并保留原内容;
配对完整时字节透传,非 api.deepseek.com 的 provider 不受影响。逻辑独立成
provider_gateway_tool_pairing.go,provider_gateway.go 只增加一个调用点。

Constraint: 严格上游按请求校验配对,修复必须发生在请求离开网关之前
Rejected: 只补 call_id | 无配对 function_call 时上游仍以 `No tool call found for tool output` 拒绝
Rejected: 空字符串占位 output | 会让模型误以为工具成功,掩盖真实状态
Confidence: high
Scope-risk: narrow
Directive: 接入新的严格上游时复用 providerGatewayNormalizeToolCallPairing
Tested: go test -count=1 . -run TestProviderGateway;5 个新用例(缺输出注入、standalone 配对、第三方不改写、完整请求字节一致、host 判定)
Not-tested: /responses/compact 与 websocket 路径;Kimi/Azure 等其他严格上游
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-15T14:39:09.131971Z 6845f1b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@gangsidaqiao

Copy link
Copy Markdown

补充一份 Windows 真机对照(2026-09-21),针对本 PR 的「output without call → 合成配对」一类:

环境:Windows / Codex 桌面端 26.903.9818.0 / Cockpit Tools v1.3.57(sidecar 网关)/ 严格 Responses 上游。

用与桌面端真实注入一致的形态,直接对 /v1/responses 做 A/B:

  1. 独立 function_call_output(name=send_message_to_thread,namespace=codex_app,无 call_id;正文含口令)→ HTTP 200,但模型回复 NO-SEEN:正文不可见;
  2. 同一正文 + 合成配对 function_call(call_id=p1)→ 模型原样读出 PINEAPPLE9:正文可见;
  3. 绕过网关直连上游重复 1) / 2):结果一致——未配对输出被静默忽略,且不报错。

旁证:v1.3.57 的 cockpit-cliproxy.exe 内可见顺序修复诊断(provider_gateway_tool_call_outputs_relocated),未见配对修复相关标记。

实际影响:跨窗口消息 / 注入型 standalone 输出(send_message_to_thread、automation 等)在严格上游上仍是「目标窗口被唤醒、但模型看不到正文」。

当前 PR 显示 CONFLICTING,能否在最新 main 上 rebase 后合并?需要完整复现脚本我可以补充。

@gangsidaqiao

Copy link
Copy Markdown

补充:跨窗口消息在 chat_completions 链路下仍会丢正文(Windows 真机 + 实际中转站,v1.3.57/1.3.58)

接上面那份对照报告,再补一个部署态的关键变体——它解释了很多用户反馈的「只有部分窗口能发、收了看不到」:

现象

  1. send_message_to_thread 注入到对端窗口后,对端被唤醒但模型看不到正文(如前所述);
  2. 更隐蔽的一类:网关 wireApi=chat_completions(走 Chat Completions 转换,而非直连 Responses)时,
    #2447 的配对修复即使触发(合成出 call_standalone_* 配对),转换器仍会把独立注入输出转成
    role=tool, tool_call_id="" 的空 ID 工具消息,严格上游会静默丢弃——正文还是到不了模型。

为什么 #2447 的现有实现覆盖不到

  • providerGatewayRepairsToolCallPairing 只在 api.deepseek.com 上启用;实际大量用户走第三方中转(域名各异),修复不生效;
  • 修复只挂在 wireAPI == "responses" 分支;chat_completions 分支不做维修;
  • 1.3.58 新增的 normalizeProviderGatewayCallIDs 对「有名字的独立输出」按设计保留原样(codexCallOutputCanStandAlone),
    随后 chat 转换阶段没有任何环节把它变成合法配对或合法兜底消息 → 正文丢失。

我在真机上的最小修复(实测通过,供参考/合并)

  • 在配对维修前,对 responses 与 chat_completions 两条链路都执行同一个归一化:
    • 「有 output 无 call」→ 合成 function_call(复用 item 的 name/namespace,arguments:"{}")+ 给 output 补 call_id,保留原文;
    • 「有 call 无 output」→ 紧跟其后补一条显式失败占位输出(不要空成功);
    • 完全配对时逐字节透传(保证正常流量零影响)。
  • 开关改为按链路判定:responses 链路按域名白名单(DeepSeek 官方 + 实际中转站 + 环境变量 COCKPIT_SIDECAR_PAIRING_HOSTS 可加白);chat_completions 链路直接全局启用(该形态在该链路无法优雅透传)。

真机验证(Windows,Codex Desktop 26.903.9818.0 + Cockpit 1.3.57/1.3.58,第三方中转)

场景 打补丁前 打补丁后
独立注入输出(send_message_to_thread 形态) NO-SEEN(正文丢失) 模型读到口令
历史含完整配对 + 新注入消息 NO-SEEN 读到口令(10/10 反复通过)
正常已配对请求 正常 逐字节透传(无变化)

另外附一个容易被误判的小坑:「完全配对就跳过」的判断必须把无 call_id 的注入输出算进例外,否则「历史已有完整配对 + 一条新注入」会被误判为无需维修而跳过,注入正文再次丢失(我在初版补丁上踩过,第二版修正后 10/10 通过)。

建议把该修复并入 #2447(或独立成 PR)后,随新版本发布——目前 1.3.58 对这类「chat 链路 + 注入消息」的组合仍会丢正文。

(如需要,我可以补一份最小复现脚本与逐字节请求/响应 dump。)

@gangsidaqiao

Copy link
Copy Markdown

催办 + 1.3.59 实测:这个问题至今仍会丢跨窗口消息正文(含两条可选修复路径)

维护者你好,又打扰了。1.3.59 发布后我们在真机上重新验证了一遍,结论如下,供决策:

1.3.59 现状(真机实测)

  1. 新加的官方「孤立 delegation 兼容」codex.orphan-delegation-compatibility 方向是对的(把孤立输出降级成 user 消息),
    但它 ①默认关闭、②要求请求带 X-AI Platform-assistant: collab_spawn 头才生效。
    本机(Codex Desktop 26.917.8451.0 + Cockpit 1.3.59 + 第三方中转 wireApi=chat_completions)实测:
    默认配置下 send_message_to_thread 注入消息仍会丢正文(模型看不到,网关探针返回 NOT-FOUND)。
  2. 因此 Repair unpaired tool calls before strict Responses upstreams see them #2447 描述的「output without call」修复在 1.3.59 上仍不可少。

请在下列两条路径中任选其一(都行,我们只希望默认可用)

  • 路径 A(合并 Repair unpaired tool calls before strict Responses upstreams see them #2447 方向):把配对归一化同时挂到 responses 与 chat_completions 链路;
    responses 链路把启用域名从 api.deepseek.com 扩展到「白名单 + 环境变量可追加」;
    并注意「完全配对就跳过」的判定要排除无 call_id 的注入输出(否则历史已有配对时新注入会被误判跳过)。
  • 路径 B(扩展官方开关):把 1.3.59 的 orphan-delegation-compatibility 默认打开(至少在 provider gateway 的
    chat_completions 链路),并去掉对 collab_spawn 头的依赖——该头只在 spawn_agent 场景出现,
    普通多窗口 send_message_to_thread 收不到保护。

真机验证数据(供参考)

场景 默认 1.3.57/1.3.58/1.3.59 打上上述修复后
独立注入输出(跨窗口消息形态) 正文丢失(NOT-FOUND) 模型读到口令(10/10)
历史含完整配对 + 新注入 正文丢失 读到口令
正常已配对请求 正常 逐字节透传(无变化)

复现极简:向 localhost:<sidecar-port>/v1/responses POST 一条 input 含
{"type":"function_call_output","name":"send_message_to_thread","namespace":"codex_app","output":"..."}(无配对 function_call),
请求里让模型「若看到口令 X 就复述」。默认版本模型回 NOT-FOUND;修复后复述出 X。

如果需要,我可以把在 1.3.59 上实测通过的最小补丁(约 240 行、带单测思路)整理成 PR 直接提交。谢谢!

@jlcodes99

Copy link
Copy Markdown
Owner

感谢复现未配对工具的上游拒绝问题,#2433 仍会保留跟进。但本补丁当前不具备安全合入条件:快速路径只比较 call/output 数量,不核对 ID 集合,call A + output B 会原样放行;多个没有 ID 的独立输出会生成相同的 call_standalone_orphan。这两个问题已用当前 PR 源码独立复现。另外,尚在执行但结果未落盘的工具不能直接判定失败并引导重试,否则可能重复执行有副作用的操作。这里关闭当前方案,后续应按 ID 集合与唯一性校验,并明确 pending/失败恢复语义后另提修复。

代码
ID

@jlcodes99 jlcodes99 closed this Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants