Skip to content

fix(qoder): pair tool calls whose assistant turn has null content - #297

Merged
caigee-cmd merged 1 commit into
caigee-cmd:mainfrom
metal-huang:pr/qoder-null-content-tool-pairing
Oct 15, 2026
Merged

caigee-cmd merged 1 commit into
caigee-cmd:mainfrom
metal-huang:pr/qoder-null-content-tool-pairing

Conversation

@metal-huang

Copy link
Copy Markdown
Contributor

中文

改动概要

  • 修复 Qoder 模型的多轮工具调用从第二轮起全部失败:content: null 的 assistant 轮次被网关拒绝 #296。
  • Qoder 网关只在前一条 assistant 消息的 content 为字符串时,才把它携带的 tool_calls 登记为可配对。OpenAI 客户端对纯工具调用轮次发送 "content": null,导致每次重放工具历史都被拒:400 InvalidParameter: messages with role "tool" must be a response to a preceeding message with "tool_calls"(实测矩阵见 issue)。
  • BuildPlainChatBody 现在会把带 tool_calls 的消息上的 nil content 改写为 ""。调用 id、工具结果和其他一切内容形状原样透传;不带 tool_calls 的普通 assistant 消息保持原样。

测试计划

  • go test ./internal/providers/qoder/ -run NullContent — 新增 3 个用例:null+tool_calls 改写为 ""、无 tool_calls 的 null 保持不动、非空 content 保持不动
  • go vet 对涉及包无告警
  • Added changelog/unreleased/qoder-null-content-tool-pairing.md
  • No tokens, auth blobs, raw captures, or host details in the diff

English

Summary

  • Fixes Qoder 模型的多轮工具调用从第二轮起全部失败:content: null 的 assistant 轮次被网关拒绝 #296.
  • The Qoder gateway only registers a tool-call pairing when the assistant turn's content is a string. OpenAI clients send "content": null for a pure tool-call turn, so every replayed tool history is rejected with 400 InvalidParameter: messages with role "tool" must be a response to a preceeding message with "tool_calls" (measured matrix in the linked issue).
  • BuildPlainChatBody now rewrites a nil content to "" on messages that carry tool_calls. Call ids, tool results, and every other content shape pass through untouched; a plain assistant message with null content keeps its shape.

Test plan

  • go test ./internal/providers/qoder/ -run NullContent — 3 new cases: null+tool_calls becomes "", null without tool_calls is kept, non-empty content is kept
  • go vet clean on the touched packages
  • Added changelog/unreleased/qoder-null-content-tool-pairing.md
  • No tokens, auth blobs, raw captures, or host details in the diff

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.

@caigee-cmd caigee-cmd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. 审查结论:

正确性 — 上游网关只在 assistant 消息 content 为字符串时登记 tool_calls 配对;OpenAI 客户端对纯工具调用轮次发送的正是 "content": null,导致配对未登记、后续 role:"tool" 消息被拒。把该轮次的 nil content 改写为 "" 是对症的修复。

范围 — normalizeMessageContent 只在 len(message.ToolCalls) > 0 && message.Content == nil 时改写,其他所有内容形状(非空字符串、空字符串、无 tool_calls 的 null)原样透传。我确认 BuildPlainChatBody 是 qoder 包唯一的 chat 请求构造路径,不存在第二条需要同步修改的分支。

验证 — 本地跑过:go build ./...、go test ./...(全量通过)、go vet ./...、release-notes.py validate(exit 0)、3 个新测试全通过。gofmt 仅 internal/store/migrations.go 一处,为 main 既有基线,与本 PR 无关。

合并。

@caigee-cmd
caigee-cmd merged commit d866f89 into caigee-cmd:main Oct 15, 2026
11 of 12 checks passed
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.

Qoder 模型的多轮工具调用从第二轮起全部失败:content: null 的 assistant 轮次被网关拒绝

2 participants