From 8aa3077b43b6b82a81ca7da97f34315b29279bf1 Mon Sep 17 00:00:00 2001 From: METAL-H Date: Wed, 14 Oct 2026 19:18:37 +0800 Subject: [PATCH] fix(qoder): pair tool calls whose assistant turn has null content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Qoder gateway pairs each role:"tool" message with the tool_calls of the assistant turn ahead of it, and it only registers that pairing when the assistant message's `content` is a string. OpenAI clients send `"content": null` for a pure tool-call turn, which is what Claude Code and most SDKs emit when the model answers with tools and no prose. The gateway then has no registered call for the follow-up tool message and rejects the whole request: <400> InternalError.Algo.InvalidParameter: messages with role "tool" must be a response to a preceeding message with "tool_calls" An empty string is accepted and pairs correctly, so rewrite a nil content on a tool-call turn to "". Measured against the live gateway, holding everything else fixed: content=null id=call_1 -> 400 InvalidParameter content=null id=toolu_01 -> 400 InvalidParameter content="" id=call_1 -> 200 content="" id=toolu_01 -> 200 content="x" id=call_1 -> 200 content="x" id=toolu_01 -> 200 The call id is irrelevant; only the content shape decides it. Only messages that carry tool_calls are rewritten — a plain assistant message with null content keeps its original shape, and non-empty content passes through untouched. --- .../qoder-null-content-tool-pairing.md | 7 ++ internal/providers/qoder/direct.go | 24 +++- internal/providers/qoder/null_content_test.go | 115 ++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/qoder-null-content-tool-pairing.md create mode 100644 internal/providers/qoder/null_content_test.go diff --git a/changelog/unreleased/qoder-null-content-tool-pairing.md b/changelog/unreleased/qoder-null-content-tool-pairing.md new file mode 100644 index 00000000..d8049fbb --- /dev/null +++ b/changelog/unreleased/qoder-null-content-tool-pairing.md @@ -0,0 +1,7 @@ +### English + +- Fix Qoder chat rejecting multi-turn tool use with `400 InvalidParameter: messages with role "tool" must be a response to a preceeding message with "tool_calls"`. The upstream gateway only pairs a `role:"tool"` message with the assistant turn ahead of it when that assistant message's `content` is a string; OpenAI clients send `"content": null` for a pure tool-call turn, which left the call unregistered. The in-process client now rewrites a nil content on a tool-call turn to an empty string, which the gateway accepts. Call ids, tool results, and all other content shapes pass through untouched. + +### 中文 + +- 修复 Qoder 多轮工具调用被上游以 `400 InvalidParameter: messages with role "tool" must be a response to a preceeding message with "tool_calls"` 拒绝的问题。上游网关只在前一条 assistant 消息的 `content` 为字符串时,才把它携带的 `tool_calls` 登记为可配对;OpenAI 客户端对纯工具调用轮次发送的正是 `"content": null`,导致调用未登记、后续 `role:"tool"` 消息被拒。进程内直连客户端现在会把工具调用轮次的 null content 改写为空字符串(网关接受该形状);工具调用 id、工具结果与其他内容形状均原样保留。 diff --git a/internal/providers/qoder/direct.go b/internal/providers/qoder/direct.go index 2bd98c6b..481ab8bf 100644 --- a/internal/providers/qoder/direct.go +++ b/internal/providers/qoder/direct.go @@ -262,7 +262,7 @@ func BuildPlainChatBody(req translate.ChatRequest, now time.Time) map[string]any } continue } - item := map[string]any{"role": message.Role, "content": message.Content} + item := map[string]any{"role": message.Role, "content": normalizeMessageContent(message)} if message.Name != "" { item["name"] = message.Name } @@ -353,6 +353,28 @@ func BuildPlainChatBody(req translate.ChatRequest, now time.Time) map[string]any } } +// normalizeMessageContent replaces a nil content on a message that carries +// tool_calls with an empty string. +// +// The gateway pairs each role:"tool" message with the tool_calls of the +// assistant turn ahead of it, and it only registers that pairing when the +// assistant message's `content` is a string. A JSON null — what OpenAI clients +// send for an assistant turn that is pure tool calls — leaves the call +// unregistered, and the following role:"tool" message is then rejected with +// +// messages with role "tool" must be a response to a preceeding message +// with "tool_calls" +// +// An empty string is accepted and pairs correctly. Only messages with +// tool_calls are rewritten; every other content shape passes through +// untouched. +func normalizeMessageContent(message translate.ChatMessage) any { + if len(message.ToolCalls) > 0 && message.Content == nil { + return "" + } + return message.Content +} + func rawNumber(raw json.RawMessage) (int, bool) { if len(raw) == 0 { return 0, false diff --git a/internal/providers/qoder/null_content_test.go b/internal/providers/qoder/null_content_test.go new file mode 100644 index 00000000..833df900 --- /dev/null +++ b/internal/providers/qoder/null_content_test.go @@ -0,0 +1,115 @@ +package qoder + +import ( + "encoding/json" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/translate" +) + +// The gateway pairs a role:"tool" message with the tool_calls of the assistant +// turn ahead of it, and only registers that pairing when the assistant +// message's content is a string. OpenAI clients send `"content": null` for a +// pure tool-call turn, which leaves the call unregistered and the gateway +// rejects the follow-up tool message: +// +// messages with role "tool" must be a response to a preceeding message +// with "tool_calls" +// +// An empty string pairs correctly, so a nil content on a tool-call turn is +// rewritten to "". +func TestBuildPlainChatBodyNullContentWithToolCallsBecomesEmptyString(t *testing.T) { + raw := `{ + "model":"qwen3.8-flash","stream":false,"max_tokens":40, + "tools":[{"type":"function","function":{"name":"bash","description":"run","parameters":{"type":"object"}}}], + "messages":[ + {"role":"user","content":"run ls"}, + {"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"bash","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"file1.txt"}, + {"role":"user","content":"say OK"} + ] + }` + var req translate.ChatRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatal(err) + } + plain := BuildPlainChatBody(req, time.Now()) + encoded, _ := json.Marshal(plain["messages"]) + t.Logf("messages = %s", encoded) + + var msgs []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCalls json.RawMessage `json:"tool_calls"` + } + if err := json.Unmarshal(encoded, &msgs); err != nil { + t.Fatal(err) + } + if len(msgs) != 4 { + t.Fatalf("message count = %d, want 4", len(msgs)) + } + if string(msgs[1].Content) != `""` { + t.Fatalf("assistant content = %s, want an empty string", msgs[1].Content) + } + // The tool call and its id must survive the rewrite untouched. + if len(msgs[1].ToolCalls) == 0 { + t.Fatal("assistant tool_calls dropped") + } +} + +// A plain assistant message with null content and no tool calls keeps its +// original shape: the pairing rule only applies to tool-call turns. +func TestBuildPlainChatBodyKeepsNullContentWithoutToolCalls(t *testing.T) { + raw := `{ + "model":"qwen3.8-flash","max_tokens":40, + "messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":null} + ] + }` + var req translate.ChatRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatal(err) + } + plain := BuildPlainChatBody(req, time.Now()) + encoded, _ := json.Marshal(plain["messages"]) + + var msgs []struct { + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(encoded, &msgs); err != nil { + t.Fatal(err) + } + if string(msgs[1].Content) != "null" { + t.Fatalf("content = %s, want null (no tool_calls to pair)", msgs[1].Content) + } +} + +// Non-empty content and empty-string content both pass through unchanged. +func TestBuildPlainChatBodyKeepsExistingContent(t *testing.T) { + for _, want := range []string{`""`, `"planning the call"`} { + raw := `{ + "model":"qwen3.8-flash","max_tokens":40, + "messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":` + want + `,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"bash","arguments":"{}"}}]} + ] + }` + var req translate.ChatRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatal(err) + } + plain := BuildPlainChatBody(req, time.Now()) + encoded, _ := json.Marshal(plain["messages"]) + var msgs []struct { + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(encoded, &msgs); err != nil { + t.Fatal(err) + } + if string(msgs[1].Content) != want { + t.Fatalf("content = %s, want %s", msgs[1].Content, want) + } + } +}