Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-null-content-tool-pairing.md
Original file line number Diff line number Diff line change
@@ -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、工具结果与其他内容形状均原样保留。
24 changes: 23 additions & 1 deletion internal/providers/qoder/direct.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions internal/providers/qoder/null_content_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading