Skip to content

fix(protocol): convert the responses tool items instead of dropping them - #208

Merged
argszero merged 1 commit into
mainfrom
fix/responses-input-tool-items
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/responses-input-tool-items

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The gateway advertises three-protocol interop (src/protocol.rs: OpenAI Chat / Responses / Anthropic 双向互转), but the requests it hands upstream had never been measured. openai_responses_to_openai_chat_req passed every input item to convert_responses_message, which only understands role + content.

A Responses client's tool round trip is two items that carry no role:

{"type":"function_call","call_id":"ZZCALLMARK_R7","name":"ZZFNMARK_R7","arguments":"{\"city\":\"ZZARGMARK_R7\"}"}
{"type":"function_call_output","call_id":"ZZCALLMARK_R7","output":"ZZOUTMARK_R7"}

Both fell through to the default branch (unwrap_or("user") + missing content), so what the upstream actually received was measured as:

messages = [ {user,"ZZTEXTMARK_R7"}, {user,null}, {user,null} ]
markers in messages: call_id=false name=false arguments=false output=false

The tool call and its result were silently destroyed - the upstream saw an empty user turn, the request succeeded, tokens were billed and the answer was semantically empty (silent data loss, worse than an error).

Reachability: 11 of the 12 plans in config/config.toml (measured with the real determine_forwarding_protocol): a responses inbound is forwarded as anthropic on the 7 openai_chat+anthropic plans and as openai_chat on the 4 openai_chat-only plans; only deepseek-paygo passes through. A Responses client's tool round trip is exactly these two item types, so any agent client hits this.

Why this is an omission, not a chosen subset: the same round trip in the other directions is converted completely - anthropic -> openai_chat produced assistant.tool_calls[0].{id,name,arguments} + {role:"tool",tool_call_id,content} and openai_chat -> anthropic produced tool_use + tool_result (all markers true). There was also zero coverage: responses_req_to_openai_chat only tested plain-text input items.

Related Issue

Changes

  • 功能/修复说明 — openai_responses_to_openai_chat_req 现在按 type 分派 input 条目;工具条目由新的 convert_responses_tool_item 处理,转成代码库已有的 chat 形状(assistant.tool_calls + role:"tool",与 convert_message_to_openai 的 tool_use/tool_result 分支同构 —— 形状单源,不另立一套)
  • 功能/修复说明 — {"type":"function_call","call_id","name","arguments"} → {"role":"assistant","content":null,"tool_calls":[{"id":call_id,"type":"function","function":{"name","arguments"}}]}(arguments 非字符串时 to_string())
  • 功能/修复说明 — {"type":"function_call_output","call_id","output"} → {"role":"tool","tool_call_id":call_id,"content":<string 原样 / 非 string 转 JSON 字符串>}
  • 功能/修复说明 — 连续的 function_call 合并进同一条 assistant 消息:Responses 的并行工具调用就是连续多个 item,而 chat 的形状是「一条消息一个 tool_calls 数组」(否则并行调用会被拆成多条 assistant 消息)
  • 功能/修复说明 — 其余条目(role + content)走原路径,纯文本输入行为不变
  • 涉及配置/数据结构的改动已同步示例文件 — 不涉及(无 config / schema / ui/ / i18n 改动)

Tests

  • cargo test 全部通过 — 202 passed(原 199,+3)
  • cargo fmt --check 通过
  • 新增/更新了单元测试(如适用)
    • responses_req_tool_items_to_openai_chat — 一次完整工具往返:逐项断言 call_id/name/arguments/output 存活,并断言缺陷形状消失(不再出现 content: null 的 user 消息)
    • responses_req_parallel_function_calls_share_one_assistant_message — 两个连续 function_call ⇒ 一条 assistant 消息两个 tool_calls,两条 tool 结果按 id 对齐
    • responses_req_tool_items_through_the_anthropic_chain — 链式 responses → chat → anthropic(7 个 plan 的实际路径)得到 tool_use + tool_result
    • 既有 responses_req_to_openai_chat(纯文本)保持绿 —— 证明原路径未变

A/B(红前绿后;每条变异自带推导出来的期望红集,还原放 finally 并刷新 mtime——坑 177/178)

变异 期望红集 实测
取消分派(所有条目都走 convert_responses_message,即修复前的行为) 三条新测试 199 passed / 3 red(= 期望)
不合并(每个 function_call 各起一条 assistant 消息) 仅并行调用那条 201 passed / 1 red(= 期望)

第二条变异只让「并行」那条红 —— 合并规则与分派规则各自被断言,而不是被同一条断言笼统覆盖。

修复前的实测输出(把分派还原后跑该测试):

thread 'protocol::tests::responses_req_tool_items_to_openai_chat' panicked at src/protocol.rs:1468:9:
  left: String("user")
 right: "assistant"

Checklist

  • 分支命名符合约定(fix/responses-input-tool-items)
  • Commit message 使用 Conventional Commits 格式(fix(protocol): ...)
  • 单一职责,改动最小化 — 只动 src/protocol.rs 的 responses 入站请求翻译;⛔ tool_choice(同一轴的下一个面,单独处理)、⛔ chat -> responses 丢 tool_calls(可达性未证)、⛔ 响应翻译器与 status/截断语义均未触碰

`openai_responses_to_openai_chat_req` handed every `input` item to
`convert_responses_message`, which only understands `role` + `content`. A
Responses client's tool round trip is two items that carry no `role` -
`{"type":"function_call","call_id","name","arguments"}` and
`{"type":"function_call_output","call_id","output"}` - so both fell back to
`{"role":"user","content":null}`: the tool call and its result disappeared, the
upstream saw an empty user turn, the request succeeded, tokens were billed and
the answer was semantically empty.

- dispatch the `input` items by `type`; the tool items are translated into the
  chat shapes the codebase already uses for the same round trip
  (`assistant.tool_calls` + `role:"tool"`, mirroring `convert_message_to_openai`)
- consecutive `function_call` items (responses parallel tool calls) share one
  assistant message, because that is the shape chat gives a `tool_calls` array
- every other item keeps the previous path, so plain-text input is unchanged

202 passed (was 199), fmt/clippy clean.
@argszero
argszero merged commit 03a9aca into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/responses-input-tool-items branch September 13, 2026 12:32
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.

1 participant