Skip to content

fix(protocol): carry tool_choice across every cross-protocol request translation - #209

Merged
argszero merged 1 commit into
mainfrom
fix/request-tool-choice-cross-protocol
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/request-tool-choice-cross-protocol

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

tool_choice was carried across protocols by exactly one direction. map_tool_choice()
was called only from anthropic_to_openai_chat_req, so every other cross-protocol request
translation silently dropped the field: responses -> chat, responses -> anthropic,
chat -> anthropic, chat -> responses and anthropic -> responses all lost it. Measured
through the real translators: a request that asks for tool_choice: "required", or names a
function, arrives upstream with no tool preference at all — so a model may answer in prose and
an agent client waits forever for a tool call it never gets.

This is not a lossy mapping by intent: map_tool_choice exists for this field, which is
proof the author wants it translated; the other directions simply have no counterpart. The
omission was invisible because grep -rn tool_choice src/ returns six lines and none of them
is a test
.

Reachability (real determine_forwarding_protocol, all 12 shipped [[plans]]): every plan has
an openai_chat endpoint, so a chat inbound is always a passthrough — but a responses
inbound is converted on 11 of 12 plans (7 through the responses -> chat -> anthropic
chain, 4 through responses -> chat).

Related Issue

No issue exists for this; the finding came from a routine sweep of the request-side
translation. Nothing is fabricated or linked.

Changes

  • src/protocol.rs only: the shape knowledge for tool_choice now has a single home.
    • Added a protocol-agnostic intermediate representation ToolChoice
      (None / Auto / Required / Function(name)) with parse_tool_choice(from, raw) and
      render_tool_choice(to, &tc), plus copy_tool_choice(body, from, to, result) — the shared
      tail every request translator now calls.
    • Wired the four cross directions: chat -> anthropic, chat -> responses,
      responses -> chat, and anthropic -> chat (which previously used map_tool_choice;
      map_tool_choice is now deleted — it had no other caller).
    • The two chained directions (responses -> anthropic = responses -> chat ∘
      chat -> anthropic; anthropic -> responses = anthropic -> chat ∘ chat -> responses)
      are fixed by the same rings.
    • Shapes: chat "none"|"auto"|"required" / {"type":"function","function":{"name":X}};
      anthropic {"type":"none"|"auto"|"any"} / {"type":"tool","name":X};
      responses "none"|"auto"|"required" / {"type":"function","name":X}.
    • A shape the source protocol cannot mean (e.g. {"type":"allowed_tools"}, an unknown
      scalar, a named tool with no name) is left absent instead of being copied verbatim —
      the old _ => clone() arm could forward a value the target protocol does not accept, and
      omitting the key keeps "absent" distinguishable from an explicit null.
  • 涉及配置/数据结构的改动已同步示例文件 — n/a (no config, no schema, no ui/, no i18n keys)

Tests

  • cargo test 全部通过 — 205 passed (was 202)
  • cargo fmt --check 通过
  • 新增/更新了单元测试

Added three tests:

  • tool_choice_survives_every_cross_protocol_direction — per direction, three scalars plus one
    named tool, each expected value written out literally (not derived from the implementation),
    so a missing ring cannot be masked by a sibling.
  • tool_choice_survives_the_two_chained_directions — the two chains asserted at the composition
    entry point (transform_request), because a chain's benefit is a composition and is not
    visible from the individual functions.
  • tool_choice_is_not_invented — negative control: no tool_choice in the source ⇒ no key in
    the target; unrecognised shape ⇒ absent, never fabricated.

Teeth proof (each mutation applied alone, its own derived expected red set, restore in
finally with an mtime refresh): baseline passed=205 red=[]; removing any one of the four
call sites ⇒ passed=203 with exactly the two tests above red, and the failure output is the
defect (assertion left == right failed: responses->chat 源 "auto"; left: Null, right: String("auto")). An unrecognised shape is asserted by construction, not by omission.

Checklist

  • 分支命名符合约定 (fix/request-tool-choice-cross-protocol)
  • Commit message 使用 Conventional Commits 格式 (fix(protocol): ...)
  • 单一职责,改动最小化 (one field's mapping in one file; no unrelated refactor)

…translation

map_tool_choice() was called from anthropic_to_openai_chat_req only, so every other
cross-protocol request translation dropped the field: responses->chat,
responses->anthropic, chat->anthropic, chat->responses and anthropic->responses all
lost a client's tool_choice (measured through the real translators). A request that
requires a tool call, or names a function, therefore reached the upstream with no tool
preference at all - a model may answer in prose and an agent client waits forever for a
tool call it never receives. It is not a deliberate lossy mapping: map_tool_choice
exists precisely for this field, and the omission stayed invisible because none of the
six tool_choice references in the tree is a test.

The shape knowledge now has a single home: a protocol-agnostic ToolChoice IR with
parse_tool_choice(from, raw) / render_tool_choice(to, &tc), plus copy_tool_choice() as
the shared tail of every request translator. All four cross directions call it
(chat->anthropic, chat->responses, responses->chat, anthropic->chat; map_tool_choice is
deleted - it had no other caller), and the two chained directions
(responses->anthropic = responses->chat . chat->anthropic; anthropic->responses =
anthropic->chat . chat->responses) are fixed by the same rings. A shape the source
protocol cannot mean is left absent rather than copied verbatim, so a value the target
protocol does not accept is never invented and "absent" stays distinguishable from an
explicit null.

+3 tests (202 -> 205 passed): a per-direction matrix with each expected value written out
literally, the two chained directions asserted at the composition entry (transform_request),
and a negative control for the absent/unrecognised shapes. fmt + clippy clean.
@argszero
argszero merged commit bbc74ea into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/request-tool-choice-cross-protocol branch September 13, 2026 13:01
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