Skip to content

feat(protocols): implement T8 custom tool + call/output items - #1301

Merged
slin1237 merged 4 commits into
mainfrom
feat/audit-t8-custom-tool
Apr 22, 2026
Merged

slin1237 merged 4 commits into
mainfrom
feat/audit-t8-custom-tool

Conversation

@slin1237

@slin1237 slin1237 commented Apr 22, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements audit task T8: custom tool + custom_tool_call / custom_tool_call_output input items.

What changed

  • crates/protocols/src/responses.rs
    • ResponseTool::Custom(CustomTool) variant (#[serde(rename = "custom")]).
    • CustomTool { name, description?, defer_loading?, format? } with deny_unknown_fields.
    • CustomToolInputFormat enum (Text | Grammar) internally tagged by type.
    • CustomToolGrammar { definition, syntax } and CustomToolGrammarSyntax enum (Lark | Regex).
    • ResponseInputOutputItem::CustomToolCall { call_id, input, name, id?, namespace? }.
    • ResponseInputOutputItem::CustomToolCallOutput { call_id, output, id? } — no status field (see drift note).
    • CustomToolCallOutputContent untagged enum — Text(String) | Parts(Vec<ResponseContentPart>).
    • Extended extract_text_for_routing and validate_input_item match arms.
    • Added 5 serde round-trip tests.
  • model_gateway/src/routers/grpc/harmony/builder.rs — added the two new input-item arms in parse_response_item_to_harmony_message and the ResponseTool::Custom arm in the tool-type labeler.
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs — added the two new input-item arms in responses_to_chat.
  • model_gateway/src/routers/openai/responses/utils.rs — added the ResponseTool::Custom arm in response_tool_to_value.
  • model_gateway/benches/routing_allocation_bench.rs — added the two new input-item arms to the exhaustive routing-text extraction.

Why

Spec openai-responses-api-spec.md L471-474 defines the custom tool:

Custom { name, type: "custom", defer_loading?, description?, format? }
format: CustomToolInputFormat: Text { type: "text" } | Grammar { definition, syntax: "lark" | "regex", type: "grammar" }

and L268-273 defines the paired input items:

CustomToolCallOutput object { call_id, output, type, id } — output: string or array of ResponseInputText | ResponseInputImage | ResponseInputFile
CustomToolCall object { call_id, input, name, type, id, namespace }

Without these types smg cannot round-trip user-defined tool flows; previous turns emitting custom_tool_call would be rejected by ResponseInputOutputItem deserialisation. ToolChoice::Custom already landed in #1276 (P7), so no new tool-choice variant is required.

Verification

  • cargo +nightly fmt --all -- --check — clean (silent)
  • cargo clippy -p openai-protocol --all-targets --all-features -- -D warnings — clean
  • cargo clippy -p smg --all-targets --all-features -- -D warnings — clean
  • cargo test -p openai-protocol --lib responses::tests — 35/35 passing, incl. 5 new T8 tests
  • pre-commit run codespell — Passed
  • Tech Lead spec round-trip (6 hand-built fixtures from spec L268-274 + L471-474): 7/7 byte-identical
  • Exhaustive-match arms verified compile-forced: reverting the conversions.rs arm produces error[E0004]: non-exhaustive patterns: CustomToolCall, CustomToolCallOutput not covered
  • Codex independent review: codex: unavailable (§8 solo-fallback — Tech Lead APPROVE after hand round-trip suffices)
  • Grep confirmed no downstream call-site left un-updated: every match on ResponseTool / ResponseInputOutputItem now covers the new variants

Blast radius

5 files changed, +333 / −1:

crates/protocols/src/responses.rs                              | +314 / -1
model_gateway/benches/routing_allocation_bench.rs              | +2
model_gateway/src/routers/grpc/harmony/builder.rs              | +9
model_gateway/src/routers/grpc/regular/responses/conversions.rs| +8
model_gateway/src/routers/openai/responses/utils.rs            | +1

No Cargo.toml / Cargo.lock / bindings/ / .github/ / .cargo/ changes.

Out of scope

  • Python bindings mirror (the new responses types are Rust-only today; bindings expose a coarser surface).
  • namespace tool grouping — tracked as T9 (blocked on T8; this PR unblocks it).
  • Harmony / regular gRPC / Chat Completions runtime handling of custom tool calls (currently returns Unsupported input item type); this PR only lands wire types.
  • Audit "Desired" listed CustomToolCallOutput { ..., status? } but spec L268 has no status field. Implemented per spec; drift logged in .claude/_audit/responses-api-gap-audit.md → Drift Log.
  • #[serde(deny_unknown_fields)] is intentionally absent on the CustomToolCallOutput struct variant to match sibling variants (FunctionCallOutput, etc.); hardening silent-field drops is tracked separately as P5.

Refs: T8

Summary by CodeRabbit

  • New Features
    • Added support for custom tools with configurable input formats (text-based or grammar-based).
    • Introduced custom tool call and response handling to enable tool invocations and output capture.
    • Custom tools support optional descriptions, defer loading, and flexible output formats.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Apr 22, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds support for custom tool definitions and interactions to the responses protocol. New message types (CustomToolCall, CustomToolCallOutput), tool variant (ResponseTool::Custom), and associated structures enable custom tool payloads. Gateway routers are updated to handle these variants: Harmony builder detects them for message construction, OpenAI utils serialize them, and regular/harmony converters reject them with warnings.

Changes

Cohort / File(s) Summary
Protocol layer additions
crates/protocols/src/responses.rs
Added ResponseTool::Custom(CustomTool) variant with supporting types: CustomTool, CustomToolInputFormat (text/grammar), CustomToolGrammar, CustomToolGrammarSyntax, CustomToolInputContentPart, and CustomToolCallOutputContent. Added ResponseInputOutputItem variants CustomToolCall and CustomToolCallOutput. Updated extract_text_for_routing to skip custom tool items and validate_input_item to reject empty inputs/outputs. Includes serde round-trip unit tests.
Router handler updates
model_gateway/src/routers/grpc/harmony/builder.rs, model_gateway/src/routers/grpc/regular/responses/conversions.rs, model_gateway/src/routers/openai/responses/utils.rs
Extended tool-type mapping in Harmony builder to recognize ResponseTool::Custom for message construction; added explicit match arms in Harmony and regular converters to warn and error on custom tool call items; serialized custom tools in OpenAI utils.
Benchmark
model_gateway/benches/routing_allocation_bench.rs
Updated extract_text_for_routing_old to filter and exclude custom tool call variants from routing text contribution.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested labels

tests

Suggested reviewers

  • CatherineSue
  • key4ng

Poem

🐰 Custom tools hop into view,
With grammar rules and formats too!
Validation guards the inputs tight,
New protocol variants take flight—
A fuzzy friend approves this code! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(protocols): implement T8 custom tool + call/output items' accurately and concisely summarizes the main change: adding custom tool support and associated request/response item types to the Responses API protocols.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/audit-t8-custom-tool

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added grpc gRPC client and router changes benchmarks Benchmark changes protocols Protocols crate changes model-gateway Model gateway crate changes openai OpenAI router changes labels Apr 22, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean implementation of T8 custom tools. Types are well-structured, serde attributes match the spec, and all code paths (Harmony, gRPC regular, OpenAI passthrough, routing text extraction, benchmarks) properly handle the new variants. The error handling in Harmony/gRPC conversion is consistent with the existing MCP approval item pattern. Tests are comprehensive with full round-trip coverage for all variants.

0 🔴 Important · 0 🟡 Nit · 0 🟣 Pre-existing

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24da7c16d5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/protocols/src/responses.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
model_gateway/src/routers/grpc/harmony/builder.rs (1)

423-439: ⚠️ Potential issue | 🟠 Major

ResponseTool::Custom is still silently dropped on the Harmony path.

Line 435 makes custom tools count as tool-bearing input, but ToolLike for ResponseTool still only turns ResponseTool::Function into a ToolDescription, so a request with only custom tools builds successfully while sending no tool definition to Harmony. That leaves the first turn unable to produce the expected tool call, and later turns then start failing once custom_tool_call* history hits Lines 726-733. Please reject ResponseTool::Custom here until Harmony can encode it explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/routers/grpc/harmony/builder.rs` around lines 423 - 439,
The code currently treats ResponseTool::Custom as a tool type but ToolLike for
ResponseTool does not produce a ToolDescription for Custom, causing silent
drops; update the builder to detect any ResponseTool::Custom in request.tools
and return an explicit error (reject the request) instead of including "custom"
in tool_types. Locate the mapping that produces tool_types (the closure
iterating over request.tools) and add a branch that returns an Err/early return
when encountering ResponseTool::Custom, and note that ToolLike for ResponseTool
remains unchanged until Harmony gains explicit encoding support.
🤖 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 534-548: CustomToolCallOutputContent currently uses
ResponseContentPart for the Parts variant, which allows output_text/refusal;
replace that broad type with a new input-only enum (e.g.,
InputResponseContentPart) that only includes the three input variants
(input_text, input_image, input_file) and mirrors their serde/structure from
ResponseContentPart, then change CustomToolCallOutputContent::Parts to
Vec<InputResponseContentPart> and update any (de)serialization/validation paths
that reference CustomToolCallOutputContent or ResponseContentPart so the wire
validator rejects output/refusal types for custom_tool_call_output payloads.

In `@model_gateway/src/routers/grpc/regular/responses/conversions.rs`:
- Around line 153-160: In responses_to_chat, add an early rejection for
ResponseTool::Custom so requests that include tools with type Custom are refused
before conversion; specifically, when mapping/validating incoming tools (the
code path that later produces ResponseInputOutputItem variants like
ResponseInputOutputItem::CustomToolCall and
ResponseInputOutputItem::CustomToolCallOutput), detect ResponseTool::Custom and
return Err("Unsupported input item type" or a similar message) immediately to
prevent silent loss of custom tools later; update the conversion logic in
responses_to_chat to check for ResponseTool::Custom near the tool
extraction/mapping phase (before the branch that currently warns on
CustomToolCall) so custom tools are rejected end-to-end on the gRPC regular
route.

---

Outside diff comments:
In `@model_gateway/src/routers/grpc/harmony/builder.rs`:
- Around line 423-439: The code currently treats ResponseTool::Custom as a tool
type but ToolLike for ResponseTool does not produce a ToolDescription for
Custom, causing silent drops; update the builder to detect any
ResponseTool::Custom in request.tools and return an explicit error (reject the
request) instead of including "custom" in tool_types. Locate the mapping that
produces tool_types (the closure iterating over request.tools) and add a branch
that returns an Err/early return when encountering ResponseTool::Custom, and
note that ToolLike for ResponseTool remains unchanged until Harmony gains
explicit encoding support.
🪄 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: d1286b6a-f3c7-4262-bf67-748d365e8c44

📥 Commits

Reviewing files that changed from the base of the PR and between 97dc848 and 24da7c1.

📒 Files selected for processing (5)
  • crates/protocols/src/responses.rs
  • model_gateway/benches/routing_allocation_bench.rs
  • model_gateway/src/routers/grpc/harmony/builder.rs
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs
  • model_gateway/src/routers/openai/responses/utils.rs

Comment thread crates/protocols/src/responses.rs
Comment thread model_gateway/src/routers/grpc/regular/responses/conversions.rs
slin1237 added a commit that referenced this pull request Apr 22, 2026
…tPart + chat-path rejection)

Two defects surfaced during PR #1301 review are corrected on top of
24da7c1:

1. `CustomToolCallOutputContent::Parts` previously accepted
   `Vec<ResponseContentPart>`, which let spec-illegal `output_text` /
   `refusal` variants round-trip through `custom_tool_call_output`
   payloads. The spec's `output` array is restricted to
   `ResponseInputText | ResponseInputImage | ResponseInputFile`, so the
   array now holds a new `CustomToolInputContentPart` enum carrying only
   the three input-typed variants (`input_text`, `input_image`,
   `input_file`). The enum's type-level rustdoc documents the tightening
   and cross-references the Postel-of-liberality retained for
   `ResponseContentPart` at other call sites. Clippy's
   `enum_variant_names` lint is silenced via `#[expect(...)]` with a
   reason attribute because the repeated `Input` prefix intentionally
   mirrors the spec's wire tags. A negative `serde_json::from_value`
   test (`custom_tool_call_output_parts_rejects_output_typed_parts`)
   asserts that an `output_text` element in the array is rejected at
   the deserialize boundary.

2. The `chat-completions` conversion path in
   `model_gateway/src/routers/grpc/regular/responses/conversions.rs`
   silently dropped `ResponseTool::Custom(_)` entries while iterating
   the tools array. Silent drop risks double-charging callers and
   masking integration bugs. The iteration now returns
   `PipelineError::invalid_request(...)` on `ResponseTool::Custom(_)`
   with a message directing callers to the gRPC path.

Addresses coderabbitai Major review comment 3121061850 (type-level
restriction + negative test) and Nitpick comment 3121061854
(chat-path rejection), plus codex P2 observations covering the same
two concerns.

Refs: T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
return Err("Unsupported tool type".to_string());
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Pre-existing: The Harmony pipeline has the same silent-drop gap — preparation.rs:175 calls extract_tools_from_response_tools without a preceding ResponseTool::Custom rejection. Custom tool items are rejected at builder.rs:726-732, but a request carrying tools: [{type: "custom", …}] with no custom items would silently lose the tool definition. Not introduced by this PR, but worth a follow-up since the pattern is now established here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cfb03552f7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread model_gateway/src/routers/grpc/harmony/builder.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model_gateway/src/routers/grpc/regular/responses/conversions.rs`:
- Around line 173-191: In responses_to_chat, besides checking req.tools for
ResponseTool::Custom, also inspect the request's tool_choice field (e.g.,
req.tool_choice or equivalent enum) and return an Err when its variant/type is
"custom"; specifically, detect tool_choice == Custom (or the string "custom")
and fail fast with the same error message used for ResponseTool::Custom so
requests with a custom tool_choice are rejected at the same conversion boundary
rather than being silently downgraded to auto.
🪄 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: 753116b4-a04f-4fae-8f6b-36eb8b5adb0a

📥 Commits

Reviewing files that changed from the base of the PR and between 24da7c1 and cfb0355.

📒 Files selected for processing (2)
  • crates/protocols/src/responses.rs
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs

Comment thread model_gateway/src/routers/grpc/regular/responses/conversions.rs Outdated
slin1237 added a commit that referenced this pull request Apr 22, 2026
…to harmony + tool_choice)

What changed
- model_gateway/src/routers/grpc/regular/responses/conversions.rs:
  - Import `ResponsesToolChoice`.
  - After the existing `ResponseTool::Custom` tool-array rejection in
    `responses_to_chat`, also reject `req.tool_choice` when it is
    `ResponsesToolChoice::Custom { .. }`. Returns the same-shape
    `Err("Unsupported tool choice")` so the chat path no longer silently
    downgrades a custom tool_choice to `auto` via `to_chat_tool_choice`.
  - New test `test_custom_tool_choice_rejected_on_chat_path` locks in
    the behaviour for requests carrying `tool_choice: {"type":"custom"}`
    but no custom tool in `tools`.

- model_gateway/src/routers/grpc/harmony/stages/preparation.rs:
  - Import `ResponseTool`, `ResponsesToolChoice`, and `warn`.
  - At the top of `prepare_responses`, before
    `extract_tools_from_response_tools` runs, reject both
    `ResponseTool::Custom` entries in `request.tools` and
    `ResponsesToolChoice::Custom { .. }` in `request.tool_choice` with
    `error::bad_request("unsupported_tool_type" / "unsupported_tool_choice",
    ...)`. This closes the silent-drop gap the Harmony path previously
    had: custom tool definitions were accepted and then stripped by
    `extract_tools_from_response_tools` / `to_chat_tool_choice` without
    reaching the model.

- model_gateway/src/routers/grpc/harmony/builder.rs:
  - Convert the `tool_types` match in `construct_input_messages_with_harmony`
    from infallible `.map(...).collect()` into a fallible
    `.map(|tool| match tool { ... })` that returns
    `Err("Unsupported tool type")` for `ResponseTool::Custom` and is
    collected as `Result<Vec<_>, _>`. Propagated via `?`, this surfaces
    as a string error up through `build_from_responses` →
    `prepare_responses` → `error::bad_request`.
  - Adds defense-in-depth at the builder boundary: even if a future
    caller invokes `build_from_responses` without passing through
    `prepare_responses`, custom tools are not silently dropped.
  - New test `test_custom_tool_rejected_in_harmony_build` exercises the
    builder boundary directly.

Why
- Three convergent round-2 review comments flagged the same underlying
  gap: Custom-variant rejection added in PR #1301 only covered
  `ResponseTool::Custom` on the chat path, leaving `tool_choice::Custom`
  and the entire Harmony path as silent-downgrade corridors. A request
  with `tools: [{"type":"custom", ...}]` or
  `tool_choice: {"type":"custom", ...}` on the Harmony path was
  accepted, had its custom definition stripped, and ran as if the
  caller had asked for `auto` — the model never saw the custom tool
  and could not emit the expected `custom_tool_call` output. Postel's
  law cuts the other way here: the gateway should fail loudly on
  unrepresentable inputs, not silently change caller semantics.

How
- Mirrored the existing chat-path rejection pattern (warn! + Err
  return of "Unsupported tool type") in three places so rejection is
  complete across:
  1. chat path tools (pre-existing, unchanged),
  2. chat path tool_choice (new, conversions.rs),
  3. Harmony path tools + tool_choice (new, preparation.rs
     single-site guard for both new and continuing conversations),
  4. Harmony builder boundary (new, builder.rs fallible collect as
     defense-in-depth for direct callers).
- Uses the same error shape per site: chat path keeps the plain
  `String` return type already in `responses_to_chat`; Harmony
  preparation path uses `error::bad_request` (the existing error
  helper for that stage); builder path returns `Err(String)` that the
  preparation stage translates through its existing `map_err` into
  `bad_request`.
- Two regression tests (one per path) prevent reintroduction of the
  silent-drop behaviour.

Verification
- cargo fmt --all --check (clean, only unstable-feature warnings)
- cargo check -p smg (clean)
- cargo clippy -p smg --lib --tests -- -D warnings (clean)
- cargo test -p smg --lib -- routers::grpc (39 passed / 0 failed,
  both new tests included)
- cargo test -p smg --lib -- responses (19 passed / 0 failed)
- cargo test -p smg --lib -- harmony (2 passed / 0 failed)
- codespell on the three modified files (clean)

Addresses
- coderabbitai review comment 3121598363 (Major, chat path
  tool_choice silent downgrade)
- codex review comment 3121584043 (P1, Harmony conversion accepts
  custom tools but cannot emit them)
- claude review comment 3121583469 (Pre-existing, Harmony preparation
  path silent-drop gap)

Refs: T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model_gateway/src/routers/grpc/harmony/builder.rs`:
- Around line 423-451: The scan that rejects ResponseTool::Custom must be moved
earlier so it runs before any early-return branch that checks
previous_response_id; update construct_input_messages_with_harmony to validate
request.tools for any ResponseTool::Custom (matching the current mapping that
returns Err("Unsupported tool type")) before the code path that splits or
delegates to build_from_responses when previous_response_id is set, ensuring
continuations can't silently drop custom tools, and add a regression test that
constructs a request with previous_response_id set and a ResponseTool::Custom to
verify the function returns the same unsupported-tool error.
🪄 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: 9d7482d2-3b3e-4103-acd2-8cf472f252c6

📥 Commits

Reviewing files that changed from the base of the PR and between cfb0355 and 7ca2e7d.

📒 Files selected for processing (3)
  • model_gateway/src/routers/grpc/harmony/builder.rs
  • model_gateway/src/routers/grpc/harmony/stages/preparation.rs
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs

Comment thread model_gateway/src/routers/grpc/harmony/builder.rs Outdated
slin1237 added a commit that referenced this pull request Apr 22, 2026
…previous_response_id split)

What changed:
- model_gateway/src/routers/grpc/harmony/builder.rs
  - Move the `ResponseTool::Custom` rejection scan in
    `construct_input_messages_with_harmony` from inside the
    `previous_response_id.is_none()` arm to the top of the function,
    before the new-vs-continuing branch split. The `tool_types` vec is
    still computed unconditionally and reused by the new-conversation
    branch for `has_custom_tools`; the continuing-conversation branch
    now benefits from the same validation pass instead of skipping it.
  - Add regression test
    `test_custom_tool_rejected_in_harmony_build_with_previous_response_id`
    covering the exact case flagged by the reviewer: a request with
    `previous_response_id = Some(..)` and a `ResponseTool::Custom`
    must still surface the `Unsupported tool type` error.

Why:
- coderabbitai Major (PR #1301, harmony/builder.rs:451): the
  `ResponseTool::Custom` validation only ran for new conversations.
  When `previous_response_id` was set, `build_from_responses()`
  skipped the rejection scan, ignored `request.tools`, and still
  succeeded — continuations could silently drop custom tools, leaving
  later turns unable to produce the expected tool call and eventually
  failing once `custom_tool_call*` history items hit the Harmony
  item-level rejection downstream.

How:
- The scan runs first and returns `Err("Unsupported tool type")` the
  moment any `ResponseTool::Custom` appears in `request.tools`,
  regardless of the continuation state. The new-conversation branch
  still uses the computed `tool_types` for `has_custom_tools`, so
  there is no behavioral change for supported tool kinds
  (`function`, `web_search_preview`, `code_interpreter`, `mcp`,
  `file_search`).
- Narrow gate: `cargo check -p smg --tests` passes, and both
  `test_custom_tool_rejected_in_harmony_build` and the new
  `test_custom_tool_rejected_in_harmony_build_with_previous_response_id`
  pass (2/2 ok).

Refs: T8
Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@slin1237
slin1237 force-pushed the feat/audit-t8-custom-tool branch from be01408 to 9e697cc Compare April 22, 2026 14:04
@slin1237

Copy link
Copy Markdown
Member Author

Branch rewritten to restore schema-only scope discipline.

What was dropped (scope bleed / defensive elaboration):

  • model_gateway/src/routers/grpc/harmony/builder.rs — defensive ResponseTool::Custom pre-check scan in construct_input_messages_with_harmony (including the round-3 repositioning before the previous_response_id split), plus the fallible .map(...).collect::<Result<_, _>>() refactor of the tool_types match, plus the two builder-boundary regression tests.
  • model_gateway/src/routers/grpc/harmony/stages/preparation.rs — the entire prepare_responses entry-block that rejected ResponseTool::Custom and ResponsesToolChoice::Custom before extract_tools_from_response_tools ran.
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs — the tool-level iteration block rejecting ResponseTool::Custom, the tool_choice: Custom { .. } rejection block, and the test_custom_tool_choice_rejected_on_chat_path regression test.

What was kept (forced by the compiler; minimum arms matching existing arm shape):

  • builder.rs: ResponseTool::Custom(_) => "custom" in the tool_types &str match, and a CustomToolCall/CustomToolCallOutput arm returning Err("Unsupported input item type") + warn!() in parse_response_item_to_harmony_message (mirrors the existing McpApprovalRequest / ImageGenerationCall arms).
  • conversions.rs: a CustomToolCall/CustomToolCallOutput arm in responses_to_chat with the same shape as the existing McpApprovalResponse / ImageGenerationCall arms.
  • openai/responses/utils.rs: ResponseTool::Custom(_) => serde_json::to_value(tool).ok() matching FileSearch / ImageGeneration.
  • benches/routing_allocation_bench.rs: CustomToolCall/CustomToolCallOutput => None matching the existing McpApprovalRequest / ImageGenerationCall arms.

Final diff: 548 lines total (432 protocol / 21 router / rest is test code inside the protocol tests mod). Protocol additions cover ResponseTool::Custom, CustomTool* struct family, CustomToolInputContentPart, CustomToolCallOutputContent, ResponseInputOutputItem::CustomToolCall, ResponseInputOutputItem::CustomToolCallOutput, plus validate_input_item + extract_text_for_routing match arms for the two new items, plus 6 serde round-trip tests (including the negative test that output_text / refusal are rejected inside the Parts array).

Verification:

  • cargo check -p smg --lib --tests --benches — clean
  • cargo fmt --all — clean
  • cargo clippy -p openai-protocol -p smg --lib --tests --benches -- -D warnings — clean
  • cargo test -p openai-protocol --lib — 100 passed / 0 failed (all 6 T8 tests included)

ResponsesToolChoice::Custom was NOT added here — it already landed with P7 (#1276).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e697cc704

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread model_gateway/src/routers/grpc/regular/responses/conversions.rs
@mergify

mergify Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

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

@mergify mergify Bot added the needs-rebase PR has merge conflicts that need to be resolved label Apr 22, 2026
@slin1237
slin1237 force-pushed the feat/audit-t8-custom-tool branch from 9e697cc to fa3eef3 Compare April 22, 2026 16:24
@github-actions github-actions Bot added the tests Test changes label Apr 22, 2026
@mergify mergify Bot removed the needs-rebase PR has merge conflicts that need to be resolved label Apr 22, 2026

/// Mask reference for image-generation `edit` calls. Spec: `{ file_id?, image_url? }`.
/// Reuses the same upload conventions as `InputImage`.
/// Reuses the same upload conventions as P1 `InputImage`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: Spurious "P1" prefix — looks like an internal task label that leaked into the doc comment.

Suggested change
/// Reuses the same upload conventions as P1 `InputImage`.
/// Reuses the same upload conventions as `InputImage`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Out of scope for this PR — the P1 InputImage doc comment was introduced by T4 (#1303) and is unrelated to T8's custom tool additions. Keeping T8's diff schema-only per audit playbook §7; happy to clean these labels up in a follow-up docs pass across all audit tasks.

/// to a single-value tag enum so payloads with an unknown `type`
/// (e.g. `"input_file"`, `"totally_made_up"`) do not silently land
/// in this untagged catch-all variant.
/// in this untagged catch-all variant — P5 fail-fast contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: "P5 fail-fast contract" reads like an internal task/priority label. The comment already explains the mechanism clearly without it — the tag adds no value to a future reader.

Suggested change
/// in this untagged catch-all variant — P5 fail-fast contract.
/// in this untagged catch-all variant.

Same on line 1027.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Out of scope for this PR — the P5 fail-fast contract label was introduced by P5 (c8996dc) and predates T8. Keeping T8's diff schema-only per audit playbook §7; suitable for a follow-up docs pass.

Comment thread crates/protocols/src/responses.rs Outdated
Comment on lines +2232 to +2237
ResponseInputOutputItem::CustomToolCall { input, .. } => {
if input.is_empty() {
let mut e = ValidationError::new("custom_tool_call_input_empty");
e.message = Some("Custom tool call input cannot be empty".into());
return Err(e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: CustomToolCall.input is the model's free-form payload echoed back by the client in a multi-turn conversation. Rejecting "" here means that if the model ever produces a custom_tool_call with empty input (plausible for a parameterless tool using unconstrained format: text), the client's follow-up request will fail validation on the re-submitted item.

The other model-generated call variants — FunctionToolCall (line 2227) and ImageGenerationCall (line 2230) — have no analogous content validation, so this is asymmetric. (FunctionToolCall.arguments is always JSON so "" is naturally impossible, but the validation function doesn't enforce that either.)

The output-side validation (CustomToolCallOutput below, FunctionCallOutput above) is fine — those are client-authored and the "non-empty output" contract is consistent.

Consider dropping this check to match the existing call-side pattern, or at minimum adding a test that documents the intent (the test file covers serde round-trips but not validation).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — dropped the asymmetric check in 7030bfe. CustomToolCall.input is now unvalidated at this layer, matching the existing FunctionToolCall arm. Output-side rejection on CustomToolCallOutput stays (client-authored, consistent with FunctionCallOutput).

slin1237 added a commit that referenced this pull request Apr 22, 2026
Remove the is_empty() rejection on `CustomToolCall.input` in
`validate_input_item`. The `input` field is the model's free-form
payload that clients must echo back unchanged on multi-turn replay —
if the model emits an empty-input `custom_tool_call` (plausible for a
parameterless tool using `format: text`), the follow-up request would
fail validation on the re-submitted item.

FunctionToolCall (the closest call-side analogue) has no equivalent
content validation, so this check was also asymmetric.

Keep CustomToolCallOutput's non-empty check — the output side is
client-authored and the non-empty-output contract is consistent with
FunctionCallOutput.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
slin1237 added a commit that referenced this pull request Apr 22, 2026
Update the doc comments for `ResponseInputOutputItem::CustomToolCall`
and `::CustomToolCallOutput` so the spec-shape string matches the Rust
types: `id?` / `namespace?` instead of bare `id, namespace`. Also add a
sentence explaining that these are modelled as `Option<String>` for
newly-minted client-side calls (they are populated on round-tripped
items from a previous response).

No wire-shape or type change — the fields already serialize with
`skip_serializing_if = "Option::is_none"`.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@mergify

mergify Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

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

@mergify mergify Bot added the needs-rebase PR has merge conflicts that need to be resolved label Apr 22, 2026
slin1237 added a commit that referenced this pull request Apr 22, 2026
Remove the is_empty() rejection on `CustomToolCall.input` in
`validate_input_item`. The `input` field is the model's free-form
payload that clients must echo back unchanged on multi-turn replay —
if the model emits an empty-input `custom_tool_call` (plausible for a
parameterless tool using `format: text`), the follow-up request would
fail validation on the re-submitted item.

FunctionToolCall (the closest call-side analogue) has no equivalent
content validation, so this check was also asymmetric.

Keep CustomToolCallOutput's non-empty check — the output side is
client-authored and the non-empty-output contract is consistent with
FunctionCallOutput.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
slin1237 added a commit that referenced this pull request Apr 22, 2026
Update the doc comments for `ResponseInputOutputItem::CustomToolCall`
and `::CustomToolCallOutput` so the spec-shape string matches the Rust
types: `id?` / `namespace?` instead of bare `id, namespace`. Also add a
sentence explaining that these are modelled as `Option<String>` for
newly-minted client-side calls (they are populated on round-tripped
items from a previous response).

No wire-shape or type change — the fields already serialize with
`skip_serializing_if = "Option::is_none"`.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@slin1237
slin1237 force-pushed the feat/audit-t8-custom-tool branch from 930f992 to a19a329 Compare April 22, 2026 21:21
@mergify mergify Bot removed the needs-rebase PR has merge conflicts that need to be resolved label Apr 22, 2026
},
other => panic!("expected CustomToolCallOutput, got {other:?}"),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: The new tests thoroughly cover serde round-trips and the output_text/refusal rejection, but the validate_input_item paths for CustomToolCallOutput are untested. Specifically, the empty-string ("output": "") and empty-array ("output": []) rejection branches at responses.rs:2241-2253 have no coverage — the validation could regress silently if someone refactors the match arms or reorders the guard clauses.

Consider adding a test that constructs a ResponsesRequest with an empty CustomToolCallOutput and asserts req.validate().is_err().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added test_custom_tool_call_output_validation_rejects_empty_text_and_parts in a13b9a1 that covers both the empty-string and empty-parts rejection branches plus a non-empty sanity case. The test pairs each output item with a user message so the cross-parameter "must contain at least one message" gate doesn't short-circuit the branch we want to cover.

slin1237 added a commit that referenced this pull request Apr 22, 2026
Add validation coverage for the two `validate_input_item` branches on
`CustomToolCallOutput`:
- `CustomToolCallOutputContent::Text(s) if s.is_empty()` → reject
- `CustomToolCallOutputContent::Parts(parts) if parts.is_empty()` → reject

Plus a sanity case (non-empty text) that must still validate. All three
payloads pair the tool output with a user message because the
cross-parameter validator requires at least one `Message` /
`SimpleInputMessage` in the input list.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@mergify

mergify Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

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

@mergify mergify Bot added the needs-rebase PR has merge conflicts that need to be resolved label Apr 22, 2026
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>
Remove the is_empty() rejection on `CustomToolCall.input` in
`validate_input_item`. The `input` field is the model's free-form
payload that clients must echo back unchanged on multi-turn replay —
if the model emits an empty-input `custom_tool_call` (plausible for a
parameterless tool using `format: text`), the follow-up request would
fail validation on the re-submitted item.

FunctionToolCall (the closest call-side analogue) has no equivalent
content validation, so this check was also asymmetric.

Keep CustomToolCallOutput's non-empty check — the output side is
client-authored and the non-empty-output contract is consistent with
FunctionCallOutput.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Update the doc comments for `ResponseInputOutputItem::CustomToolCall`
and `::CustomToolCallOutput` so the spec-shape string matches the Rust
types: `id?` / `namespace?` instead of bare `id, namespace`. Also add a
sentence explaining that these are modelled as `Option<String>` for
newly-minted client-side calls (they are populated on round-tripped
items from a previous response).

No wire-shape or type change — the fields already serialize with
`skip_serializing_if = "Option::is_none"`.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Add validation coverage for the two `validate_input_item` branches on
`CustomToolCallOutput`:
- `CustomToolCallOutputContent::Text(s) if s.is_empty()` → reject
- `CustomToolCallOutputContent::Parts(parts) if parts.is_empty()` → reject

Plus a sanity case (non-empty text) that must still validate. All three
payloads pair the tool output with a user message because the
cross-parameter validator requires at least one `Message` /
`SimpleInputMessage` in the input list.

Refs: PR #1301 review

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmarks Benchmark changes grpc gRPC client and router changes model-gateway Model gateway crate changes openai OpenAI router changes protocols Protocols crate changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant