Skip to content

gateway: an item between a batch's tool calls and their results is relayed as is, so a strict Responses upstream refuses the request (patch attached) #1752

Closed

Description

@Edward4226

What happens

A request that reaches an upstream with a non-tool item between a batch's tool calls and one of its results is refused by every strict upstream. Codex writes such an item itself: when a tool returned an image it resized, its <image_resize_notice> goes between the two results of one assistant step.

Reported to Codex and opencode first, one assistant step with view_image and exec_command:

function_call          view_image      call_00_hC0DonBjeKHawzABcyLL1642
function_call          exec_command    call_01_mNySew0rARWuQYQXPeIY2319
function_call_output   call_00_...     (the image, 1024x9000)
message (developer)    <image_resize_notice>…</image_resize_notice>
function_call_output   call_01_...     (the command's output)

Every call in that body has its result. Relayed as is:

  • Volcengine Agent Plan (deepseek-v4.1-flash) answers 400 An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)
  • OpenCode Zen/Go (deepseek-v4.1-flash) answers 400 No tool output found for tool call <id> — and only for an interleave their gateway doesn't recognize: with this notice item they answer 200, with any other text there 400. The original report (request_id 01271f1e-0095-4424-9a58-7ccb17b37182) is one of these two shapes.

The chat path has mended this since #1275 (pairToolMessages closes a run, interrupted ones included, before another message) and #1341 mended the missing-result half of it for Responses (pairToolItems). The Responses relay itself still passes an interrupted run through.

The change

joinToolRuns, beside orphanedToolOutputs and pairToolItems in the normalization attempt runs for a relayed Responses request (internal/gateway/responses.go, internal/gateway/gateway.go): an item that sits inside a run of tool calls and results is moved to the end of that run. A run is open while one of its calls has been seen and the result answering it is still ahead, so an item between two whole exchanges stays where it is; a request with no tool items, one whose exchanges are each whole, and a body that isn't a Responses request come back byte for byte. It runs before the request is relayed or translated, so every destination whose own form is strict gets it.

The item's own bytes, its role and its order among the other non-tool items are as they were; a native ChatGPT (Codex account) destination is left as it came.

One expectation changed on purpose: TestGrokGetsAgentMessageAsUser's turn holds a subagent's reply between the send_message call and its output, so Grok is now given that reply after the run. Its conversion is unchanged (same user message, same From <author> to <recipient> header, same text and images) and only its place moved; the community plugin (packages/grok) converts in place the same way. Narrow the rule to messages other than agent_message if Grok's backend needs the reply in place.

The patch

The repository's PR policy is collaborators_only, so this couldn't be sent as a PR (GitHub answers Edward4226 does not have the correct permissions to execute CreatePullRequest). The change is on a branch of my fork instead:

git fetch https://github.com/Edward4226/magpie.git codex/keep-tool-runs-together && git cherry-pick 03626b2e applies it — cherry-picked onto origin/main fd035923 with no conflict; the later internal/gateway changes do not touch the files this patch edits.

What was run

  • go test -tags nogui ./internal/gateway/ — 2 failures, both red on origin/main 6c1c0a7 as well and neither from this change: TestClaudeAsRootEnv (a Linux-only expectation with no Windows guard) and TestSweepBridgeProjectsTakesOnlyTheBridgesFolders (needs a symlink privilege this Windows box doesn't hold). Everything else passes, TestQueuedStreamKeptAliveOnceSentToItsVendor included (it failed once on main under load — the queued-stream timing the standards name).

  • the same run under a temporary HOME with GOPATH/GOMODCACHE/GOCACHE pinned from go env: the same two failures.

  • gofmt -l on the four changed files: nothing. go vet ./..., GOOS=linux go vet -tags nogui ./..., GOOS=windows go vet ./...: clean. go build, GOOS=windows go build, GOOS=linux go build -tags nogui: ok.

  • without the change (the one call-site line taken out) TestResponsesRelayKeepsAToolRunTogether prints status 400: {"error":{"code":null,"message":"Fake: An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.",…}}; with the line back it passes.

  • real upstreams, the shape above, the same build with the line and without it, streamed the way Codex sends it:

    upstream / model run whole notice between the results any other note there
    Volcengine Agent Plan / deepseek-v4.1-flash 200 → 200 400 → 200 400 → 200
    OpenCode Zen/Go / deepseek-v4.1-flash 200 → 200 200 → 200 400 → 200
  • the reporter's own rebuilt turn (1399 items, from their rollout) to Volcengine: refused insufficient tool messages following tool_calls message without the change, past that check with it (what is left is the input's length, 1.7M tokens, over the model's limit).

Not tried

Refs: #1275, #1341. Reported as anomalyco/opencode#54698, openai/codex#44604.

Activity

  1. Edward4226 commented on Oct 12, 2026

    @Edward4226
    ContributorAuthor

    更新一下基线和验证,方便直接 git am / cherry-pick。

    这个补丁做什么

    在 Responses 转发之前,把"一批工具调用 + 它们的返回"里被夹进去的非工具项(Codex 自己在图片被缩放时会插 <image_resize_notice>)移到这串的末尾,严格上游就不再把它后面的 output 判成缺失。这与 #1341(b3330ea8)修的是同一个问题的互补形状:#1341 管"整个 input 里都没有结果",这里管"结果在、只是被夹开"。这个形状的独立报告在 #1822(call_00 输出 → notice → call_01 输出 → No tool output found),上游 deepseek-ai/DeepSeek-V3#1588 同型。

    为什么是这个形状(含考虑过的替代做法)

    它和 #1341 里的 pairToolItems、以及 chat 路径的 pairToolMessages(#1275,internal/gateway/chat.go:522)是同一条规则:一次工具交换当作一个不可分割的 run,先闭合,再让别的 item 排在后面 —— chat 路径早就是把一次交换的答案攥住、等它闭合才一起输出,夹在交换中间的空 assistant 消息甚至直接丢弃;你 b3330ea 里把合成结果放到整串末尾,用的也是这条规则。joinToolRuns 只在"调用确实有结果、却被非工具项隔开"时才移动那条项;没隔开就返回原字节,也不动调用与结果的相对顺序。它放在 attempt 已有的那个归一化点(gateway.go:2910 的 if)、在 relay/translate 分叉之前,所以两个方向都吃到同一个形状。

    考虑过、没选的两个:① 只把 <image_resize_notice> 折进它所描述的那条 output —— 顺序完全不动、notice 仍紧贴图片,但只对"描述前一条输出"这类条目成立,夹进 reasoning 或真消息就不适用;② 被 400 拒绝后才归一化重试(refusesInput / bareReasoningRefused 那套)—— happy path 一个字节不动,但要认两种不同的 400 文案,而且那套重试写在 passthrough 里、只覆盖 relay 路径,translate 方向够不着。

    如果更希望 item 完全不移动,我可以改成 ①;如果更希望少一个独立 pass,也愿意把它折进 pairToolItems / orphanedToolOutputs 做一次遍历(行为不变)。

    可直接采纳

    $ git fetch https://github.com/Edward4226/magpie.git codex/keep-tool-runs-together
    $ git cherry-pick 03626b2e
     4 files changed, 266 insertions(+), 11 deletions(-)
    

    在 fd035923 上零冲突(正文里"main 的两个新提交只动 gui/provider"那句已过时:其后 5 个提交动过 internal/gateway 的 cors / decide / codex_backend,未碰本补丁改的三个文件)。也可以直接用下面折叠块里的 format-patch。

    验证

    $ go test -tags nogui -count=1 -run 'TestJoinToolRunsMovesAnItemOutOfARun|TestJoinToolRunsLeavesWholeExchangesAlone|TestResponsesRelayKeepsAToolRunTogether' ./internal/gateway/
    --- PASS: TestJoinToolRunsMovesAnItemOutOfARun (0.00s)
    --- PASS: TestJoinToolRunsLeavesWholeExchangesAlone (0.00s)
    --- PASS: TestResponsesRelayKeepsAToolRunTogether (0.16s)
    ok  	github.com/yetone/magpie/internal/gateway	2.596s
    

    把 gateway.go:2911 的 body = joinToolRuns(body) 删掉即复红:

    --- FAIL: TestResponsesRelayKeepsAToolRunTogether (0.16s)
        join_tool_runs_test.go:149: status 400: {"error":{"code":null,"message":"Fake: An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.","param":null,"type":"invalid_request_error"}}
    

    真实上游 A/B(同一构建,只差那一行;列为"整串不动 / notice 夹在两条结果之间 / 任意文本夹在那里"):

    上游 整串不动 notice 夹中间 任意文本夹中间
    Volcengine Agent Plan deepseek-v4.1-flash 200→200 400→200 400→200
    OpenCode Zen/Go deepseek-v4.1-flash 200→200 200→200 400→200

    边界

    • 只挂在 attempt 里已有的那个 if(gateway.go:2910,from == provider.Responses && (p.Account == nil || p.Account.Agent != "codex")),即"发给非原生 ChatGPT 账号的中继 /responses"。
    • 逐字节不变:没有工具项的请求、交换本来就完整的请求、非 Responses 的 body、原生 ChatGPT 目的地。
    • 一处既有断言更新:grok_agent_message_test.go 的 TestGrokGetsAgentMessageAsUser —— 子代理那条消息从 send_message 调用与结果之间移到整串之后;转换出的内容不变(同一条 user 消息、同样的 From … to … 头、同样文字与图片)。若 Grok 后端需要它在原位,把规则收窄到非 agent_message 即可,说一声我来改。

    本补丁不声明修的东西

    原始报告里那次 OpenCode Zen/Go 的 400,用报告者自己的字节复现不出来(两条结果都在、notice 夹中间,改前改后都 200),可能属 Codex 自己 follow-up 请求体早于该批最后一个 output 组装(openai/codex#44604)。可复现的依据是 #1822 的序列与上面的 A/B。

    两个可选项

    1. joinToolRuns 要不要折进 orphanedToolOutputs / pairToolItems,合成一个"工具串归一化"的 pass?名字也可换。
    2. docs/subsystems/gateway-routing.md 要不要我补一条(挨着 pairFunctionTurns / gateway: 被打断的工具调用在 Gemini/Vertex/Code Assist 上游得到合成结果,而不是 400 #1560 那条),还是你合并时自己写?

    补丁全文 03626b2e(git am 可直接采纳):

    03626b2 — an item between a batch's tool calls and their results.patch
    From 03626b2e6034f020feeb260157cb808e6d5fbe21 Mon Sep 17 00:00:00 2001
    From: Edward4226 <[email protected]>
    Date: Mon, 12 Oct 2026 15:04:49 +0800
    Subject: [PATCH] gateway: an item a batch's tool results were interrupted by
     goes after them, so strict Responses upstreams stop refusing the relayed
     request
    
    What: the normalization a relayed Responses request goes through before it
    leaves (gateway.go, attempt) gains joinToolRuns beside orphanedToolOutputs
    and pairToolItems: an item that sits inside a run of tool calls and results
    - between a call and the result that answers it - is moved to the end of
    that run. A run is open while one of its calls has been seen and the result
    answering it is still ahead, so an item between two whole exchanges stays
    where it is. Codex writes such an item itself: when a tool returned an
    image it resized, its <image_resize_notice> goes between one batch's two
    results (the report from the Codex app on Windows, one assistant step with
    view_image and exec_command).
    
    Before, the run reached the upstream with the item inside it and a strict
    one refused the whole request with 400: Volcengine's Agent Plan "An
    assistant message with 'tool_calls' must be followed by tool messages
    responding to each 'tool_call_id'", OpenCode Zen/Go "No tool output found
    for tool call <id>" - both naming a result that is in the request. After,
    the results follow the calls, the item goes after them, and both answer
    200. This is the rule the chat path has had since #1275 (pairToolMessages
    closes a run with its results, interrupted ones included, before another
    message) and the same family as #1341 (pairToolItems, a call nothing
    answers), and it is applied before the request is relayed or translated,
    so every destination whose own form is strict gets it in the same change.
    
    The item's own bytes, its role and its order among the other non-tool items
    are as they were. A request with no tool items, one whose exchanges are each
    whole, and a body that is not a Responses request come back byte for byte
    (TestJoinToolRunsLeavesWholeExchangesAlone). One expectation changed on
    purpose: TestGrokGetsAgentMessageAsUser's turn holds a subagent's reply
    between the send_message call and its output, and Grok is now given that
    reply after the run. Its conversion is unchanged (same user message, same
    "From <author> to <recipient>" header, same text and images) and only its
    place moved, the way every other item Grok is handed keeps the place magpie
    gave it; a review that knows Grok's backend needs the reply in place can
    narrow the rule to messages other than agent_message.
    
    Run:
    - go test -tags nogui ./internal/gateway/: 2 failures, both red on
      origin/main 6c1c0a7 as well and neither from this change -
      TestClaudeAsRootEnv (a Linux-only expectation with no Windows guard) and
      TestSweepBridgeProjectsTakesOnlyTheBridgesFolders (needs a symlink
      privilege this Windows box doesn't hold). Everything else passes,
      TestQueuedStreamKeptAliveOnceSentToItsVendor among them (that one failed
      once on main under load; it is the queued-stream timing in the standards).
    - the same run under a temporary HOME with GOPATH/GOMODCACHE/GOCACHE pinned
      from go env: the same two failures.
    - gofmt -l on the four changed files: nothing. go vet ./..., GOOS=linux go
      vet -tags nogui ./..., GOOS=windows go vet ./...: clean. go build,
      GOOS=windows go build, GOOS=linux go build -tags nogui: ok.
    - without the change: with the one call-site line taken out,
      TestResponsesRelayKeepsAToolRunTogether prints
      `status 400: ... "Fake: An assistant message with 'tool_calls' must be
      followed by tool messages responding to each 'tool_call_id'."`; the line
      back, it passes.
    - real upstreams, the reporter's shape (two parallel calls, an image
      result, Codex's <image_resize_notice>, the second result), the same build
      with the line and without it, streamed the way Codex sends it:
      Volcengine Agent Plan / deepseek-v4.1-flash - run whole 200/200; the
      notice between the results 400 "insufficient tool messages following
      tool_calls message" then 200; any other note there 400 then 200.
      OpenCode Zen/Go / deepseek-v4.1-flash - run whole 200/200; a note between
      the results 400 "No tool output found for tool call call_b" then 200; the
      notice between them 200/200, their gateway recognizing that one item.
    - the reporter's own rebuilt turn (1399 items, from their rollout) to
      Volcengine: refused "insufficient tool messages following tool_calls
      message" without the change, past that check with it (what is left is the
      input's length, 1.7M tokens, over the model's limit).
    
    Not tried: a real Grok subscription - the reply's new place is unverified
    against xAI's backend, though its conversion is unchanged and its plugin
    (community plugins packages/grok) converts in place the same way; a real
    ChatGPT (native Codex account) destination, which takes standalone tool
    outputs itself and is left as it came; the Windows GUI. The OpenCode Zen/Go
    400 of the original report is not reproduced by the reporter's own bytes:
    with both results and the notice between them that gateway answers 200
    before and after this change, so the report's refusal may be the follow-up
    body assembled before the batch's last output was in it
    (openai/codex#44604).
    
    Refs: #1341, #1275. Reported as anomalyco/opencode#54698 and
    openai/codex#44604.
    ---
     internal/gateway/gateway.go                 |   1 +
     internal/gateway/grok_agent_message_test.go |  13 +-
     internal/gateway/join_tool_runs_test.go     | 162 ++++++++++++++++++++
     internal/gateway/responses.go               | 101 +++++++++++-
     4 files changed, 266 insertions(+), 11 deletions(-)
     create mode 100644 internal/gateway/join_tool_runs_test.go
    
    diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
    index 9e51ae3c..0bb70963 100644
    --- a/internal/gateway/gateway.go
    +++ b/internal/gateway/gateway.go
    @@ -2908,6 +2908,7 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P
     	// Normalize for the actual destination, separately on each fallback.
     	// Native ChatGPT accounts accept standalone tool outputs themselves.
     	if from == provider.Responses && (p.Account == nil || p.Account.Agent != "codex") {
    +		body = joinToolRuns(body)
     		body = orphanedToolOutputs(body)
     		body = pairToolItems(body)
     	}
    diff --git a/internal/gateway/grok_agent_message_test.go b/internal/gateway/grok_agent_message_test.go
    index 7c2ad18c..f8b2b859 100644
    --- a/internal/gateway/grok_agent_message_test.go
    +++ b/internal/gateway/grok_agent_message_test.go
    @@ -22,21 +22,24 @@ const agentMessageTurn = `"input":[
     	{"type":"function_call_output","call_id":"c1","output":"sent"}]`
     
     // checkAgentMessageSent fails unless Grok was given the subagent's reply
    -// as the user's message, in its place, its sender and recipient first.
    +// as the user's message, after the run it broke, its sender and recipient
    +// first. The reply sat between the call and its output on the way in;
    +// joinToolRuns puts it after the output, so the exchange Grok reads is
    +// whole. Its text, its role, and its conversion are as they were.
     func checkAgentMessageSent(t *testing.T, q map[string]any) {
     	t.Helper()
     	in, _ := q["input"].([]any)
     	if len(in) != 4 {
     		t.Fatalf("Grok was given %v", q["input"])
     	}
    -	m, _ := in[2].(map[string]any)
    +	if o, _ := in[2].(map[string]any); o["type"] != "function_call_output" || o["call_id"] != "c1" {
    +		t.Fatalf("its call's output went as %v", in[2])
    +	}
    +	m, _ := in[3].(map[string]any)
     	got, _ := json.Marshal(m)
     	if want := `{"content":[{"text":"From /root/research to /root\n\n","type":"input_text"},{"text":"Message Type: FINAL_ANSWER\nPayload:\nfound the cause","type":"input_text"}],"role":"user","type":"message"}`; string(got) != want {
     		t.Fatalf("the agent_message went as\n%s\nwant\n%s", got, want)
     	}
    -	if o, _ := in[3].(map[string]any); o["type"] != "function_call_output" || o["call_id"] != "c1" {
    -		t.Fatalf("its call's output went as %v", in[3])
    -	}
     }
     
     // A Codex subagent's plain agent_message reaches a Grok subscription as the
    diff --git a/internal/gateway/join_tool_runs_test.go b/internal/gateway/join_tool_runs_test.go
    new file mode 100644
    index 00000000..66d49900
    --- /dev/null
    +++ b/internal/gateway/join_tool_runs_test.go
    @@ -0,0 +1,162 @@
    +package gateway
    +
    +import (
    +	"bytes"
    +	"encoding/json"
    +	"testing"
    +
    +	"github.com/yetone/magpie/internal/provider"
    +)
    +
    +// notice is the item Codex writes between one batch's tool results when the
    +// image a tool returned was resized for the model.
    +const resizeNotice = `<image_resize_notice>
    +Image 1 of 1 in the preceding tool output was resized from 1024x9000 to 233x2048 pixels.
    +</image_resize_notice>`
    +
    +// orderOf is a Responses request's input items as "type:call_id" strings.
    +func orderOf(t *testing.T, body []byte) []string {
    +	t.Helper()
    +	var order []string
    +	for _, it := range inputOf(t, body) {
    +		typ, _ := it["type"].(string)
    +		id, _ := it["call_id"].(string)
    +		order = append(order, typ+":"+id)
    +	}
    +	return order
    +}
    +
    +// A batch of two calls whose results have a message between them: the
    +// message goes after both results, where the run a strict upstream checks
    +// is whole. Fixtures are the reporter's own bytes — two parallel calls
    +// (view_image and exec_command), an output holding an image, Codex's resize
    +// notice, and the second output.
    +func TestJoinToolRunsMovesAnItemOutOfARun(t *testing.T) {
    +	body := []byte(`{"model":"m","input":[` +
    +		`{"type":"message","role":"user","content":[{"type":"input_text","text":"look"}]},` +
    +		`{"type":"function_call","call_id":"call_00_hC0DonBjeKHawzABcyLL1642","name":"view_image","arguments":"{\"path\":\"wide.png\"}"},` +
    +		`{"type":"function_call","call_id":"call_01_mNySew0rARWuQYQXPeIY2319","name":"exec_command","arguments":"{\"cmd\":\"python -c ...\"}"},` +
    +		`{"type":"function_call_output","call_id":"call_00_hC0DonBjeKHawzABcyLL1642","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},` +
    +		`{"type":"message","role":"developer","content":[{"type":"input_text","text":` + jsonStr(resizeNotice) + `}]},` +
    +		`{"type":"function_call_output","call_id":"call_01_mNySew0rARWuQYQXPeIY2319","output":"PIL 11.3.0\r\n"}]}`)
    +	original := bytes.Clone(body)
    +	got := joinToolRuns(body)
    +	if !bytes.Equal(body, original) {
    +		t.Fatal("mutated the caller's request buffer")
    +	}
    +	want := []string{
    +		"message:",
    +		"function_call:call_00_hC0DonBjeKHawzABcyLL1642",
    +		"function_call:call_01_mNySew0rARWuQYQXPeIY2319",
    +		"function_call_output:call_00_hC0DonBjeKHawzABcyLL1642",
    +		"function_call_output:call_01_mNySew0rARWuQYQXPeIY2319",
    +		"message:",
    +	}
    +	order := orderOf(t, got)
    +	if len(order) != len(want) {
    +		t.Fatalf("items %v, want %v", order, want)
    +	}
    +	for i := range want {
    +		if order[i] != want[i] {
    +			t.Fatalf("items %v, want %v", order, want)
    +		}
    +	}
    +	// the notice keeps its text, and the results keep theirs
    +	if !bytes.Contains(got, []byte("image_resize_notice")) ||
    +		!bytes.Contains(got, []byte("PIL 11.3.0")) ||
    +		!bytes.Contains(got, []byte("data:image/png;base64,AQID")) {
    +		t.Fatalf("lost an item's content: %s", got)
    +	}
    +	// the same request again is left as it is
    +	if again := joinToolRuns(got); !bytes.Equal(again, got) {
    +		t.Fatalf("not idempotent:\n%s\n%s", got, again)
    +	}
    +}
    +
    +// Only an item that breaks a run moves: one between two whole exchanges, a
    +// request with no tool items, and anything that isn't a Responses body go
    +// byte for byte.
    +func TestJoinToolRunsLeavesWholeExchangesAlone(t *testing.T) {
    +	for _, keep := range []string{
    +		// each exchange is whole: the call before the notice is answered,
    +		// and so is the one after it
    +		`{"model":"m","input":[` +
    +			`{"type":"function_call","call_id":"call_a","name":"echo","arguments":"{}"},` +
    +			`{"type":"function_call_output","call_id":"call_a","output":"a"},` +
    +			`{"type":"message","role":"developer","content":[{"type":"input_text","text":"a note"}]},` +
    +			`{"type":"function_call","call_id":"call_b","name":"echo","arguments":"{}"},` +
    +			`{"type":"function_call_output","call_id":"call_b","output":"b"}]}`,
    +		// a call nothing answers is not a run to keep together
    +		`{"model":"m","input":[` +
    +			`{"type":"function_call","call_id":"call_a","name":"echo","arguments":"{}"},` +
    +			`{"type":"message","role":"user","content":[{"type":"input_text","text":"never mind"}]}]}`,
    +		// parallel results already contiguous, and out of the calls' order
    +		`{"model":"m","input":[` +
    +			`{"type":"function_call","call_id":"call_a","name":"echo","arguments":"{}"},` +
    +			`{"type":"function_call","call_id":"call_b","name":"echo","arguments":"{}"},` +
    +			`{"type":"function_call_output","call_id":"call_b","output":"b"},` +
    +			`{"type":"function_call_output","call_id":"call_a","output":"a"}]}`,
    +		`{"model":"m","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`,
    +		`not json`, `{}`, `{"input":"hello"}`,
    +	} {
    +		if got := joinToolRuns([]byte(keep)); string(got) != keep {
    +			t.Fatalf("rewritten:\n%s\nto\n%s", keep, got)
    +		}
    +	}
    +}
    +
    +// The relay keeps the run whole for the upstream: a request whose batch has
    +// an item between its results comes out with the results together, and an
    +// upstream that checks the exchange answers it.
    +func TestResponsesRelayKeepsAToolRunTogether(t *testing.T) {
    +	f := &fake{t: t, reply: sse(`data: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`)}
    +	f.refuse = func(body []byte) (int, string) {
    +		// The upstream's own check, on the chat form it builds: every result
    +		// of a run must follow the calls without an item between them.
    +		open := 0
    +		var sent struct {
    +			Input []struct {
    +				Type   string `json:"type"`
    +				CallID string `json:"call_id"`
    +			} `json:"input"`
    +		}
    +		if err := json.Unmarshal(body, &sent); err != nil {
    +			t.Fatal(err)
    +		}
    +		for _, it := range sent.Input {
    +			switch it.Type {
    +			case "function_call":
    +				open++
    +			case "function_call_output":
    +				open--
    +			default:
    +				if open > 0 {
    +					return 400, `{"error":{"message":"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'."}}`
    +				}
    +			}
    +		}
    +		return 0, ""
    +	}
    +	setup(t, provider.Responses, f)
    +	body := `{"model":"fake/m1","stream":true,"input":[` +
    +		`{"type":"message","role":"user","content":[{"type":"input_text","text":"look"}]},` +
    +		`{"type":"function_call","call_id":"call_a","name":"view_image","arguments":"{}"},` +
    +		`{"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"},` +
    +		`{"type":"function_call_output","call_id":"call_a","output":"looked"},` +
    +		`{"type":"message","role":"developer","content":[{"type":"input_text","text":` + jsonStr(resizeNotice) + `}]},` +
    +		`{"type":"function_call_output","call_id":"call_b","output":"ran"}]}`
    +	if code, out := post(t, "/v1/responses", body); code != 200 {
    +		t.Fatalf("status %d: %s", code, out)
    +	}
    +	want := []string{"message:", "function_call:call_a", "function_call:call_b",
    +		"function_call_output:call_a", "function_call_output:call_b", "message:"}
    +	order := orderOf(t, f.got)
    +	if len(order) != len(want) {
    +		t.Fatalf("sent %v, want %v", order, want)
    +	}
    +	for i := range want {
    +		if order[i] != want[i] {
    +			t.Fatalf("sent %v, want %v", order, want)
    +		}
    +	}
    +}
    diff --git a/internal/gateway/responses.go b/internal/gateway/responses.go
    index 45395720..82abeb2d 100644
    --- a/internal/gateway/responses.go
    +++ b/internal/gateway/responses.go
    @@ -481,6 +481,100 @@ func parseResponses(body []byte) (*Request, error) {
     	return r, nil
     }
     
    +// toolCallItem is a Responses input item that makes a tool call, and
    +// toolResultItem one that answers a call.
    +func toolCallItem(t string) bool {
    +	switch t {
    +	case "function_call", "custom_tool_call", "tool_search_call":
    +		return true
    +	}
    +	return false
    +}
    +
    +func toolResultItem(t string) bool {
    +	switch t {
    +	case "function_call_output", "custom_tool_call_output", "tool_search_output":
    +		return true
    +	}
    +	return false
    +}
    +
    +// joinToolRuns moves an item that sits inside a run of tool calls and
    +// results — between a call and the result that answers it — to the end of
    +// that run. An upstream that checks the exchange, on the chat form it turns
    +// a relayed request into or on the Responses input itself, reads the run as
    +// ended there and refuses it: Volcengine's coding plan answers 400 "An
    +// assistant message with 'tool_calls' must be followed by tool messages
    +// responding to each 'tool_call_id'", the same refusal pairToolItems mends
    +// a missing result for (#1341), and OpenCode Zen/Go answers "No tool output
    +// found for tool call <id>". An agent writes an item between one batch's
    +// results itself: Codex writes its <image_resize_notice> there when a tool
    +// returned an image it resized. An item between two exchanges that are each
    +// whole is left where it is, and a request with nothing inside a run comes
    +// back byte for byte.
    +func joinToolRuns(body []byte) []byte {
    +	if !bytes.Contains(body, []byte(`_call"`)) {
    +		return body
    +	}
    +	var q map[string]json.RawMessage
    +	if decodeJSON(body, &q) != nil {
    +		return body
    +	}
    +	var input []json.RawMessage
    +	if decodeJSON(q["input"], &input) != nil {
    +		return body
    +	}
    +	type item struct {
    +		Type   string `json:"type"`
    +		CallID string `json:"call_id"`
    +	}
    +	items := make([]item, len(input))
    +	answered := map[string]bool{} // call IDs a result in the request answers
    +	for i, raw := range input {
    +		if decodeJSON(raw, &items[i]) != nil {
    +			items[i] = item{}
    +		}
    +		if toolResultItem(items[i].Type) && items[i].CallID != "" {
    +			answered[items[i].CallID] = true
    +		}
    +	}
    +	out := make([]json.RawMessage, 0, len(input))
    +	var held []json.RawMessage
    +	moved := false
    +	// open: calls of the run in progress whose result is still ahead. A run
    +	// is open while one of its calls has been seen and its result has not.
    +	open := map[string]bool{}
    +	for i, raw := range input {
    +		t, id := items[i].Type, items[i].CallID
    +		switch {
    +		case toolCallItem(t):
    +			if id != "" && answered[id] {
    +				open[id] = true
    +			}
    +		case toolResultItem(t) && id != "":
    +			delete(open, id)
    +		case len(open) > 0:
    +			held, moved = append(held, raw), true
    +			continue
    +		}
    +		out = append(out, raw)
    +		if len(open) == 0 && len(held) > 0 {
    +			out = append(out, held...)
    +			held = nil
    +		}
    +	}
    +	out = append(out, held...)
    +	if !moved {
    +		return body
    +	}
    +	q["input"], _ = marshalPlain(out)
    +	encoded, err := marshalPlain(q)
    +	if err != nil {
    +		return body
    +	}
    +	return encoded
    +}
    +
     // orphanedToolOutputs turns a tool result with no call ID into a user message.
     // Codex uses standalone outputs for cross-thread notifications. Backends
     // requiring paired outputs still need the delivered text, without a fake call ID.
    @@ -602,12 +696,7 @@ func pairToolItems(body []byte) []byte {
     		}
     	}
     	exchange := func(t string) bool {
    -		switch t {
    -		case "function_call", "custom_tool_call", "tool_search_call",
    -			"function_call_output", "custom_tool_call_output", "tool_search_output":
    -			return true
    -		}
    -		return false
    +		return toolCallItem(t) || toolResultItem(t)
     	}
     	out := make([]json.RawMessage, 0, len(input)+1)
     	var synthetic []json.RawMessage // results owed by the run in progress
    -- 
    2.51.0.windows.1
  2. yetone commented on Oct 12, 2026

    @yetone
    Owner

    谢谢 @Edward4226,补丁已合入 main,作者保留为你:

    • 5b7112f — 你的 03626b2e 原样 cherry-pick(joinToolRuns,含 Grok 那条断言的调整)。
    • da085e3 — 我在 review 后补的收紧(Co-authored-by 你),外加测试:
      1. 带 call_id 的项一律不动,不管 type。原版只认 function_call / custom_tool_call / tool_search_call,夹在 run 里的 local_shell_call 之类会被挪到结果后面(连同它自己的 output 一起错位)。
      2. 第一个结果出现之前,模型自己写在两次调用之间的东西(reasoning、assistant 文字、托管工具调用)留在原地。 原版会把 [call_a, reasoning, call_b, out_a, out_b] 里的 reasoning 挪到结果之后,变成下一步的开头——对 DeepSeek(每步必须以 reasoning 开头,f4cc7459 的 reasonFirst)会造出一个不对的步。其他项(子代理的 agent_message、notice 等)照旧挪到 run 之后,所以 Grok 那条断言维持你的改法。
      3. 只有"后面有结果回答它"的调用才开启 run。 原版里如果某个结果写在它的调用之前,run 会一直开着,之后所有消息都被挪到请求末尾。

    核对过的地方

    • Codex rust-v0.162.1:core/src/image_preparation.rs 的 prepare_response_items 在每个缩放过图片的 function_call_output / custom_tool_call_output 之后紧接着 push developer <image_resize_notice>,所以一批两个图片结果之间必然夹着它,和 codex报错OpenCode Go: The reasoning_text in the thinking mode must be passed back to the API #1822 rollout 第 347–349 行一致。
    • 各条路由:joinToolRuns 在 attempt 里、在所有目的地分叉之前,Responses 直转、插件(走 passthrough)、翻译到 Chat / Anthropic / Gemini 都会经过。翻译路径本来对 developer / user 项是安全的(developer 被提到 system,user 轮合并且结果排前),但 assistant 消息夹在两个结果之间时,Chat 翻译的 pairToolMessages 会给第二个调用补一个 "unavailable" 的假结果、真结果变成 user 文本——这个现在也一起修了(TestTranslatedToolRunKeepsItsRealResults,去掉调用点即红)。
    • 新测试 TestRelayedCodexImageBatchKeepsItsResultsTogether 用的是 codex报错OpenCode Go: The reasoning_text in the thinking mode must be passed back to the API #1822 里的真实 call_id 和形状(两个并行 view_image、图片结果、notice 夹中间),fake 按 DeepSeek 的规则回 "No tool output found for tool call …";去掉调用点即 400。

    实测(DeepSeek 官方 api.deepseek.com/v1/responses,deepseek-v4-flash)

    夹在两个结果之间 挪到结果之后
    任意 developer 文本,直连 400 No tool output found for tool call call_01_x2 200
    经 sandbox magpie(流式 / 非流式) origin/main 构建 400,同一报错 本次构建 200
    <image_resize_notice> 原文,直连(文本结果) 200 200

    和你在 OpenCode Zen/Go 上看到的一样:DeepSeek 认得 notice 这一条,所以纯文本结果下复现不出 #1822 那次 400;报告者的是图片结果加很长的历史,我这边没能复现那一条。OpenCode Go 我这个账号的工作区没开 Global 区域,所有请求在到模型之前就被拒,没测成。Volcengine、真实 Grok 也没测。

    还没发版(按规矩等 CI 绿了再一起打 tag)。issue 先开着,等 #1822 的报告者升级后确认。docs/subsystems/gateway-routing.md 也补了一条,不用你再写了。

  3. yetone commented on Oct 12, 2026

    @yetone
    Owner

    @Edward4226 v0.1.1217 is out (check for updates in magpie) and includes your patch 5b7112f (authored by you) and da085e3 (co-authored): an item interrupting a batch's tool results goes after them, so strict Responses upstreams stop refusing the relayed request. Please try it and let us know here: we'll close this once it works for you, or keep going if it doesn't.

  4. Edward4226 commented on Oct 13, 2026

    @Edward4226
    ContributorAuthor

    已在 v0.1.1222(Windows,2026-10-13)上对真实 OpenCode Go 上游复测通过。

    复测形状就是这里的原形:一批两个 function_call、两条结果之间夹一条非工具项,POST /v1/responses,model: opencode-go-2/deepseek-v4.1-flash,stream: false:

    两条结果之间夹的项 合入前(10-12 的 A/B,旧构建) v0.1.1222(今天)
    任意文本(a plain note) 400 No tool output found for tool call call_01_b 200(40d25529-dc0f-4f2e-9d38-466f54027399)
    Codex 的 <image_resize_notice> 200(上游特例放行) 200(e383fbaf-5691-4330-ad60-615eb876cd70)

    (复现时 max_output_tokens: 16,响应是被截到 reasoning-only 的 incomplete;这里核对的是 400 那一类,请求体与 10-12 的 A/B 逐字节相同。)

    #1822 里报告者补的第二个报错(resize notice 夹在两条 view_image 结果之间)是同形状,那边的最小复现体在 v0.1.1222 下也全部 200,已在 #1822 一并说明。

    谢谢 cherry-pick(5b7112fe2)和 review 后的收紧(da085e3f7,带 call_id 的项不动、首个结果前模型自写的文字不动)。这条确认完毕,关闭。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions