Skip to content

feat(model_gateway): support custom tool in OpenAI responses API#1000

Open
BenDing96 wants to merge 3 commits intolightseekorg:mainfrom
BenDing96:ben/custom-tool
Open

feat(model_gateway): support custom tool in OpenAI responses API#1000
BenDing96 wants to merge 3 commits intolightseekorg:mainfrom
BenDing96:ben/custom-tool

Conversation

@BenDing96
Copy link
Copy Markdown

@BenDing96 BenDing96 commented Mar 31, 2026

Description

Feature Request: #998

Problem

OpenAI's Responses API supports a custom tool type that allows models to return arbitrary text input (not JSON) to client-defined tools, with optional grammar constraints (Lark CFG or Regex). SMG currently only supports function, web_search_preview, code_interpreter, and mcp tool types, so requests with
"type": "custom" tools are rejected or unrecognized.

Solution

Add end-to-end CustomToolCall support to the OpenAI Responses API pipeline. Custom tools are sent to backends as function tools (with empty parameters), and responses are transformed back to the custom_tool_call format with ctc_ ID prefix and input field (instead of arguments). This implementation
supports both HTTP proxy (cloud OpenAI) and gRPC (self-hosted SGLang/vLLM) backends.
## Changes

Protocol Types (crates/protocols/src/):

  • Add Custom(CustomTool) variant to ResponseTool enum with CustomTool and CustomToolFormat structs
  • Add CustomToolCall and CustomToolCallOutput variants to ResponseInputOutputItem and ResponseOutputItem
  • Add CustomToolCallEvent (input delta/done) and ItemType::CustomToolCall to event types
  • Add is_custom_tool_call_type() helper function
  • Update validation: custom tool name required, tool_choice includes custom tools, empty output rejected

ID Generation & Persistence (crates/data_connector/, model_gateway/src/routers/persistence_utils.rs):

  • Add "custom_tool_call" => "ctc" prefix mapping in make_item_id()
  • Add ITEM_TYPE_FIELDS entries and whole-item storage for custom tool call types

Backend Conversion (model_gateway/src/routers/grpc/):

  • extract_tools_from_response_tools() converts custom tools to function tools (empty params) for backends
  • ToolLike impl handles Custom variant for Harmony builder
  • build_tool_response() and chat_to_responses() emit CustomToolCall output items
  • Harmony and Regular builders handle CustomToolCall/CustomToolCallOutput input items in conversation history

Streaming (model_gateway/src/routers/openai/responses/, grpc/common/responses/streaming.rs):

  • Transform function_callcustom_tool_call in streaming events, rename argumentsinput, transform fc_ctc_ ID prefix
  • Add emit_custom_tool_call_input_delta/done() methods to event emitter
  • Add CustomToolCall to OutputItemType with ctc_ prefix

History (model_gateway/src/routers/openai/responses/history.rs):

  • Load custom_tool_call and custom_tool_call_output items from conversation storage

Unit Tests (30 new tests):

  • Protocol: serialization, deserialization, validation, tool_choice, normalization
  • Event types: constants, Display, classification helpers
  • Data connector: ctc_ prefix generation
  • Conversions: custom tool extraction, input/output item conversion, chat_to_responses output

Test Plan

Unit tests:

cargo test -- custom_tool    # 30 tests across 4 packages                                                                                                                                                                                                                                                                 
cargo test --package openai-protocol   # 59 passed                                                                                                                                                                                                                                                                        
cargo test --package data-connector    # 216 passed                                                                                                                                                                                                                                                                       
cargo test --package smg --test spec_test -- responses  # 57 passed                                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                                                                                                          
Local E2E test (cloud OpenAI backend):                                                                                                                                                                                                                                                                                    
# Start gateway                                                                                                                                                                                                                                                                                                           
OPENAI_API_KEY="sk-proj-***" python3 -m smg.launch_router \                                                                                                                                                                                                                                                               
  --backend openai --model-path gpt-5.4 \                                                                                                                                                                                                                                                                               
  --worker-urls https://api.openai.com \                                                                                                                                                                                                                                                                                  
  --history-backend memory --log-level debug --disable-health-check                                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                                                                                                          
# Send custom tool request                                                                                                                                                                                                                                                                                                
curl -X POST http://localhost:30000/v1/responses \                                                                                                                                                                                                                                                                        
  -H "Content-Type: application/json" \                                                                                                                                                                                                                                                                                 
  -H "Authorization: Bearer sk-proj-***" \                                                                                                                                                                                                                                                                                
  -d '{"model":"gpt-5.4","input":"Use the code_exec tool to print hello world.","tools":[{"type":"custom","name":"code_exec","description":"Executes arbitrary Python code."}]}'
                                                                                                                                                                                                                                                                                                                          
Verified response:                                                                                                                                                                                                                                                                                                        
{
  "output": [                                                                                                                                                                                                                                                                                                             
    {                                                                                                                                                                                                                                                                                                                   
      "id": "ctc_0a1b7cf6...",
      "type": "custom_tool_call",
      "status": "completed",     
      "call_id": "call_kVP4S2j7...",                                                                                                                                                                                                                                                                                      
      "input": "print(\"hello world\")\n",
      "name": "code_exec"                                                                                                                                                                                                                                                                                                 
    }                                                                                                                                                                                                                                                                                                                   
  ],                                                                                                                                                                                                                                                                                                                      
  "tools": [                                                                                                                                                                                                                                                                                                              
    {       
      "type": "custom",                                                                                                                                                                                                                                                                                                   
      "name": "code_exec",                                                                                                                                                                                                                                                                                              
      "description": "Executes arbitrary Python code."                                                                                                                                                                                                                                                                    
    }
  ]                                                                                                                                                                                                                                                                                                                       
}    

<!-- Provide a thorough reproducible test showing before and after behavior. -->

<details>
<summary>Checklist</summary>

- [x] `cargo +nightly fmt` passes
- [x] `cargo clippy --all-targets --all-features -- -D warnings` passes
- [x] (Optional) Documentation updated: https://developers.openai.com/api/docs/guides/function-calling#custom-tools
- [x] (Optional) Please join us on Slack [#sig-smg](https://slack.lightseek.org) to discuss, review, and merge PRs

</details>


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added support for custom tools, enabling users to define and invoke custom tool integrations alongside standard function tools.
* Custom tools now integrate seamlessly with conversation streaming and chat responses, with full support for inputs, outputs, and status tracking.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@github-actions github-actions bot added grpc gRPC client and router changes tests Test changes model-gateway Model gateway crate changes openai OpenAI router changes labels Mar 31, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

Introduces custom tool call support across the codebase by adding CustomToolCall/CustomToolCallOutput variants to ItemType and response items, CustomToolCallEvent for streaming, CustomTool for tool definitions, and updating ID generation to use ctc_ prefix. Extends streaming and non-streaming response converters with comprehensive test coverage.

Changes

Cohort / File(s) Summary
Protocol Type Definitions
crates/protocols/src/event_types.rs, crates/protocols/src/responses.rs
Added CustomToolCallEvent enum with InputDelta/InputDone variants, extended ItemType with CustomToolCall variant and helper methods, introduced CustomTool and CustomToolFormat structs, added ResponseInputOutputItem::CustomToolCall/CustomToolCallOutput and ResponseOutputItem::CustomToolCall variants with validation logic.
Core Infrastructure
crates/data_connector/src/core.rs, model_gateway/src/routers/persistence_utils.rs
Updated make_item_id() to recognize custom_tool_call and generate ctc_ prefixed IDs; extended persistence mapping to extract and handle custom tool call/output fields.
gRPC Streaming & Responses
model_gateway/src/routers/grpc/common/responses/streaming.rs, model_gateway/src/routers/grpc/common/responses/utils.rs, model_gateway/src/routers/grpc/harmony/...
Added OutputItemType::CustomToolCall variant, introduced emit_custom_tool_call_input_delta/done methods, updated tool extraction to recognize ResponseTool::Custom, extended ToolLike implementation for custom tools, modified harmony builder to emit tool messages for custom tool calls.
Regular/OpenAI Response Conversions
model_gateway/src/routers/grpc/regular/responses/conversions.rs, model_gateway/src/routers/grpc/regular/responses/streaming.rs
Extended responses_to_chat and chat_to_responses to distinguish custom tool calls from function tools using ResponseTool::Custom name matching; updated streaming accumulator to convert matching tool calls to CustomToolCall variant.
OpenAI Event & History Handling
model_gateway/src/routers/openai/responses/streaming.rs, model_gateway/src/routers/openai/responses/history.rs, model_gateway/src/routers/openai/responses/utils.rs
Added custom tool detection and event transformation to emit CustomToolCallEvent types with ctc_ IDs; extended history deserialization for custom tool item types; updated tool conversion to exclude custom tools from restoration.
Comprehensive Test Coverage
model_gateway/tests/spec/responses.rs
Added extensive tests for ResponseTool::Custom validation, serialization/deserialization of CustomTool/CustomToolFormat, custom tool call item handling, tool-choice validation, and mixed tool-type scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Issue #998: Directly addressed by this PR—implements full CustomTool support including new response types, item variants, streaming/non-streaming converters, and ctc_ ID generation across all routing layers.

Possibly related PRs

  • PR #532: Modifies ResponseTool handling and extract_tools_from_response_tools logic, sharing overlapping code patterns with this PR's tool extraction updates.
  • PR #732: Updates conversation history deserialization in responses/history.rs, which this PR extends to handle CUSTOM_TOOL_CALL and CUSTOM_TOOL_CALL_OUTPUT item types.

Suggested labels

model-gateway, protocols, data-connector, openai, grpc, tests

Suggested reviewers

  • CatherineSue
  • key4ng
  • slin1237

Poem

🐰 Hops with glee, through tool paths new,
Custom calls dance in ctc hue,
Streaming swift, from gRPC to routers,
Request to response—what noble suitors!
Harmony rings where functions dwell,
A rabbit's refactor, done quite well! 🌟

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding support for custom tools in the OpenAI Responses API, which is the primary objective across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch ben/custom-tool

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@mergify
Copy link
Copy Markdown
Contributor

mergify bot commented Mar 31, 2026

Hi @BenDing96, the DCO sign-off check has failed. All commits must include a Signed-off-by line.

To fix existing commits:

# Sign off the last N commits (replace N with the number of unsigned commits)
git rebase HEAD~N --signoff
git push --force-with-lease

To sign off future commits automatically:

  • Use git commit -s every time, or
  • VSCode: enable Git: Always Sign Off in Settings
  • PyCharm: enable Sign-off commit in the Commit tool window

@mergify
Copy link
Copy Markdown
Contributor

mergify bot commented Mar 31, 2026

Hi @BenDing96, 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 Mar 31, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements support for "custom tools" throughout the system, adding new types like CustomTool and CustomToolCall to the protocol and updating the gateway to handle these variants. Key changes include specialized validation, streaming event emission for custom tool inputs, and logic to map custom tools to the underlying chat completion format. The PR also updates persistence and history loading to support the new item types. A review comment identifies an opportunity to reduce code duplication by extracting the logic for collecting custom tool names into a helper method on ResponsesRequest.

Comment on lines +434 to +446
let custom_tool_names: std::collections::HashSet<&str> = responses_request
.tools
.as_ref()
.map(|tools| {
tools
.iter()
.filter_map(|t| match t {
ResponseTool::Custom(ct) => Some(ct.name.as_str()),
_ => None,
})
.collect()
})
.unwrap_or_default();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This logic to collect custom tool names into a HashSet is duplicated in several places in this PR. To improve code reuse and maintainability, consider adding a helper method to ResponsesRequest.

For example, you could add this to crates/protocols/src/responses.rs:

impl ResponsesRequest {
    pub fn custom_tool_names(&self) -> std::collections::HashSet<&str> {
        self.tools
            .as_deref()
            .unwrap_or(&[])
            .iter()
            .filter_map(|t| {
                if let ResponseTool::Custom(ct) = t {
                    Some(ct.name.as_str())
                } else {
                    None
                }
            })
            .collect()
    }
}

Then you can simplify this block to:

let custom_tool_names = responses_request.custom_tool_names();

This would also simplify similar blocks in model_gateway/src/routers/grpc/regular/responses/conversions.rs and model_gateway/src/routers/grpc/regular/responses/streaming.rs.

References
  1. Extract duplicated logic into a shared helper function to improve maintainability and reduce redundancy.
  2. To prevent vulnerabilities from duplicate entries, use data structures that inherently enforce uniqueness, such as HashSet, instead of manually deduplicating collections like Vec.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 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/data_connector/src/core.rs`:
- Line 296: Update the ID-prefix mapping in crates/data_connector/src/core.rs so
it matches the router-side mapping in
model_gateway/src/routers/persistence_utils.rs: ensure both "custom_tool_call"
and "custom_tool_call_output" map to "ctc" (instead of only one mapping or
divergent prefixes) and remove/merge any duplicate hand-written maps around the
mapping block (see the entries near lines 725-738 and 767) so the two keys
consistently use "ctc".

In `@crates/protocols/src/responses.rs`:
- Around line 1238-1245: Currently only empty custom.names are rejected in
ResponseTool::Custom; add validation to reject duplicate tool names across
function and custom tool variants. When validating tools in the responses
parsing/validation path (e.g., where ResponseTool::Custom and
ResponseTool::Function are handled), collect the set of names from
ResponseTool::Function and then, when encountering a ResponseTool::Custom with a
name that already exists in that set (and vice‑versa if functions are validated
after customs), return a ValidationError (use ValidationError::new with an
appropriate code like "duplicate_tool_name") whose message clearly states that
the tool name is already used (e.g., "Duplicate tool name: 'foo' used by both
function and custom tool"). Ensure you reference and check the custom.name and
the corresponding function tool name fields so conflicting names are rejected.

In `@model_gateway/src/routers/grpc/common/responses/utils.rs`:
- Around line 128-136: The conversion for ResponseTool::Custom(ct) currently
discards ct.format and sets Function.parameters to an empty object; update the
ResponseTool::Custom(ct) branch so that Function.parameters is built from
ct.format (convert ct.format into the appropriate JSON Schema/serde_json::Value)
and propagate any related flags (e.g., strict) so the Tool/Function preserves
grammar-constrained input; locate the ResponseTool::Custom(ct) match arm and
replace the hardcoded serde_json::json!({}) with a serialization of ct.format
(or a constructed schema from ct.format) and ensure ct.format is not ignored
when constructing Tool and Function.

In `@model_gateway/src/routers/grpc/harmony/builder.rs`:
- Around line 120-124: ResponseTool::Custom mapping currently drops the tool
format by calling ToolDescription::new(..., None); change it to forward the
custom tool's format (ct.format) into ToolDescription::new so formatted custom
tools retain their constraints when the Harmony prompt is built—i.e., replace
the None with ct.format.clone() or ct.format (matching the type) in the
ResponseTool::Custom arm.

In `@model_gateway/src/routers/grpc/regular/responses/conversions.rs`:
- Around line 136-172: The assistant tool-call replay uses the wrapper
conversation item id (`id`) instead of the backend tool identifier (`call_id`),
causing mismatched tool_call.id vs tool message tool_call_id; update the
ResponseInputOutputItem::CustomToolCall branch to use the actual backend call id
(call_id) for the ToolCall.id and ensure the Tool message uses the same call_id
(and likewise ensure ResponseInputOutputItem::CustomToolCallOutput uses call_id
for tool_call_id), i.e., replace usages of id.clone() with the backend call
identifier so ToolCall { id: ... } and ChatMessage::Tool { tool_call_id: ... }
consistently use call_id.

In `@model_gateway/src/routers/grpc/regular/responses/streaming.rs`:
- Around line 382-419: The conversion to CustomToolCall is producing empty
call_id because streamed deltas only set the streamed tool-call id in the
FunctionToolCall.id field (process_chunk() fills id, name, arguments but leaves
call_id empty); update the conversion loop that iterates self.tool_calls (in
streaming.rs) so that when matching ResponseOutputItem::FunctionToolCall you
populate call_id from the FunctionToolCall.id (or from the streamed tool-call id
field) before constructing ResponseOutputItem::CustomToolCall; ensure you copy
id -> call_id (or use a non-empty call identifier derived from id) so
CustomToolCall.call_id is populated for downstream consumers.

In `@model_gateway/src/routers/openai/responses/utils.rs`:
- Line 234: The match arm for ResponseTool::Custom currently returns None which
causes custom tools to be dropped when restoring final response tools; update
the ResponseTool handling so that ResponseTool::Custom(_) returns Some(...)
preserving the custom tool entry (i.e., return the custom tool value instead of
None) so that restoring MCP/builtin tools merges with rather than overwrites
response.tools; locate the match that contains ResponseTool::Custom and change
its branch to return the appropriate Some variant that contains the custom tool.

In `@model_gateway/tests/spec/responses.rs`:
- Around line 1521-1537: The test test_validate_custom_tool_call_output_empty is
currently passing due to ResponsesRequest::validate rejecting Items when there
is no Message item, so update the fixture to ensure the empty-output validator
is actually exercised: construct a ResponsesRequest whose input contains both
the ResponseInputOutputItem::CustomToolCallOutput with output set to empty AND a
valid message item (e.g., a ResponseInput::Message entry) so validate() advances
to the CustomToolCallOutput check, then assert the specific validation error for
CustomToolCallOutput.output (or an expected error code/message) instead of just
is_err(); reference ResponsesRequest::validate, ResponseInput::Items, and
ResponseInputOutputItem::CustomToolCallOutput.output when making the change.
🪄 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: e69b0c6e-151d-4ae6-bb61-5e0299743b03

📥 Commits

Reviewing files that changed from the base of the PR and between 0b61bf0 and 593af16.

📒 Files selected for processing (14)
  • crates/data_connector/src/core.rs
  • crates/protocols/src/event_types.rs
  • crates/protocols/src/responses.rs
  • model_gateway/src/routers/grpc/common/responses/streaming.rs
  • model_gateway/src/routers/grpc/common/responses/utils.rs
  • model_gateway/src/routers/grpc/harmony/builder.rs
  • model_gateway/src/routers/grpc/harmony/responses/non_streaming.rs
  • model_gateway/src/routers/grpc/regular/responses/conversions.rs
  • model_gateway/src/routers/grpc/regular/responses/streaming.rs
  • model_gateway/src/routers/openai/responses/history.rs
  • model_gateway/src/routers/openai/responses/streaming.rs
  • model_gateway/src/routers/openai/responses/utils.rs
  • model_gateway/src/routers/persistence_utils.rs
  • model_gateway/tests/spec/responses.rs

"mcp_call" => "mcp",
"mcp_list_tools" => "mcpl",
"function_call" => "fc",
"custom_tool_call" => "ctc",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Keep custom-tool ID prefixes aligned with the router-side mapping.

The new branch/tests cover custom_tool_call, but model_gateway/src/routers/persistence_utils.rs already maps both custom_tool_call and custom_tool_call_output to ctc. Keeping separate hand-written maps here makes the new custom-tool IDs easy to drift again.

💡 Minimal alignment diff
-        "custom_tool_call" => "ctc",
+        "custom_tool_call" | "custom_tool_call_output" => "ctc",
@@
         let test_cases = vec![
             ("message", "msg_"),
             ("reasoning", "rs_"),
             ("mcp_call", "mcp_"),
             ("mcp_list_tools", "mcpl_"),
             ("function_call", "fc_"),
             ("custom_tool_call", "ctc_"),
+            ("custom_tool_call_output", "ctc_"),
         ];

Also applies to: 725-738, 767-767

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

In `@crates/data_connector/src/core.rs` at line 296, Update the ID-prefix mapping
in crates/data_connector/src/core.rs so it matches the router-side mapping in
model_gateway/src/routers/persistence_utils.rs: ensure both "custom_tool_call"
and "custom_tool_call_output" map to "ctc" (instead of only one mapping or
divergent prefixes) and remove/merge any duplicate hand-written maps around the
mapping block (see the entries near lines 725-738 and 767) so the two keys
consistently use "ctc".

Comment on lines +1238 to +1245
if let ResponseTool::Custom(custom) = tool {
if custom.name.is_empty() {
let mut e = ValidationError::new("missing_required_parameter");
e.message =
Some(format!("Missing required parameter: 'tools[{idx}].name'.").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.

⚠️ Potential issue | 🟠 Major

Reject duplicate names across function and custom tools.

model_gateway/src/routers/grpc/harmony/responses/non_streaming.rs now decides whether to emit FunctionToolCall or CustomToolCall by checking the tool name against a custom-name set. With only the validation here, a request can still declare both a function tool and a custom tool named foo, which makes the output type ambiguous and currently serializes any foo call as custom_tool_call.

💡 Suggested fix
 fn validate_response_tools(tools: &[ResponseTool]) -> Result<(), ValidationError> {
     // MCP server_label must be present and unique (case-insensitive).
     let mut seen_mcp_labels: HashSet<String> = HashSet::new();
+    let mut seen_named_tools: HashSet<String> = HashSet::new();
 
     for (idx, tool) in tools.iter().enumerate() {
+        if let Some(name) = match tool {
+            ResponseTool::Function(ft) => Some(ft.function.name.as_str()),
+            ResponseTool::Custom(custom) => Some(custom.name.as_str()),
+            _ => None,
+        } {
+            if !seen_named_tools.insert(name.to_string()) {
+                let mut e = ValidationError::new("duplicate_tool_name");
+                e.message = Some(
+                    format!("Duplicate tool name '{name}' found in 'tools' parameter.").into(),
+                );
+                return Err(e);
+            }
+        }
+
         if let ResponseTool::Custom(custom) = tool {
             if custom.name.is_empty() {
                 let mut e = ValidationError::new("missing_required_parameter");
                 e.message =
                     Some(format!("Missing required parameter: 'tools[{idx}].name'.").into());
🤖 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 1238 - 1245, Currently only
empty custom.names are rejected in ResponseTool::Custom; add validation to
reject duplicate tool names across function and custom tool variants. When
validating tools in the responses parsing/validation path (e.g., where
ResponseTool::Custom and ResponseTool::Function are handled), collect the set of
names from ResponseTool::Function and then, when encountering a
ResponseTool::Custom with a name that already exists in that set (and vice‑versa
if functions are validated after customs), return a ValidationError (use
ValidationError::new with an appropriate code like "duplicate_tool_name") whose
message clearly states that the tool name is already used (e.g., "Duplicate tool
name: 'foo' used by both function and custom tool"). Ensure you reference and
check the custom.name and the corresponding function tool name fields so
conflicting names are rejected.

Comment on lines +128 to +136
ResponseTool::Custom(ct) => Some(Tool {
tool_type: "function".to_string(),
function: Function {
name: ct.name.clone(),
description: ct.description.clone(),
parameters: serde_json::json!({}),
strict: None,
},
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

tools[].format becomes a no-op on the self-hosted gRPC conversion path.

This conversion turns every custom tool into an unconstrained parameterless function and drops ct.format entirely. Requests that depend on grammar-constrained custom input will still be accepted, but the backend never receives the constraint.

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

In `@model_gateway/src/routers/grpc/common/responses/utils.rs` around lines 128 -
136, The conversion for ResponseTool::Custom(ct) currently discards ct.format
and sets Function.parameters to an empty object; update the
ResponseTool::Custom(ct) branch so that Function.parameters is built from
ct.format (convert ct.format into the appropriate JSON Schema/serde_json::Value)
and propagate any related flags (e.g., strict) so the Tool/Function preserves
grammar-constrained input; locate the ResponseTool::Custom(ct) match arm and
replace the hardcoded serde_json::json!({}) with a serialization of ct.format
(or a constructed schema from ct.format) and ensure ct.format is not ignored
when constructing Tool and Function.

Comment on lines +120 to +124
ResponseTool::Custom(ct) => Some(ToolDescription::new(
ct.name.clone(),
ct.description.clone().unwrap_or_default(),
None,
)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Harmony request building also discards custom-tool format constraints.

ToolDescription::new(..., None) strips ct.format before the Harmony prompt is built, so formatted custom tools are treated as unconstrained free-form calls on this path too.

🤖 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 120 - 124,
ResponseTool::Custom mapping currently drops the tool format by calling
ToolDescription::new(..., None); change it to forward the custom tool's format
(ct.format) into ToolDescription::new so formatted custom tools retain their
constraints when the Harmony prompt is built—i.e., replace the None with
ct.format.clone() or ct.format (matching the type) in the ResponseTool::Custom
arm.

Comment on lines +136 to +172
ResponseInputOutputItem::CustomToolCall {
id,
name,
input,
output,
..
} => {
// Custom tool call from history - convert to function tool call format
messages.push(ChatMessage::Assistant {
content: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: id.clone(),
tool_type: "function".to_string(),
function: FunctionCallResponse {
name: name.clone(),
arguments: Some(input.clone()),
},
}]),
reasoning_content: None,
});

if let Some(output_text) = output {
messages.push(ChatMessage::Tool {
content: MessageContent::Text(output_text.clone()),
tool_call_id: id.clone(),
});
}
}
ResponseInputOutputItem::CustomToolCallOutput {
call_id, output, ..
} => {
messages.push(ChatMessage::Tool {
content: MessageContent::Text(output.clone()),
tool_call_id: call_id.clone(),
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use call_id, not the conversation item id, when replaying custom tool history.

Line 148 and Line 161 currently reuse the item id. For streamed custom tools, id is the wrapper conversation item id (ctc_*), while the actual backend tool-call identifier lives in call_id. Replaying persisted history through chat will then produce an assistant tool_call.id that does not match the later tool message's tool_call_id, so follow-up turns break as soon as id != call_id.

Suggested fix
-                    ResponseInputOutputItem::CustomToolCall {
-                        id,
-                        name,
-                        input,
-                        output,
-                        ..
-                    } => {
+                    ResponseInputOutputItem::CustomToolCall {
+                        call_id,
+                        name,
+                        input,
+                        output,
+                        ..
+                    } => {
                         // Custom tool call from history - convert to function tool call format
                         messages.push(ChatMessage::Assistant {
                             content: None,
                             name: None,
                             tool_calls: Some(vec![ToolCall {
-                                id: id.clone(),
+                                id: call_id.clone(),
                                 tool_type: "function".to_string(),
                                 function: FunctionCallResponse {
                                     name: name.clone(),
                                     arguments: Some(input.clone()),
                                 },
                             }]),
                             reasoning_content: None,
                         });

                         if let Some(output_text) = output {
                             messages.push(ChatMessage::Tool {
                                 content: MessageContent::Text(output_text.clone()),
-                                tool_call_id: id.clone(),
+                                tool_call_id: call_id.clone(),
                             });
                         }
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ResponseInputOutputItem::CustomToolCall {
id,
name,
input,
output,
..
} => {
// Custom tool call from history - convert to function tool call format
messages.push(ChatMessage::Assistant {
content: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: id.clone(),
tool_type: "function".to_string(),
function: FunctionCallResponse {
name: name.clone(),
arguments: Some(input.clone()),
},
}]),
reasoning_content: None,
});
if let Some(output_text) = output {
messages.push(ChatMessage::Tool {
content: MessageContent::Text(output_text.clone()),
tool_call_id: id.clone(),
});
}
}
ResponseInputOutputItem::CustomToolCallOutput {
call_id, output, ..
} => {
messages.push(ChatMessage::Tool {
content: MessageContent::Text(output.clone()),
tool_call_id: call_id.clone(),
});
}
ResponseInputOutputItem::CustomToolCall {
call_id,
name,
input,
output,
..
} => {
// Custom tool call from history - convert to function tool call format
messages.push(ChatMessage::Assistant {
content: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: call_id.clone(),
tool_type: "function".to_string(),
function: FunctionCallResponse {
name: name.clone(),
arguments: Some(input.clone()),
},
}]),
reasoning_content: None,
});
if let Some(output_text) = output {
messages.push(ChatMessage::Tool {
content: MessageContent::Text(output_text.clone()),
tool_call_id: call_id.clone(),
});
}
}
ResponseInputOutputItem::CustomToolCallOutput {
call_id, output, ..
} => {
messages.push(ChatMessage::Tool {
content: MessageContent::Text(output.clone()),
tool_call_id: call_id.clone(),
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/routers/grpc/regular/responses/conversions.rs` around lines
136 - 172, The assistant tool-call replay uses the wrapper conversation item id
(`id`) instead of the backend tool identifier (`call_id`), causing mismatched
tool_call.id vs tool message tool_call_id; update the
ResponseInputOutputItem::CustomToolCall branch to use the actual backend call id
(call_id) for the ToolCall.id and ensure the Tool message uses the same call_id
(and likewise ensure ResponseInputOutputItem::CustomToolCallOutput uses call_id
for tool_call_id), i.e., replace usages of id.clone() with the backend call
identifier so ToolCall { id: ... } and ChatMessage::Tool { tool_call_id: ... }
consistently use call_id.

Comment on lines +382 to +419
// Add tool calls (convert custom tools from FunctionToolCall to CustomToolCall)
let custom_tool_names: std::collections::HashSet<&str> = self
.original_request
.tools
.as_ref()
.map(|tools| {
tools
.iter()
.filter_map(|t| match t {
ResponseTool::Custom(ct) => Some(ct.name.as_str()),
_ => None,
})
.collect()
})
.unwrap_or_default();

for tc in self.tool_calls {
match tc {
ResponseOutputItem::FunctionToolCall {
id,
call_id,
name,
arguments,
output: tc_output,
status,
} if custom_tool_names.contains(name.as_str()) => {
output.push(ResponseOutputItem::CustomToolCall {
id,
call_id,
name,
input: arguments,
output: tc_output,
status,
});
}
other => output.push(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.

⚠️ Potential issue | 🟠 Major

The new streamed CustomToolCall items will expose call_id: "".

Line 410 copies call_id from the accumulated FunctionToolCall, but process_chunk() only fills id, name, and arguments for streamed tool-call deltas and leaves call_id at its String::new() initializer. The non-MCP streaming path will therefore return custom_tool_call items with an empty call_id, so clients have nothing usable to echo back in custom_tool_call_output.

Please populate call_id from the streamed tool-call id before this conversion.

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

In `@model_gateway/src/routers/grpc/regular/responses/streaming.rs` around lines
382 - 419, The conversion to CustomToolCall is producing empty call_id because
streamed deltas only set the streamed tool-call id in the FunctionToolCall.id
field (process_chunk() fills id, name, arguments but leaves call_id empty);
update the conversion loop that iterates self.tool_calls (in streaming.rs) so
that when matching ResponseOutputItem::FunctionToolCall you populate call_id
from the FunctionToolCall.id (or from the streamed tool-call id field) before
constructing ResponseOutputItem::CustomToolCall; ensure you copy id -> call_id
(or use a non-empty call identifier derived from id) so CustomToolCall.call_id
is populated for downstream consumers.

Comment on lines 200 to 239
FunctionCallEvent::ARGUMENTS_DONE => {
parsed_data["type"] = json!(McpEvent::CALL_ARGUMENTS_DONE);
// Check if this belongs to a custom tool by looking up the tool name
let is_custom = parsed_data
.get("name")
.and_then(|v| v.as_str())
.is_some_and(|name| {
ctx.original_request.tools.as_ref().is_some_and(|tools| {
tools
.iter()
.any(|t| matches!(t, ResponseTool::Custom(ct) if ct.name == name))
})
});

if is_custom {
parsed_data["type"] = json!(CustomToolCallEvent::INPUT_DONE);

// Rename arguments → input
if let Some(args) = parsed_data
.as_object_mut()
.and_then(|o| o.remove("arguments"))
{
parsed_data["input"] = args;
}

// Transform item_id from fc_* to mcp_*
if let Some(item_id) = parsed_data.get("item_id").and_then(|v| v.as_str()) {
if let Some(stripped) = item_id.strip_prefix("fc_") {
let new_id = format!("mcp_{stripped}");
parsed_data["item_id"] = json!(new_id);
// Transform item_id from fc_* to ctc_*
if let Some(item_id) = parsed_data.get("item_id").and_then(|v| v.as_str()) {
if let Some(stripped) = item_id.strip_prefix("fc_") {
parsed_data["item_id"] = json!(format!("ctc_{stripped}"));
}
}
} else {
parsed_data["type"] = json!(McpEvent::CALL_ARGUMENTS_DONE);

// Transform item_id from fc_* to mcp_*
if let Some(item_id) = parsed_data.get("item_id").and_then(|v| v.as_str()) {
if let Some(stripped) = item_id.strip_prefix("fc_") {
let new_id = format!("mcp_{stripped}");
parsed_data["item_id"] = json!(new_id);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Custom-tool input.done never survives this transform.

Line 202 tries to re-identify the tool from parsed_data["name"], but response.function_call_arguments.done does not carry the tool name in the event shape used elsewhere in this stack. is_custom therefore stays false, so custom calls in the interception path still fall into the MCP branch and get mcp_* ids. Even if the payload type were rewritten here, forward_streaming_event() still derives the SSE header from map_event_name(evt), which hard-maps ARGUMENTS_DONE to response.mcp_call_arguments.done. That leaves custom_tool_call output items paired with MCP argument events.

Please carry the tool kind forward from the earlier buffered call metadata keyed by item_id/output_index, and use that for both the buffered delta and the done event.

ResponseTool::WebSearchPreview(_) => serde_json::to_value(tool).ok(),
ResponseTool::CodeInterpreter(_) => serde_json::to_value(tool).ok(),
ResponseTool::Function(_) => None,
ResponseTool::Custom(_) => None,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Restore custom tools in the final response payload.

This helper is used right before the OpenAI streaming/non-streaming paths return and persist the final response. Returning None for ResponseTool::Custom means any response that also restores MCP/builtin tools overwrites response.tools without the original custom entry.

💡 Suggested fix
-        ResponseTool::Custom(_) => None,
+        ResponseTool::Custom(_) => serde_json::to_value(tool).ok(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/src/routers/openai/responses/utils.rs` at line 234, The match
arm for ResponseTool::Custom currently returns None which causes custom tools to
be dropped when restoring final response tools; update the ResponseTool handling
so that ResponseTool::Custom(_) returns Some(...) preserving the custom tool
entry (i.e., return the custom tool value instead of None) so that restoring
MCP/builtin tools merges with rather than overwrites response.tools; locate the
match that contains ResponseTool::Custom and change its branch to return the
appropriate Some variant that contains the custom tool.

Comment on lines +1521 to +1537
/// Test custom tool input item validation - empty output rejected
#[test]
fn test_validate_custom_tool_call_output_empty() {
let request = ResponsesRequest {
input: ResponseInput::Items(vec![ResponseInputOutputItem::CustomToolCallOutput {
id: Some("ctc_123".to_string()),
call_id: "call_1".to_string(),
output: String::new(),
status: None,
}]),
..Default::default()
};
let result = request.validate();
assert!(
result.is_err(),
"CustomToolCallOutput with empty output should be invalid"
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

This test can pass before the empty-output validator is exercised.

ResponsesRequest::validate() already rejects input: Items([...]) when there is no message item, so this fixture can stay green even if the CustomToolCallOutput.output.is_empty() check regresses. Add a valid message item or assert the specific field/code you expect from the empty-output rule.

Suggested fixture change
     let request = ResponsesRequest {
-        input: ResponseInput::Items(vec![ResponseInputOutputItem::CustomToolCallOutput {
-            id: Some("ctc_123".to_string()),
-            call_id: "call_1".to_string(),
-            output: String::new(),
-            status: None,
-        }]),
+        input: ResponseInput::Items(vec![
+            ResponseInputOutputItem::SimpleInputMessage {
+                content: StringOrContentParts::String("run the tool".to_string()),
+                role: "user".to_string(),
+                r#type: None,
+            },
+            ResponseInputOutputItem::CustomToolCallOutput {
+                id: Some("ctc_123".to_string()),
+                call_id: "call_1".to_string(),
+                output: String::new(),
+                status: None,
+            },
+        ]),
         ..Default::default()
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Test custom tool input item validation - empty output rejected
#[test]
fn test_validate_custom_tool_call_output_empty() {
let request = ResponsesRequest {
input: ResponseInput::Items(vec![ResponseInputOutputItem::CustomToolCallOutput {
id: Some("ctc_123".to_string()),
call_id: "call_1".to_string(),
output: String::new(),
status: None,
}]),
..Default::default()
};
let result = request.validate();
assert!(
result.is_err(),
"CustomToolCallOutput with empty output should be invalid"
);
/// Test custom tool input item validation - empty output rejected
#[test]
fn test_validate_custom_tool_call_output_empty() {
let request = ResponsesRequest {
input: ResponseInput::Items(vec![
ResponseInputOutputItem::SimpleInputMessage {
content: StringOrContentParts::String("run the tool".to_string()),
role: "user".to_string(),
r#type: None,
},
ResponseInputOutputItem::CustomToolCallOutput {
id: Some("ctc_123".to_string()),
call_id: "call_1".to_string(),
output: String::new(),
status: None,
},
]),
..Default::default()
};
let result = request.validate();
assert!(
result.is_err(),
"CustomToolCallOutput with empty output should be invalid"
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_gateway/tests/spec/responses.rs` around lines 1521 - 1537, The test
test_validate_custom_tool_call_output_empty is currently passing due to
ResponsesRequest::validate rejecting Items when there is no Message item, so
update the fixture to ensure the empty-output validator is actually exercised:
construct a ResponsesRequest whose input contains both the
ResponseInputOutputItem::CustomToolCallOutput with output set to empty AND a
valid message item (e.g., a ResponseInput::Message entry) so validate() advances
to the CustomToolCallOutput check, then assert the specific validation error for
CustomToolCallOutput.output (or an expected error code/message) instead of just
is_err(); reference ResponsesRequest::validate, ResponseInput::Items, and
ResponseInputOutputItem::CustomToolCallOutput.output when making the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

grpc gRPC client and router changes model-gateway Model gateway crate changes needs-rebase PR has merge conflicts that need to be resolved openai OpenAI router changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant