feat(protocols): implement P7 ToolChoice variant coverage - #1276
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughIntroduced a new Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
8009967 to
3fce285
Compare
3fce285 to
faee4fb
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/protocols/src/responses.rs (1)
1077-1141:⚠️ Potential issue | 🟠 MajorExpand existence checks beyond function refs.
Line 1077 onward only cross-checks function selections. A request can still force a non-function tool that is missing from
request.tools—for example viaToolChoice::Mcp, a hostedToolChoice::Types, or non-function entries insideAllowedTools—and this validator will accept it. That turns simple typos/mismatches into late routing failures instead of a request-time validation error.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/protocols/src/responses.rs` around lines 1077 - 1141, The validator currently only checks function references (uses function_tool_names and ToolReference::Function) so non-function tool refs (e.g., ToolChoice::Mcp, ToolChoice::Types, hosted tools, or non-function entries inside AllowedTools) can slip through; update the validation to also verify existence for every tool reference type: in the AllowedTools loop check all ToolReference variants (not just Function) against the canonical tool name set (the same set used for function_tool_names or a unified tools_name_set), and add existence checks for ToolChoice::Mcp, ToolChoice::Types, ToolChoice::Custom, ToolChoice::ApplyPatch, and ToolChoice::Shell paths where they include tool identifiers, returning a ValidationError (same style as existing errors like tool_choice_tool_not_found/tool_choice_invalid_mode) when a referenced tool name/id is not present. Ensure you reference the enums/variants ToolChoice, ToolReference, function_tool_names (or a unified tools_name_set), allowed_tools, and ValidationError when making the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/protocols/src/chat.rs`:
- Around line 523-531: The validation wrongly treats all non-`none` ToolChoice
variants as requiring a `tools` array; update the check in the function that
calls tool_choice_requires_tools so it does not mark ToolChoice::Types,
ToolChoice::Mcp, ToolChoice::Custom, ToolChoice::ApplyPatch, or
ToolChoice::Shell as requiring tools. Concretely, adjust the predicate used by
tool_choice_requires_tools (or the call site before the validation) to return
false for those five variants so they pass through to the upstream provider when
no request-scoped `tools` are present, leaving other variants (including
function-invoking ones) to still require the `tools` array.
In `@model_gateway/src/routers/grpc/regular/streaming.rs`:
- Around line 1183-1198: The code currently calls
choice.function_name().unwrap_or_default() which turns a missing function name
into an empty string and allows generate_tool_call_id and add_choice_tool_name
to proceed with an invalid seed; instead, detect when function_name() is None
and fail closed: do not default to "", return/propagate an error or skip
emitting this tool-choice chunk. Update the block handling ToolChoice::Function
(the choice variable, fn_name extraction, utils::generate_tool_call_id and
ChatCompletionStreamResponse::add_choice_tool_name calls) to explicitly handle
None (e.g., early-return Err or skip with a clear log) rather than using
unwrap_or_default().
In `@model_gateway/tests/spec/chat_completion.rs`:
- Around line 176-177: Add a test case that covers the flat ToolChoice::Function
shape (top-level "name" field, not nested "function.name") so the
parser/validator exercise the flat Responses-style path; locate the pattern
match handling Some(choice @ ToolChoice::Function { .. }) in
model_gateway/tests/spec/chat_completion.rs (around the existing assertions at
the current match arms) and add an input example where the choice payload uses
the flat "name":"my_function" form, then assert choice.function_name() ==
Some("my_function") (do the same for the similar block around lines 283-313).
---
Outside diff comments:
In `@crates/protocols/src/responses.rs`:
- Around line 1077-1141: The validator currently only checks function references
(uses function_tool_names and ToolReference::Function) so non-function tool refs
(e.g., ToolChoice::Mcp, ToolChoice::Types, hosted tools, or non-function entries
inside AllowedTools) can slip through; update the validation to also verify
existence for every tool reference type: in the AllowedTools loop check all
ToolReference variants (not just Function) against the canonical tool name set
(the same set used for function_tool_names or a unified tools_name_set), and add
existence checks for ToolChoice::Mcp, ToolChoice::Types, ToolChoice::Custom,
ToolChoice::ApplyPatch, and ToolChoice::Shell paths where they include tool
identifiers, returning a ValidationError (same style as existing errors like
tool_choice_tool_not_found/tool_choice_invalid_mode) when a referenced tool
name/id is not present. Ensure you reference the enums/variants ToolChoice,
ToolReference, function_tool_names (or a unified tools_name_set), allowed_tools,
and ValidationError when making the changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 11d263e4-777a-4c6d-9376-85afb45386b0
📒 Files selected for processing (8)
crates/protocols/src/chat.rscrates/protocols/src/common.rscrates/protocols/src/responses.rsmodel_gateway/src/routers/grpc/harmony/stages/preparation.rsmodel_gateway/src/routers/grpc/regular/streaming.rsmodel_gateway/src/routers/grpc/utils/chat_utils.rsmodel_gateway/src/routers/grpc/utils/message_utils.rsmodel_gateway/tests/spec/chat_completion.rs
faee4fb to
6d4d513
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (2)
model_gateway/tests/spec/chat_completion.rs (1)
176-177: 🧹 Nitpick | 🔵 TrivialConsider adding a test for the flat
ToolChoice::Functionwire shape.The current tests only exercise the nested helper path (
ToolChoice::function_nested). Since the validator now accepts both"name"(flat Responses-style) and"function.name"(nested), a regression in the flat form would not be caught by this suite.Adding a test that deserializes
{"type": "function", "name": "my_function"}and assertschoice.function_name() == Some("my_function")would provide coverage for both wire shapes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@model_gateway/tests/spec/chat_completion.rs` around lines 176 - 177, Add a new unit test that deserializes the flat wire shape for ToolChoice::Function (e.g. JSON {"type":"function","name":"my_function"}) and asserts that choice.function_name() == Some("my_function"); place it alongside the existing test that exercises ToolChoice::function_nested so both the nested ("function.name") and flat ("name") forms are covered, using the same deserialization helper and match arm that checks Some(choice @ ToolChoice::Function { .. }) as in the current test.model_gateway/src/routers/grpc/regular/streaming.rs (1)
1183-1197:⚠️ Potential issue | 🟡 MinorDon't default missing function names to an empty string.
Line 1184 uses
choice.function_name().unwrap_or_default(), which silently converts a malformedToolChoice::Function(missing bothnameandfunction.name) into an empty string. This allows the code path to proceed with an invalid tool name and generate a tool-call ID from an empty seed, rather than failing closed.Consider returning early or logging a warning when
function_name()returnsNone:Suggested fix
if let Some(choice @ ToolChoice::Function { .. }) = tool_choice { - let fn_name = choice.function_name().unwrap_or_default(); + let Some(fn_name) = choice.function_name() else { + debug_assert!(false, "ToolChoice::Function missing name"); + return chunks; + }; let is_first_call = !has_tool_calls.contains_key(&index); if is_first_call { // First chunk: send name and id has_tool_calls.insert(index, true); let tool_call_id = - utils::generate_tool_call_id(model, fn_name, 0, history_tool_calls_count); + utils::generate_tool_call_id(model, &fn_name, 0, history_tool_calls_count); chunks.push( ChatCompletionStreamResponse::builder(request_id, model) .created(created) - .add_choice_tool_name(index, tool_call_id, fn_name.to_string()) + .add_choice_tool_name(index, tool_call_id, fn_name.to_string()) .maybe_system_fingerprint(system_fingerprint) .build(), );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@model_gateway/src/routers/grpc/regular/streaming.rs` around lines 1183 - 1197, Do not silently default a missing function name to "" in the ToolChoice::Function branch: replace the unwrap_or_default() call on choice.function_name() with an explicit check for None and bail out (or log and skip) so you never call utils::generate_tool_call_id or ChatCompletionStreamResponse::add_choice_tool_name with an empty name; in the block handling is_first_call (where has_tool_calls, index, tool_call_id, ChatCompletionStreamResponse::builder and add_choice_tool_name are used) return early or emit an error chunk when function_name() is None to ensure invalid/malformed ToolChoice::Function values fail closed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/protocols/src/responses.rs`:
- Around line 1229-1235: The new match arms in ToolChoice (ToolChoice::Types,
ToolChoice::Mcp, ToolChoice::Custom, ToolChoice::ApplyPatch, ToolChoice::Shell)
skip validation and allow tool_choice values that aren't present in the provided
tools list; update the validation logic where tool_choice is matched to check
that the selected variant actually corresponds to an entry in tools (e.g., for
Types ensure the referenced type exists in tools, for Mcp match the server_label
against an MCP tool in tools, for Custom/ApplyPatch/Shell ensure a tool with
that identifier/type is present) and return an appropriate validation error when
no matching tool is found instead of falling through to the empty arm.
In `@model_gateway/src/routers/grpc/harmony/stages/preparation.rs`:
- Around line 336-343: The match arm that currently returns Ok(None) for
ToolChoice::Types, ToolChoice::Mcp, ToolChoice::Custom, ToolChoice::ApplyPatch
and ToolChoice::Shell must not silently drop a forced non-function tool
selection; instead detect these variants in the Harmony preparation path and
return a validation/error (not Ok(None)) so the gRPC router fails fast rather
than converting a forced tool choice into unconstrained generation. Locate the
match over ToolChoice in preparation.rs (the arm with ToolChoice::Types |
ToolChoice::Mcp | ToolChoice::Custom | ToolChoice::ApplyPatch |
ToolChoice::Shell) and replace the Ok(None) behavior with an explicit Err
variant (or a Result::Err with a clear validation error) that includes context
about the unsupported/non-function tool_choice in this path.
In `@model_gateway/src/routers/grpc/utils/chat_utils.rs`:
- Around line 431-440: The code currently uses unwrap_or_default() on
choice.function_name() in parse_json_schema_response which can create empty
function names and invalid IDs (e.g., functions.:0); change the logic in the
Some(choice @ ToolChoice::Function { .. }) branch to explicitly handle a missing
function name by failing closed: if choice.function_name() is None, return an
Err or skip creating the ToolCall rather than using an empty string. Update the
block that builds ToolCall/FunctionCallResponse (references: fn_name,
generate_tool_call_id, FunctionCallResponse) to only proceed when a non-empty
function name is present and propagate an appropriate error or skip result when
it is missing.
---
Duplicate comments:
In `@model_gateway/src/routers/grpc/regular/streaming.rs`:
- Around line 1183-1197: Do not silently default a missing function name to ""
in the ToolChoice::Function branch: replace the unwrap_or_default() call on
choice.function_name() with an explicit check for None and bail out (or log and
skip) so you never call utils::generate_tool_call_id or
ChatCompletionStreamResponse::add_choice_tool_name with an empty name; in the
block handling is_first_call (where has_tool_calls, index, tool_call_id,
ChatCompletionStreamResponse::builder and add_choice_tool_name are used) return
early or emit an error chunk when function_name() is None to ensure
invalid/malformed ToolChoice::Function values fail closed.
In `@model_gateway/tests/spec/chat_completion.rs`:
- Around line 176-177: Add a new unit test that deserializes the flat wire shape
for ToolChoice::Function (e.g. JSON {"type":"function","name":"my_function"})
and asserts that choice.function_name() == Some("my_function"); place it
alongside the existing test that exercises ToolChoice::function_nested so both
the nested ("function.name") and flat ("name") forms are covered, using the same
deserialization helper and match arm that checks Some(choice @
ToolChoice::Function { .. }) as in the current test.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0b5fcad4-df51-4b32-a935-afbd92cf4f2b
📒 Files selected for processing (8)
crates/protocols/src/chat.rscrates/protocols/src/common.rscrates/protocols/src/responses.rsmodel_gateway/src/routers/grpc/harmony/stages/preparation.rsmodel_gateway/src/routers/grpc/regular/streaming.rsmodel_gateway/src/routers/grpc/utils/chat_utils.rsmodel_gateway/src/routers/grpc/utils/message_utils.rsmodel_gateway/tests/spec/chat_completion.rs
6d4d513 to
89acffd
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (2)
model_gateway/src/routers/grpc/regular/streaming.rs (1)
1183-1198:⚠️ Potential issue | 🟡 MinorFail closed when
ToolChoice::Functionhas no name.Line 1184 turns a malformed function choice into
"", which then flows intogenerate_tool_call_id()andadd_choice_tool_name(...)as an invalid tool name.Suggested direction
- if let Some(choice @ ToolChoice::Function { .. }) = tool_choice { - let fn_name = choice.function_name().unwrap_or_default(); + if let Some(choice @ ToolChoice::Function { .. }) = tool_choice { + let Some(fn_name) = choice.function_name() else { + debug_assert!(false, "ToolChoice::Function missing name"); + return chunks; + };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@model_gateway/src/routers/grpc/regular/streaming.rs` around lines 1183 - 1198, The code currently converts a missing function name into an empty string via choice.function_name().unwrap_or_default(), which then gets passed into utils::generate_tool_call_id and add_choice_tool_name; instead, treat a missing/empty function name as an error/invalid tool choice and bail out early: replace the unwrap_or_default usage with a check that calls choice.function_name() and if None or empty then skip creating the tool-call chunk (or return an Err) so you never call utils::generate_tool_call_id or ChatCompletionStreamResponse::add_choice_tool_name with an invalid name; update the logic around ToolChoice::Function, has_tool_calls.insert(index, true), generate_tool_call_id, and add_choice_tool_name to only run when a valid non-empty fn_name is present.model_gateway/src/routers/grpc/harmony/stages/preparation.rs (1)
336-343:⚠️ Potential issue | 🟠 MajorReject unsupported forced non-function
tool_choicevariants here.Lines 339-343 currently accept a forced
Types/Mcp/Custom/ApplyPatch/Shellrequest but emit no Harmony constraint, so the request silently degrades into unconstrained generation.Suggested direction
- ToolChoice::Types { .. } - | ToolChoice::Mcp { .. } - | ToolChoice::Custom { .. } - | ToolChoice::ApplyPatch { .. } - | ToolChoice::Shell { .. } => Ok(None), + ToolChoice::Types { .. } + | ToolChoice::Mcp { .. } + | ToolChoice::Custom { .. } + | ToolChoice::ApplyPatch { .. } + | ToolChoice::Shell { .. } => Err(Box::new(error::bad_request( + "unsupported_tool_choice", + "Harmony currently supports only function/required/allowed_tools tool_choice variants", + ))),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@model_gateway/src/routers/grpc/harmony/stages/preparation.rs` around lines 336 - 343, The match arm currently treating forced non-function tool choices (ToolChoice::Types, ToolChoice::Mcp, ToolChoice::Custom, ToolChoice::ApplyPatch, ToolChoice::Shell) as Ok(None) must instead reject them with an error; update the match block so these variants return an Err containing a descriptive error (e.g., invalid_argument/UnsupportedToolChoice or the module's existing Preparation/validation error type) explaining that forced non-function tool_choice variants are not supported, and ensure the error uses the same error construction pattern used elsewhere in this file/function so callers get a proper failure instead of silent unconstrained generation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@model_gateway/src/routers/grpc/harmony/stages/preparation.rs`:
- Around line 336-343: The match arm currently treating forced non-function tool
choices (ToolChoice::Types, ToolChoice::Mcp, ToolChoice::Custom,
ToolChoice::ApplyPatch, ToolChoice::Shell) as Ok(None) must instead reject them
with an error; update the match block so these variants return an Err containing
a descriptive error (e.g., invalid_argument/UnsupportedToolChoice or the
module's existing Preparation/validation error type) explaining that forced
non-function tool_choice variants are not supported, and ensure the error uses
the same error construction pattern used elsewhere in this file/function so
callers get a proper failure instead of silent unconstrained generation.
In `@model_gateway/src/routers/grpc/regular/streaming.rs`:
- Around line 1183-1198: The code currently converts a missing function name
into an empty string via choice.function_name().unwrap_or_default(), which then
gets passed into utils::generate_tool_call_id and add_choice_tool_name; instead,
treat a missing/empty function name as an error/invalid tool choice and bail out
early: replace the unwrap_or_default usage with a check that calls
choice.function_name() and if None or empty then skip creating the tool-call
chunk (or return an Err) so you never call utils::generate_tool_call_id or
ChatCompletionStreamResponse::add_choice_tool_name with an invalid name; update
the logic around ToolChoice::Function, has_tool_calls.insert(index, true),
generate_tool_call_id, and add_choice_tool_name to only run when a valid
non-empty fn_name is present.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 97900599-e13c-46a7-81f6-cae0c466c96d
📒 Files selected for processing (8)
crates/protocols/src/chat.rscrates/protocols/src/common.rscrates/protocols/src/responses.rsmodel_gateway/src/routers/grpc/harmony/stages/preparation.rsmodel_gateway/src/routers/grpc/regular/streaming.rsmodel_gateway/src/routers/grpc/utils/chat_utils.rsmodel_gateway/src/routers/grpc/utils/message_utils.rsmodel_gateway/tests/spec/chat_completion.rs
There was a problem hiding this comment.
Incremental review (synchronize) — reviewed all changes from origin/main...HEAD.
No new issues found beyond what existing review comments already cover. The core changes are well-designed:
- Type-safe tag enums (
FunctionToolChoiceTag,BuiltInToolChoiceType, etc.) correctly prevent#[serde(untagged)]variant collisions — each variant'stypefield is locked to a single-value enum, so JSON payloads can't accidentally match the wrong arm. function_name()accessor cleanly abstracts over both Responses-API flat (name) and Chat-Completions nested (function.name) wire shapes, with correct priority (flat wins).- Round-trip tests in
common.rscover all 8 spec variants plus the collision regression guard — good coverage. - Validation properly rejects
Functionvariants missing bothnameandfunction.namewith a cleartool_choice_function_name_missingerror.
0 🔴 Important · 0 🟡 Nit · 0 🟣 Pre-existing
89acffd to
3d8b4ac
Compare
|
Hi @slin1237, this PR has merge conflicts that must be resolved before it can be merged. Please rebase your branch: git fetch origin main
git rebase origin/main
# resolve any conflicts, then:
git push --force-with-lease |
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
crates/protocols/src/responses.rs (1)
1498-1506:⚠️ Potential issue | 🟠 MajorNon-function
tool_choicevariants still skip existence validation.
Functionand function refs insideAllowedToolsare checked againstrequest.tools, butTypes,Mcp,Custom,ApplyPatch, andShellall bypass validation even when no matching tool/server is present. That lets impossible requests through instead of failing at request validation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/protocols/src/responses.rs` around lines 1498 - 1506, The match arm for ResponsesToolChoice variants (Options, Types, Mcp, Custom, ApplyPatch, Shell) currently does nothing, allowing types/MCP/custom/apply_patch/shell choices to bypass existence checks against request.tools; update the validation logic in the ResponsesToolChoice handling (the match over ResponsesToolChoice) to perform the same existence verification used for Function/FunctionRef within AllowedTools: for Types/Mcp/Custom/ApplyPatch/Shell, ensure the referenced tool name/server exists in request.tools (or the MCP server registry) and return a validation error if not present, so impossible tool choices fail during request validation rather than at routing time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/protocols/src/responses.rs`:
- Around line 248-254: The projection currently collapses explicit
Responses-only selections (Self::Types, Self::Mcp, Self::Custom,
Self::ApplyPatch, Self::Shell) into
ChatToolChoice::Value(ChatToolChoiceValue::Auto), which loses the original hard
selection; change the projection logic in the conversion function in
responses.rs so these variants are preserved (for example by adding a distinct
ChatToolChoice variant or encoding the original variant into the ChatToolChoice
value) or explicitly return an error/reject when the chat/gRPC representation
cannot express the exact selection; update the match arm handling Self::Types |
Self::Mcp | Self::Custom | Self::ApplyPatch | Self::Shell to emit that preserved
representation (or an Err) instead of ChatToolChoiceValue::Auto.
- Around line 206-213: The current serialize_to_string function double-encodes
ResponsesToolChoice by calling serde_json::to_string and returning a JSON
string; change the model and serialization so ResponsesResponse.tool_choice is a
typed optional field (Option<ResponsesToolChoice> or Option<serde_json::Value>)
and stop producing a JSON string. Replace usages of serialize_to_string to pass
the typed Option directly (or convert to serde_json::Value via
serde_json::to_value) so the final serializer emits the union/object shape
instead of a quoted JSON string; remove or repurpose serialize_to_string
accordingly and ensure ResponsesToolChoice derives/implements Serialize so the
response serializes natively.
---
Duplicate comments:
In `@crates/protocols/src/responses.rs`:
- Around line 1498-1506: The match arm for ResponsesToolChoice variants
(Options, Types, Mcp, Custom, ApplyPatch, Shell) currently does nothing,
allowing types/MCP/custom/apply_patch/shell choices to bypass existence checks
against request.tools; update the validation logic in the ResponsesToolChoice
handling (the match over ResponsesToolChoice) to perform the same existence
verification used for Function/FunctionRef within AllowedTools: for
Types/Mcp/Custom/ApplyPatch/Shell, ensure the referenced tool name/server exists
in request.tools (or the MCP server registry) and return a validation error if
not present, so impossible tool choices fail during request validation rather
than at routing time.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2bcf569d-2cac-4d59-9344-ec00224d1712
📒 Files selected for processing (7)
crates/protocols/src/responses.rsmodel_gateway/src/routers/grpc/harmony/responses/common.rsmodel_gateway/src/routers/grpc/harmony/stages/preparation.rsmodel_gateway/src/routers/grpc/regular/responses/conversions.rsmodel_gateway/tests/api/responses_api_test.rsmodel_gateway/tests/spec/chat_completion.rsmodel_gateway/tests/spec/responses.rs
|
Only repository collaborators, contributors, or members can run CodeRabbit commands. |
What changed:
- crates/protocols/src/responses.rs:
* Add `ResponsesToolChoice` — eight-variant enum matching the Responses
spec byte-for-byte: Options (none/auto/required), Types (hosted
built-ins), Function (flat `{type, name}`), AllowedTools, Mcp,
Custom, ApplyPatch, Shell.
* Add seven single-value tag enums (`FunctionToolChoiceTag`,
`AllowedToolsToolChoiceTag`, `McpToolChoiceTag`,
`CustomToolChoiceTag`, `ApplyPatchToolChoiceTag`,
`ShellToolChoiceTag`, and `BuiltInToolChoiceType`) so
`#[serde(untagged)]` cannot collide across variants — the
discriminator on each object variant is pinned by a tag that
only accepts its own literal.
* Add `ToolChoiceOptions` (snake_case none/auto/required).
* Point `ResponsesRequest.tool_choice` at
`Option<ResponsesToolChoice>` (was `Option<ToolChoice>`).
* Rewrite `normalize()` and `validate_tool_choice_with_tools()`
against the new variants; the five Responses-only variants
(Types, Mcp, Custom, ApplyPatch, Shell) trivially pass
existence validation — hosted/mcp/custom tools resolve at
routing time.
* Add `ResponsesToolChoice::to_chat_tool_choice()` — projection
onto `common::ToolChoice` for the Responses→Chat gRPC bridge:
Options map 1:1, Function re-wraps `name` into Chat's nested
`{function: {name}}` shape, AllowedTools preserves mode+tools,
Responses-only variants collapse onto `auto`.
* Add 10 round-trip tests: one per wire-shape variant (including
a negative test rejecting Chat-style nested function shape on
the Responses type), plus projection and Default coverage.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs:
* ResponsesRequest→ChatCompletionRequest now calls
`to_chat_tool_choice()` to do the shape translation explicitly.
- model_gateway/src/routers/grpc/harmony/stages/preparation.rs:
* Project ResponsesRequest.tool_choice to the Chat shape once
before passing to `filter_tools_by_tool_choice` and
`generate_tool_call_constraint` — both helpers keep their
chat-typed signatures.
- model_gateway/src/routers/grpc/harmony/responses/common.rs:
* Migrate the iteration-switch assignment
(`request.tool_choice = Some(... Auto)`) to
`ResponsesToolChoice::Options(ToolChoiceOptions::Auto)`.
- model_gateway/tests/spec/chat_completion.rs:
* Keep the pre-existing chat-path AllowedTools test-fixture fix:
`tool_type: "function".to_string()` is wrong for the
AllowedTools variant — corrected to `"allowed_tools"` at all
six construction sites. This is a standalone bug fix worth
keeping independently of the split.
- model_gateway/tests/spec/responses.rs,
model_gateway/tests/api/responses_api_test.rs:
* Migrate every `ToolChoice::Value(ToolChoiceValue::…)` /
`ToolChoice::default()` construction on a ResponsesRequest
to `ResponsesToolChoice::Options(…)` /
`ResponsesToolChoice::default()`.
Why:
The shared `ToolChoice` enum silently accepted Responses-only
tool_choice payloads (hosted built-ins, mcp, custom, apply_patch,
shell) on `/v1/chat/completions` — spec-invalid on that endpoint.
Keeping a single enum also forced five chat-path files to carry
catch-all match arms for variants Chat's spec does not recognise,
and collapsed the two different Function wire shapes (Chat:
nested `function.name`; Responses: flat `name`) into one
ambiguous representation.
Splitting restores strict Chat validation — unknown variants
fail at deserialise time with a 400 — eliminates the catch-all
arms from chat-path code, and lets each API surface its spec-
exact wire shape. The 0-line diff across the five chat-path
files that were previously touched (streaming.rs, chat_utils.rs,
message_utils.rs, preparation.rs catch-all, chat.rs catch-all)
is the load-bearing evidence that chat validation is no longer
polluted by Responses-only concerns.
How:
The Responses type lives in `responses.rs` rather than `common.rs`
precisely because it is Responses-only — chat code cannot import
it and therefore cannot silently accept its variants. Within the
new enum, `#[serde(untagged)]` is safe only because every object
variant pins its `"type"` discriminator through a single-value
tag enum; without that, `Types { tool_type: BuiltInToolChoiceType }`
would compete with `Mcp { server_label, name }` on payloads that
happen to contain a `name` field. The Responses→Chat projection
(`to_chat_tool_choice`) is the single bridge point where the shape
translation happens; two call sites (conversions.rs and
harmony/stages/preparation.rs) use it. The `Function` projection
is the only non-trivial case — flat `name` on the Responses side
rewraps into Chat's nested `function: {name}` form.
Refs: P7
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
3d8b4ac to
b1b1a1f
Compare
…or backward compat (P7)
What:
`ResponsesToolChoice::Function` now deserializes both the spec-flat
`{"type": "function", "name": "..."}` wire shape and the legacy Chat-style
nested `{"type": "function", "function": {"name": "..."}}` shape. Both
are normalized at deserialize time; `Serialize` always emits the canonical
flat shape per the OpenAI Responses spec. A new `function_name()` accessor
on the enum returns the pinned name for internal consumers that previously
pattern-matched on `Function { name, .. }`.
Why:
smg historically accepted the Chat-style nested shape on the Responses
endpoint because Chat and Responses shared a single `ToolChoice` type
before P7. Existing smg clients — including `e2e_test/responses/test_tools_call.py`
— send `tool_choice={"type": "function", "function": {"name": "..."}}`
on Responses requests and must keep working. The frozen Responses spec
(`.claude/_audit/openai-responses-api-spec.md`) and the OpenAI Python SDK
v1.76.2 canonical type `openai/types/responses/tool_choice_function.py`
both define the shape as flat, so the gateway stays Postel-conformant:
liberal on input (accept both wire shapes), conservative on output
(emit canonical flat only).
How (Option A — factored payload with custom Deserialize):
Extracted the `Function` variant body into a new `ResponsesFunctionToolChoice`
struct. The struct derives `Serialize` (emitting the canonical flat
shape) but hand-rolls `Deserialize` via a private `Helper` with optional
`name` and `function: FunctionChoice` fields; the helper routes whichever
field is present into the canonical `name: String`, failing if neither
carries a function name. The outer untagged enum keeps its existing
`FunctionToolChoiceTag` discriminator pinning, so only payloads with
`"type": "function"` can reach this variant. Chosen over an enum-level
custom `Serialize` (Option B) because factoring keeps the `untagged`
auto-routing intact, localizes the two-shape logic to a single helper,
and guarantees canonical serialize without branching on option fields
every time. Call sites are updated: `to_chat_tool_choice` now projects
through the struct, `validate_tool_choice_with_tools` uses the new
`function_name()` accessor, and the existing test that constructs a
`Function` variant binds through the struct.
Tests:
- Kept `responses_tool_choice_function_round_trip` (flat shape still
round-trips cleanly, exercises `function_name()` accessor).
- Deleted the negative assertion that required the nested shape to be
rejected — this was the assertion that regressed the e2e tests.
- Added `responses_tool_choice_function_accepts_legacy_nested`: reads
the nested shape, verifies internal state is the normalized flat
`name`, and verifies serialize emits canonical flat (explicitly
asserts absence of a nested `function` object on the wire).
- Added `responses_tool_choice_function_rejects_missing_name`: payload
with neither `name` nor `function.name` must still be rejected.
Evidence:
- OpenAI Python SDK v1.76.2
`openai/types/responses/tool_choice_function.py`:
class ToolChoiceFunction(BaseModel):
name: str
type: Literal["function"]
— generated from the Responses OpenAPI spec, canonical shape is flat.
- `.claude/_audit/openai-responses-api-spec.md:421`: `ToolChoiceFunction
{ name, type: "function" }` — flat.
- Failing e2e in `e2e_test/responses/test_tools_call.py` uses the nested
shape; the owner authorized keeping the pre-P7 backward-compat behavior
on deserialize.
Verification:
- cargo check --workspace --tests --benches: pass
- cargo test -p openai-protocol: 80 + 8 + 18 + 1 pass, 0 fail
(12 tool_choice round-trip tests including the 2 new backward-compat
cases)
- cargo test -p smg --lib: 562 pass
- cargo test -p smg --test spec_test: 93 pass
- cargo clippy -p openai-protocol -p smg --lib --bins --tests -- -D warnings: clean
- cargo +nightly fmt --all -- --check: clean
- pre-commit run codespell --all-files: pass
- No source code outside `crates/protocols/src/responses.rs` was modified.
Refs: P7
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
| // Accessor goes through `function_name()` so we stay agnostic to | ||
| // the underlying wire shape (flat vs. legacy nested) — both are | ||
| // normalized at deserialize time. | ||
| if let Some(name) = tool_choice.function_name() { |
There was a problem hiding this comment.
🟡 Nit: function_name() on a Function variant always returns Some (the custom Deserialize impl guarantees name: String is populated). The if let Some guard introduces a dead else branch that would silently skip the "function not found in tools" check if function_name() ever returned None.
The previous code destructured name directly from the variant and had no way to skip the check. Consider matching the payload directly to preserve that unconditional guarantee:
| if let Some(name) = tool_choice.function_name() { | |
| if let Some(name) = tool_choice.function_name() { |
→
let name = tool_choice
.function_name()
.expect("Function variant always carries a name");Not a bug today, but the old code was structurally unable to skip validation whereas this version relies on a runtime invariant.
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Schema-only rewrite of the T8 branch. This is a clean re-application
against current origin/main (which advanced through T3/T4/P5/P6) and
drops the defensive router guardrails that the earlier round-2/round-3
review iterations had layered on top. Those guardrails turned schema
tasks into behavior elaboration and violated scope discipline.
What changed (protocols only, plus forced-cascade match arms):
- crates/protocols/src/responses.rs
- Add ResponseTool::Custom(CustomTool) variant with #[serde(rename = "custom")].
- Add CustomTool struct (name, description?, defer_loading?, format?).
- Add CustomToolInputFormat enum (Text | Grammar) tagged by "type".
- Add CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax
enum (Lark | Regex).
- Add CustomToolInputContentPart (input-only content part: input_text,
input_image, input_file) — restricts the custom_tool_call_output array
form to spec-legal variants, rejecting output_text/refusal at the type
boundary.
- Add CustomToolCallOutputContent (untagged Text(String) | Parts(Vec<...>)).
- Add ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?,
namespace? } with #[serde(rename = "custom_tool_call")].
- Add ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? }
with #[serde(rename = "custom_tool_call_output")]; no status field per spec.
- Extend validate_input_item + extract_text_for_routing with match arms
for the two new input items.
- Six serde round-trip tests covering Text format, both grammar syntaxes,
custom_tool_call, custom_tool_call_output (string), custom_tool_call_output
(array), and a negative test for output_text/refusal rejection.
Forced-cascade match arms (compiler-required, one line or Ok-shaped each):
- model_gateway/src/routers/grpc/harmony/builder.rs
- tool_types match: ResponseTool::Custom(_) => "custom" (matches &str arms).
- parse_response_item_to_harmony_message match: CustomToolCall/Output arms
return Err("Unsupported input item type") + warn!(), matching the
existing McpApprovalRequest/ImageGenerationCall arm shape.
- model_gateway/src/routers/grpc/regular/responses/conversions.rs
- responses_to_chat item match: CustomToolCall/Output arms return
Err("Unsupported input item type") + warn!(), matching the existing
McpApprovalResponse/ImageGenerationCall arm shape.
- model_gateway/src/routers/openai/responses/utils.rs
- response_tool_to_value: ResponseTool::Custom(_) => serde_json::to_value(tool).ok()
matching the existing FileSearch/ImageGeneration arm shape.
- model_gateway/benches/routing_allocation_bench.rs
- extract_text_for_routing_old filter_map: CustomToolCall/Output => None
matching the existing McpApprovalRequest/ImageGenerationCall arms.
Explicitly NOT included (dropped as scope bleed during rewrite):
- Defensive pre-check blocks rejecting ResponseTool::Custom in
preparation.rs and conversions.rs (not compiler-required).
- Defensive rejection of ResponsesToolChoice::Custom (not compiler-required;
P7 landed that variant in #1276 without router changes).
- Refactor of the tool_types match from infallible .map(...) into
fallible .map(|t| match ...).collect::<Result<_, _>>() (elaboration).
- Router-layer regression tests for the dropped rejection blocks.
Why:
Closes the T8 gap in the Responses API audit. Spec
(openai-responses-api-spec.md L471-474 for the tool and L268-273 for the
input items) defines the `custom` tool + `custom_tool_call` /
`custom_tool_call_output` as first-class wire shapes.
How:
Variant placement follows the established single-rename pattern used by
FileSearch (T1) / ImageGeneration (T4) / WebSearch (T3). Grammar
sub-union is internally tagged by type. CustomToolCallOutput.output is
untagged because the spec accepts either a raw string or an array of
input content parts. Every non-exhaustive match on
ResponseInputOutputItem or ResponseTool was audited and extended (four
call sites + the bench harness) — no silent default arms added, no
behavior-level guards added.
Drift: audit "Desired" listed CustomToolCallOutput with an optional
`status` field; spec has no such field. Implementation follows the spec.
Refs: audit task T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Summary
Implements audit task P7: widen
ToolChoicefrom 3 variants to the 8 required by the OpenAI Responses API spec (Options,Types,Function,AllowedTools,Mcp,Custom,ApplyPatch,Shell). Adds single-value enum tag types to prevent cross-variant collision under#[serde(untagged)], and widens theFunctionvariant to carry both flat (name) and nested (function) wire shapes for bidirectional spec compat.What changed
Commits on branch (
main..HEAD):a1a84aa3—feat(protocols): implement P7 ToolChoice variant coverage(substance)80099675—style(protocols): rustfmt remediation for P7 (cycle 2)(fmt-only follow-up)Diff totals: 8 files, +398 / -56.
crates/protocols/src/common.rs(+319 / most lines): expandedToolChoicefrom 3 → 8 spec variants; added 6 single-value enum tag types (FunctionToolChoiceTag,AllowedToolsToolChoiceTag,BuiltInToolChoiceType,McpToolChoiceTag,CustomToolChoiceTag,ApplyPatchToolChoiceTag,ShellToolChoiceTag) to prevent untagged-match collisions; addedToolChoice::function_nested()constructor andToolChoice::function_name()accessor; 11 serde round-trip tests covering every new variant + the cross-variant collision regression case (tool_choice_mcp_does_not_collide_with_function).crates/protocols/src/chat.rs(+ / -): widened Function-variant validation to accept either wire shape via the new accessor; dropped now-unusedFunctionChoiceimport.crates/protocols/src/responses.rs(+ / -): catch-all arm for new variants at the two exhaustivematch tool_choicesites.model_gateway/src/routers/grpc/harmony/stages/preparation.rs(+13 / -):Ok(None)arm for the new variants (they don't drive harmony structural-tag generation).model_gateway/src/routers/grpc/regular/streaming.rs(+ / -): adapted twoToolChoice::Function { function, .. }destructuring sites tofunction_name()accessor.model_gateway/src/routers/grpc/utils/chat_utils.rs(+ / -): same accessor adaptation.model_gateway/src/routers/grpc/utils/message_utils.rs(+ / -): replaced one constructor withToolChoice::function_nested(name).model_gateway/tests/spec/chat_completion.rs(+ / -): corrected six tests that constructedAllowedTools { tool_type: "function" }(a pre-existing typo hidden by the old permissiveStringfield; the new enum-tagged type surfaced it) →AllowedToolsToolChoiceTag::AllowedTools; two Function constructors migrated to the new helper.Why
OpenAI Responses API spec (see
.claude/_audit/openai-responses-api-spec.md:409-425) defines 8ToolChoicevariants with strict per-variant field sets. smg previously modeled only 3 (Value,Function,AllowedTools); spec-valid clients sending{"type": "file_search"}as tool_choice (spec-validToolChoiceTypes) silently failed today, same forToolChoiceMcp,ToolChoiceCustom,ToolChoiceApplyPatch,ToolChoiceShell. This PR closes the gap.Verification
cargo +nightly fmt --all -- --checksilent (isolatedCARGO_TARGET_DIR=/tmp/p7-c2-lead-targetto avoid worktree cache collision)cargo clippy -p openai-protocol -- -D warningscleancargo test -p openai-protocol→ 93/0 (66 + 8 + 18 + 1 doc, all cycle-1 plus fmt delta)cargo test -p smg --test spec_test→ 93/0, 1 ignoredcargo test -p openai-protocol tool_choice→ 22/22 (incl. regression anchorstool_choice_mcp_does_not_collide_with_function,tool_choice_function_nested_roundtrip)function_nestedhelper has 2+ prod callsites;function_namehas 6+ prod callsites → §7 "no new helper used by one callsite" passeschat_completion.rs) verified compile-forced (not unrelated cleanup) — the oldStringfield hid a typo that the new typed enum surfaces at compile timeunavailable(harness skill permission; Tech Lead proceeded solo per playbook §8 fallback after cycle-1 spec fixtures + cycle-2 fmt delta both clean)Blast radius
8 files, classified:
common.rs,chat.rs,responses.rs.AllowedTools { tool_type: "function" }— a permissive-Stringbug that the old schema hid; the new typed enum tag (AllowedToolsToolChoiceTag::AllowedTools) would fail to compile with the old literal string. Documenting as forced, not cleanup.Out of scope
invalid_tool_choicefor unknowntypestrings — deferred to P5 (silent-swallow removal task) per audit guidance;#[serde(untagged)]kept here.Refs: audit task P7 ·
.claude/_audit/responses-api-gap-audit.mdSummary by CodeRabbit
Release Notes
New Features
Improvements