diff --git a/crates/protocols/src/responses.rs b/crates/protocols/src/responses.rs index dd40971996..2de5723edf 100644 --- a/crates/protocols/src/responses.rs +++ b/crates/protocols/src/responses.rs @@ -175,11 +175,12 @@ impl<'de> Deserialize<'de> for ResponsesFunctionToolChoice { /// the `#[serde(untagged)]` enum would accept any object shape that /// happened to fit the field set of an earlier variant. /// -/// NOTE: kept separate from `common::ToolChoice`. Chat Completions uses -/// a different `Function` wire shape (nested `{"function": {"name": ...}}`) -/// and does not accept the `Types` / `Mcp` / `Custom` / `ApplyPatch` / -/// `Shell` variants; sharing one enum would let `/v1/chat/completions` -/// silently accept spec-invalid payloads. +/// This type deliberately does NOT live in `common.rs`: Chat Completions +/// has its own `ToolChoice` with a different `Function` wire shape +/// (nested `{"function": {"name": ...}}`) and does not accept the +/// `Types` / `Mcp` / `Custom` / `ApplyPatch` / `Shell` variants at all. +/// Sharing one enum across both APIs would silently accept spec-invalid +/// payloads on `/v1/chat/completions`. #[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] #[serde(untagged)] pub enum ResponsesToolChoice { @@ -197,10 +198,14 @@ pub enum ResponsesToolChoice { /// `{"type": "function", "name": "..."}` — Responses spec flat shape. /// - /// Also accepts the legacy Chat-style nested shape - /// (`{"type": "function", "function": {"name": "..."}}`) on deserialize; - /// always serializes as the canonical flat shape. The nested legacy shape - /// is gated behind a custom `Deserialize` impl on + /// Accepts both the spec-canonical flat wire shape and the legacy + /// Chat-style nested shape (`{"type": "function", "function": {"name": "..."}}`) + /// on deserialize to preserve backward compatibility with smg clients + /// written against the pre-split shared `ToolChoice` type. Always + /// serializes as the canonical flat shape per the OpenAI Responses spec + /// — Postel's law: liberal on input, conservative on output. + /// + /// The nested legacy shape is gated behind a custom `Deserialize` impl on /// `ResponsesFunctionToolChoice`; the untagged outer enum still pins the /// `"type": "function"` discriminator via `FunctionToolChoiceTag` so /// payloads without that tag cannot reach this variant. @@ -388,6 +393,16 @@ pub enum ResponseTool { /// "windows"|"mac", type: "computer_use_preview" }`. #[serde(rename = "computer_use_preview")] ComputerUsePreview(ComputerUsePreviewTool), + + /// User-defined custom tool with optional grammar-constrained input. + /// + /// Spec: `{ name, type: "custom", defer_loading?, description?, format? }`. + /// `format` constrains the model's free-form `input` payload — `Text` is + /// unconstrained, `Grammar` enforces a Lark or regex production at decode + /// time. The model returns a `custom_tool_call` output item carrying the + /// raw `input` string back to the client; the client owns execution. + #[serde(rename = "custom")] + Custom(CustomTool), } #[serde_with::skip_serializing_none] @@ -489,6 +504,140 @@ pub enum FileSearchRanker { Default20241115, } +/// User-defined custom tool definition. +/// +/// Spec: `{ name, type: "custom", defer_loading?, description?, format? }`. +/// The discriminator (`type: "custom"`) is enforced by the parent +/// [`ResponseTool`] enum; this struct only carries the payload fields so the +/// `flatten`-style wire shape survives a round-trip. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CustomTool { + /// Stable identifier the model uses to address this tool in + /// `custom_tool_call` items. + pub name: String, + /// Optional human-readable description supplied to the model. + pub description: Option, + /// When `true`, the tool definition is deferred to a later + /// `tool_search`-style fetch instead of being loaded inline. + pub defer_loading: Option, + /// Optional input-format constraint — `text` (unconstrained) or + /// `grammar` (Lark / regex production). + pub format: Option, +} + +/// Input-format constraint applied to a [`CustomTool`]. +/// +/// Spec: `CustomToolInputFormat = Text { type: "text" } | +/// Grammar { definition, syntax: "lark" | "regex", type: "grammar" }`. +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +pub enum CustomToolInputFormat { + /// Unconstrained free-form text input. + #[serde(rename = "text")] + Text, + /// Grammar-constrained input. The model's free-form output must match + /// `definition` interpreted under `syntax`. + #[serde(rename = "grammar")] + Grammar(CustomToolGrammar), +} + +/// Grammar payload carried by [`CustomToolInputFormat::Grammar`]. +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CustomToolGrammar { + /// Grammar source text. Interpretation depends on `syntax`. + pub definition: String, + /// `"lark"` or `"regex"` per spec. + pub syntax: CustomToolGrammarSyntax, +} + +/// Grammar dialect for [`CustomToolGrammar::syntax`]. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CustomToolGrammarSyntax { + /// Lark grammar (https://lark-parser.readthedocs.io/). + Lark, + /// Regular expression. + Regex, +} + +/// Input-only content part accepted inside a `custom_tool_call_output` array. +/// +/// Spec (openai-responses-api-spec.md L269): the array form of +/// `custom_tool_call_output.output` permits only `ResponseInputText | +/// ResponseInputImage | ResponseInputFile` — assistant-facing shapes such as +/// `output_text` and `refusal` are explicitly not allowed here. This enum +/// mirrors the three input variants of [`ResponseContentPart`] with identical +/// field shapes so spec-compliant payloads round-trip unchanged, while +/// deserialization of `output_text`/`refusal` fails loudly instead of being +/// silently coerced. +/// +/// Other call sites that legitimately carry mixed input/output content parts +/// continue to use [`ResponseContentPart`] (Postel-of-liberality preserved for +/// cross-tool reuse). +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +// Variant names intentionally mirror the spec's `input_*` tag set; the shared +// prefix communicates the input-only restriction that justifies this enum's +// existence (see type-level docs above). +#[expect( + clippy::enum_variant_names, + reason = "variant names mirror spec `input_*` tags by design" +)] +pub enum CustomToolInputContentPart { + /// `type: "input_text"` — inline textual input. + #[serde(rename = "input_text")] + InputText { text: String }, + /// `type: "input_image"` — reference to an image supplied by the client. + /// Exactly one of `file_id` / `image_url` is typically set; both may be + /// absent when only `detail` is being conveyed. + #[serde(rename = "input_image")] + InputImage { + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image_url: Option, + }, + /// `type: "input_file"` — reference to an attached file. `file_data` is a + /// base64 blob; `file_url` / `file_id` reference external/uploaded files. + #[serde(rename = "input_file")] + InputFile { + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + }, +} + +/// Output payload variant accepted by `custom_tool_call_output`. +/// +/// Spec: `output: string or array of ResponseInputText | ResponseInputImage | +/// ResponseInputFile`. The array variant uses the restricted +/// [`CustomToolInputContentPart`] type so assistant-only shapes +/// (`output_text`, `refusal`) are rejected at the type boundary rather than +/// accepted and silently reinterpreted. +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(untagged)] +pub enum CustomToolCallOutputContent { + /// Plain string output. + Text(String), + /// Array of input-typed content parts (`input_text` / `input_image` / + /// `input_file`) per the spec's `output` array shape. + Parts(Vec), +} + #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] @@ -627,7 +776,7 @@ pub struct ImageGenerationTool { } /// 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`. #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)] #[serde(deny_unknown_fields)] @@ -973,9 +1122,17 @@ pub enum ResponseInputOutputItem { /// flow): clients may resubmit only `{ type, id }` to reference a prior /// generation by identifier, so `result` and `status` are accepted as /// absent on the input side. The full shape is - /// `{ id, result?: base64 string, revised_prompt?, status?, type }`. The - /// server-side `ResponseOutputItem::ImageGenerationCall` variant remains - /// strict because the gateway always populates those fields on emit. + /// `{ id, result?: base64 string, revised_prompt?, status?, type }`. + /// + /// This mirrors the OpenAI Python SDK 2.8.x + /// `response_input_item_param.ImageGenerationCall` TypedDict: while the + /// TypedDict types those fields as `Required[Optional[...]]`, the HTTP + /// API itself documents the id-only multi-turn reference form (see the + /// image-generation tool guide), and `skip_serializing_if` keeps the + /// serialized form spec-compatible when a full item is round-tripped. + /// The server-side `ResponseOutputItem::ImageGenerationCall` variant + /// remains strict because the gateway always populates those fields + /// on emit. #[serde(rename = "image_generation_call")] ImageGenerationCall { id: String, @@ -1045,6 +1202,37 @@ pub enum ResponseInputOutputItem { #[serde(default, skip_serializing_if = "Option::is_none")] status: Option, }, + /// `type: "custom_tool_call"` — assistant's call into a registered + /// custom tool. Spec: `{ call_id, input, name, type, id?, namespace? }`. + /// `id` / `namespace` are modelled as `Option` so newly-minted + /// client-side calls can omit them; they are populated on items + /// round-tripped from a previous response. `input` is the model's + /// free-form payload (constrained by the tool's `format` if grammar is + /// set); the client owns execution and replies with a matching + /// [`Self::CustomToolCallOutput`]. + #[serde(rename = "custom_tool_call")] + CustomToolCall { + call_id: String, + input: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + namespace: Option, + }, + /// `type: "custom_tool_call_output"` — client's response to a + /// `custom_tool_call`. Spec: `{ call_id, output, type, id? }` (no + /// `status` field per spec — see Drift Log entry for T8). `id` is + /// `Option` for the same reason as `CustomToolCall.id` above. + /// `output` is either a plain string or an array of input-typed content + /// parts (`input_text` / `input_image` / `input_file`). + #[serde(rename = "custom_tool_call_output")] + CustomToolCallOutput { + call_id: String, + output: CustomToolCallOutputContent, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + }, #[serde(untagged)] SimpleInputMessage { content: StringOrContentParts, @@ -1052,7 +1240,7 @@ pub enum ResponseInputOutputItem { /// Spec: `EasyInputMessage.type` is `optional "message"`. Constrained /// 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. #[serde(default, skip_serializing_if = "Option::is_none")] #[serde(rename = "type")] r#type: Option, @@ -1068,7 +1256,8 @@ pub enum ResponseInputOutputItem { /// Single-value tag enum pinning `EasyInputMessage.type` to the spec's only /// permitted value, `"message"`. Used to keep [`ResponseInputOutputItem::SimpleInputMessage`] /// — which is the `#[serde(untagged)]` fallback in the outer `type`-tagged enum -/// — from silently swallowing payloads whose `type` discriminator is unknown. +/// — from silently swallowing payloads whose `type` discriminator is unknown +/// (P5 fail-fast contract). #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SimpleInputMessageTypeTag { @@ -1177,6 +1366,8 @@ pub enum ResponseReasoningContent { /// Tagged content element carried in `Reasoning.summary`. /// /// OpenAI spec: `summary: array of SummaryTextContent { text, type: "summary_text" }`. +/// Replaces the prior `Vec` wire-type that broke bidirectional +/// interoperability with spec-compliant clients. #[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] #[serde(tag = "type")] #[serde(rename_all = "snake_case")] @@ -2024,7 +2215,9 @@ impl GenerationRequest for ResponsesRequest { | ResponseInputOutputItem::ImageGenerationCall { .. } | ResponseInputOutputItem::Compaction { .. } | ResponseInputOutputItem::ComputerCall { .. } - | ResponseInputOutputItem::ComputerCallOutput { .. } => {} + | ResponseInputOutputItem::ComputerCallOutput { .. } + | ResponseInputOutputItem::CustomToolCall { .. } + | ResponseInputOutputItem::CustomToolCallOutput { .. } => {} } } @@ -2300,6 +2493,24 @@ fn validate_input_item(item: &ResponseInputOutputItem) -> Result<(), ValidationE ResponseInputOutputItem::Compaction { .. } => {} ResponseInputOutputItem::ComputerCall { .. } => {} ResponseInputOutputItem::ComputerCallOutput { .. } => {} + // CustomToolCall is model-generated and echoed back on multi-turn + // replay; matches the FunctionToolCall arm above with no content + // validation so a parameterless custom tool with empty input can + // round-trip cleanly. + ResponseInputOutputItem::CustomToolCall { .. } => {} + ResponseInputOutputItem::CustomToolCallOutput { output, .. } => match output { + CustomToolCallOutputContent::Text(s) if s.is_empty() => { + let mut e = ValidationError::new("custom_tool_call_output_empty"); + e.message = Some("Custom tool call output cannot be empty".into()); + return Err(e); + } + CustomToolCallOutputContent::Parts(parts) if parts.is_empty() => { + let mut e = ValidationError::new("custom_tool_call_output_empty"); + e.message = Some("Custom tool call output parts cannot be empty".into()); + return Err(e); + } + _ => {} + }, } Ok(()) } diff --git a/crates/protocols/tests/responses.rs b/crates/protocols/tests/responses.rs index 3a6ede7a90..a30d53c70e 100644 --- a/crates/protocols/tests/responses.rs +++ b/crates/protocols/tests/responses.rs @@ -1388,6 +1388,72 @@ fn computer_action_variants_round_trip() { } } +#[test] +fn test_custom_tool_text_format_round_trip() { + // Spec (openai-responses-api-spec.md §tools, L471-474): + // `Custom { name, type: "custom", defer_loading?, description?, format? }` + // with `format: CustomToolInputFormat = Text { type: "text" }`. + let payload = json!({ + "type": "custom", + "name": "run_sql", + "description": "Run a SELECT against the analytics warehouse", + "defer_loading": false, + "format": {"type": "text"} + }); + + let tool: ResponseTool = serde_json::from_value(payload.clone()) + .expect("custom tool with text format should deserialize"); + match &tool { + ResponseTool::Custom(c) => { + assert_eq!(c.name, "run_sql"); + assert_eq!(c.defer_loading, Some(false)); + assert!(matches!(c.format, Some(CustomToolInputFormat::Text))); + } + other => panic!("expected ResponseTool::Custom, got {other:?}"), + } + + let serialized = serde_json::to_value(&tool).expect("custom tool should serialize"); + assert_eq!(serialized, payload); +} + +#[test] +fn test_custom_tool_grammar_format_round_trip() { + // Spec (openai-responses-api-spec.md §tools, L472-474): + // `Grammar { definition, syntax: "lark" | "regex", type: "grammar" }`. + for syntax in ["lark", "regex"] { + let payload = json!({ + "type": "custom", + "name": "parse_expr", + "format": { + "type": "grammar", + "definition": "start: NUMBER", + "syntax": syntax + } + }); + + let tool: ResponseTool = serde_json::from_value(payload.clone()) + .expect("custom tool with grammar format should deserialize"); + match &tool { + ResponseTool::Custom(c) => match &c.format { + Some(CustomToolInputFormat::Grammar(g)) => { + assert_eq!(g.definition, "start: NUMBER"); + let expected = if syntax == "lark" { + CustomToolGrammarSyntax::Lark + } else { + CustomToolGrammarSyntax::Regex + }; + assert_eq!(g.syntax, expected); + } + other => panic!("expected Grammar format, got {other:?}"), + }, + other => panic!("expected ResponseTool::Custom, got {other:?}"), + } + + let serialized = serde_json::to_value(&tool).expect("custom tool should serialize"); + assert_eq!(serialized, payload); + } +} + #[test] fn computer_call_input_item_round_trips_spec_shape() { let payload = json!({ @@ -1492,3 +1558,237 @@ fn computer_safety_check_accepts_optional_code_and_message() { } assert_eq!(serde_json::to_value(&item).expect("serialize"), payload); } + +#[test] +fn test_custom_tool_call_input_item_round_trip() { + // Spec (openai-responses-api-spec.md L272-273): + // `CustomToolCall object { call_id, input, name, type, id, namespace }` + // `type: "custom_tool_call"`. + let payload = json!({ + "type": "custom_tool_call", + "call_id": "call_abc123", + "input": "SELECT count(*) FROM events", + "name": "run_sql", + "id": "ctc_01", + "namespace": "analytics" + }); + + let item: ResponseInputOutputItem = + serde_json::from_value(payload.clone()).expect("custom_tool_call should deserialize"); + match &item { + ResponseInputOutputItem::CustomToolCall { + call_id, + input, + name, + id, + namespace, + } => { + assert_eq!(call_id, "call_abc123"); + assert_eq!(input, "SELECT count(*) FROM events"); + assert_eq!(name, "run_sql"); + assert_eq!(id.as_deref(), Some("ctc_01")); + assert_eq!(namespace.as_deref(), Some("analytics")); + } + other => panic!("expected CustomToolCall, got {other:?}"), + } + + let serialized = serde_json::to_value(&item).expect("serialize"); + assert_eq!(serialized, payload); +} + +#[test] +fn test_custom_tool_call_output_string_round_trip() { + // Spec (openai-responses-api-spec.md L268-270): + // `CustomToolCallOutput object { call_id, output, type, id }` where + // `output: string or array of ResponseInputText | ResponseInputImage | + // ResponseInputFile`. No `status` field per spec. + let payload = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": "42", + "id": "ctco_01" + }); + + let item: ResponseInputOutputItem = serde_json::from_value(payload.clone()) + .expect("custom_tool_call_output should deserialize"); + match &item { + ResponseInputOutputItem::CustomToolCallOutput { + call_id, + output, + id, + } => { + assert_eq!(call_id, "call_abc123"); + assert_eq!(id.as_deref(), Some("ctco_01")); + match output { + CustomToolCallOutputContent::Text(s) => assert_eq!(s, "42"), + CustomToolCallOutputContent::Parts(_) => panic!("expected Text output"), + } + } + other => panic!("expected CustomToolCallOutput, got {other:?}"), + } + + let serialized = serde_json::to_value(&item).expect("serialize"); + assert_eq!(serialized, payload); +} + +#[test] +fn test_custom_tool_call_output_array_round_trip() { + // Spec (openai-responses-api-spec.md L269): array of + // ResponseInputText | ResponseInputImage | ResponseInputFile. + let payload = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": [ + {"type": "input_text", "text": "rows returned:"}, + {"type": "input_file", "filename": "result.csv", "file_id": "file_xyz"} + ] + }); + + let item: ResponseInputOutputItem = serde_json::from_value(payload.clone()) + .expect("array-form custom_tool_call_output should deserialize"); + match &item { + ResponseInputOutputItem::CustomToolCallOutput { output, .. } => match output { + CustomToolCallOutputContent::Parts(parts) => { + assert_eq!(parts.len(), 2); + assert!(matches!( + parts[0], + CustomToolInputContentPart::InputText { .. } + )); + assert!(matches!( + parts[1], + CustomToolInputContentPart::InputFile { .. } + )); + } + CustomToolCallOutputContent::Text(_) => panic!("expected Parts output"), + }, + other => panic!("expected CustomToolCallOutput, got {other:?}"), + } + + let serialized = serde_json::to_value(&item).expect("serialize"); + assert_eq!(serialized, payload); +} + +#[test] +fn test_custom_tool_call_output_parts_reject_output_text() { + // Spec (openai-responses-api-spec.md L269): the `output` array only + // accepts input-typed parts. Assistant-facing shapes such as + // `output_text` and `refusal` must be rejected at the type boundary + // rather than silently coerced. + let payload_output_text = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": [ + {"type": "output_text", "text": "not allowed here", "annotations": []} + ] + }); + let err = serde_json::from_value::(payload_output_text) + .expect_err("output_text must not be accepted inside custom_tool_call_output parts"); + let msg = err.to_string(); + assert!( + msg.contains("output_text") || msg.contains("did not match any variant"), + "expected untagged-variant rejection carrying `output_text`, got: {msg}" + ); + + let payload_refusal = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": [ + {"type": "refusal", "refusal": "nope"} + ] + }); + assert!( + serde_json::from_value::(payload_refusal).is_err(), + "refusal must not be accepted inside custom_tool_call_output parts" + ); + + // Sanity: an input-typed part still deserializes cleanly. + let payload_ok = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": [ + {"type": "input_text", "text": "ok"} + ] + }); + let item: ResponseInputOutputItem = serde_json::from_value(payload_ok) + .expect("input_text part must still deserialize after the tightening"); + match &item { + ResponseInputOutputItem::CustomToolCallOutput { output, .. } => match output { + CustomToolCallOutputContent::Parts(parts) => { + assert_eq!(parts.len(), 1); + assert!(matches!( + parts[0], + CustomToolInputContentPart::InputText { .. } + )); + } + CustomToolCallOutputContent::Text(_) => panic!("expected Parts output"), + }, + other => panic!("expected CustomToolCallOutput, got {other:?}"), + } +} + +#[test] +fn test_custom_tool_call_output_validation_rejects_empty_text_and_parts() { + use validator::Validate; + + // cross-parameter validation (L2145-2160) requires a Message/SimpleInputMessage + // alongside any tool item, so every payload below pairs the custom_tool_call_output + // with a plain user message to isolate the CustomToolCallOutput branch we want + // to cover. + let user_msg = json!({"role": "user", "content": "hi"}); + + // Empty string output — validate_input_item's + // `CustomToolCallOutputContent::Text(s) if s.is_empty()` branch. + let empty_text: ResponsesRequest = serde_json::from_value(json!({ + "model": "gpt-5.4", + "input": [ + user_msg, + { + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": "" + } + ] + })) + .expect("request should deserialize"); + assert!( + empty_text.validate().is_err(), + "empty CustomToolCallOutput text must be rejected" + ); + + // Empty parts array output — validate_input_item's + // `CustomToolCallOutputContent::Parts(parts) if parts.is_empty()` branch. + let empty_parts: ResponsesRequest = serde_json::from_value(json!({ + "model": "gpt-5.4", + "input": [ + user_msg, + { + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": [] + } + ] + })) + .expect("request should deserialize"); + assert!( + empty_parts.validate().is_err(), + "empty CustomToolCallOutput parts array must be rejected" + ); + + // Sanity: a non-empty string output passes the same validator. + let ok_text: ResponsesRequest = serde_json::from_value(json!({ + "model": "gpt-5.4", + "input": [ + user_msg, + { + "type": "custom_tool_call_output", + "call_id": "call_abc123", + "output": "42" + } + ] + })) + .expect("request should deserialize"); + assert!( + ok_text.validate().is_ok(), + "non-empty CustomToolCallOutput must validate", + ); +} diff --git a/model_gateway/benches/routing_allocation_bench.rs b/model_gateway/benches/routing_allocation_bench.rs index c0c73e3043..3068b6d9d7 100644 --- a/model_gateway/benches/routing_allocation_bench.rs +++ b/model_gateway/benches/routing_allocation_bench.rs @@ -72,6 +72,8 @@ fn extract_text_for_routing_old(req: &ResponsesRequest) -> String { ResponseInputOutputItem::Compaction { .. } => None, ResponseInputOutputItem::ComputerCall { .. } => None, ResponseInputOutputItem::ComputerCallOutput { .. } => None, + ResponseInputOutputItem::CustomToolCall { .. } + | ResponseInputOutputItem::CustomToolCallOutput { .. } => None, }) .collect::>() .join(" "), diff --git a/model_gateway/src/routers/grpc/harmony/builder.rs b/model_gateway/src/routers/grpc/harmony/builder.rs index dff5686955..b6d29c6b1b 100644 --- a/model_gateway/src/routers/grpc/harmony/builder.rs +++ b/model_gateway/src/routers/grpc/harmony/builder.rs @@ -440,6 +440,7 @@ impl HarmonyBuilder { ResponseTool::ImageGeneration(_) => "image_generation", ResponseTool::Computer => "computer", ResponseTool::ComputerUsePreview(_) => "computer_use_preview", + ResponseTool::Custom(_) => "custom", }) .collect() }) @@ -744,6 +745,15 @@ impl HarmonyBuilder { ResponseInputOutputItem::Compaction { .. } => { Err("Unsupported input item type".to_string()) } + + ResponseInputOutputItem::CustomToolCall { .. } + | ResponseInputOutputItem::CustomToolCallOutput { .. } => { + warn!( + function = "parse_response_item_to_harmony_message", + "Custom tool item reached Harmony conversion" + ); + Err("Unsupported input item type".to_string()) + } } } diff --git a/model_gateway/src/routers/grpc/regular/responses/conversions.rs b/model_gateway/src/routers/grpc/regular/responses/conversions.rs index 5624a2176a..dd147a7856 100644 --- a/model_gateway/src/routers/grpc/regular/responses/conversions.rs +++ b/model_gateway/src/routers/grpc/regular/responses/conversions.rs @@ -162,6 +162,14 @@ pub(crate) fn responses_to_chat(req: &ResponsesRequest) -> Result { return Err("Unsupported input item type".to_string()); } + ResponseInputOutputItem::CustomToolCall { .. } + | ResponseInputOutputItem::CustomToolCallOutput { .. } => { + warn!( + function = "responses_to_chat", + "Custom tool item reached chat conversion" + ); + return Err("Unsupported input item type".to_string()); + } } } } diff --git a/model_gateway/src/routers/openai/responses/utils.rs b/model_gateway/src/routers/openai/responses/utils.rs index bd3d015e3a..0b0739e9e8 100644 --- a/model_gateway/src/routers/openai/responses/utils.rs +++ b/model_gateway/src/routers/openai/responses/utils.rs @@ -251,6 +251,7 @@ pub(super) fn response_tool_to_value(tool: &ResponseTool) -> Option { ResponseTool::Computer | ResponseTool::ComputerUsePreview(_) => { serde_json::to_value(tool).ok() } + ResponseTool::Custom(_) => serde_json::to_value(tool).ok(), ResponseTool::Function(_) => None, } }