From ba82073a3030e540e7c0ea9da5f6236cab37e3d0 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sat, 11 Jul 2026 16:34:28 -0700 Subject: [PATCH 1/9] fix reasoning param --- .../tests/provider_roundtrip_test.rs | 21 +++++ crates/lingua/src/providers/openai/params.rs | 79 ++++++++++++++++++- .../src/providers/openai/responses_adapter.rs | 69 ++++++++-------- crates/lingua/src/universal/reasoning.rs | 2 + ...ses-reasoning-provider-extras-request.json | 17 ++++ 5 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 crates/coverage-report/tests/provider_roundtrip_test.rs create mode 100644 payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json diff --git a/crates/coverage-report/tests/provider_roundtrip_test.rs b/crates/coverage-report/tests/provider_roundtrip_test.rs new file mode 100644 index 000000000..56c9936b6 --- /dev/null +++ b/crates/coverage-report/tests/provider_roundtrip_test.rs @@ -0,0 +1,21 @@ +use lingua::processing::adapters::ProviderAdapter; +use lingua::providers::openai::ResponsesAdapter; +use lingua::serde_json::{self, Value}; + +#[test] +fn responses_request_provider_roundtrip_preserves_reasoning_provider_fields() { + let original: Value = serde_json::from_slice(include_bytes!( + "../../../payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json" + )) + .expect("fixture should parse"); + + let adapter = ResponsesAdapter; + let universal = adapter + .request_to_universal(original.clone()) + .expect("fixture should convert to universal"); + let reconstructed = adapter + .request_from_universal(&universal) + .expect("fixture should convert back to Responses"); + + assert_eq!(reconstructed, original); +} diff --git a/crates/lingua/src/providers/openai/params.rs b/crates/lingua/src/providers/openai/params.rs index 2b5ea7044..f47eac23f 100644 --- a/crates/lingua/src/providers/openai/params.rs +++ b/crates/lingua/src/providers/openai/params.rs @@ -6,7 +6,7 @@ eliminating the need for explicit KNOWN_KEYS arrays. */ use crate::providers::openai::generated::{ChatCompletionRequestMessage, Instructions, Summary}; -use crate::serde_json::Value; +use crate::serde_json::{self, Map, Value}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -146,6 +146,44 @@ pub struct OpenAIResponsesParams { pub extras: BTreeMap, } +const RESPONSES_RECONSTRUCTED_FIELDS: &[&str] = &[ + "input", + "metadata", + "model", + "parallel_tool_calls", + "prompt_cache_key", + "reasoning", + "service_tier", + "store", + "stream", + "temperature", + "tool_choice", + "tools", + "top_logprobs", + "top_p", +]; + +impl OpenAIResponsesParams { + pub(crate) fn provider_only_extras(&self) -> Result, serde_json::Error> { + let mut extras = serialize_object(self)?; + for field in RESPONSES_RECONSTRUCTED_FIELDS { + extras.remove(*field); + } + + if let Some(reasoning) = self + .reasoning + .as_ref() + .map(OpenAIReasoning::provider_only_value) + .transpose()? + .flatten() + { + extras.insert("reasoning".into(), reasoning); + } + + Ok(extras) + } +} + /// Typed view over `UniversalParams.extras[ChatCompletions]` used during /// universal -> OpenAI Chat reconstruction. /// @@ -220,9 +258,48 @@ pub enum OpenAIReasoningEffort { /// Typed OpenAI Responses reasoning parameter view. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct OpenAIReasoning { + #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub generate_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(flatten)] + pub extras: BTreeMap, +} + +const RESPONSES_REASONING_RECONSTRUCTED_FIELDS: &[&str] = + &["effort", "summary", "generate_summary"]; + +impl OpenAIReasoning { + pub(crate) fn has_reconstructed_fields(&self) -> bool { + self.effort.is_some() || self.summary.is_some() || self.generate_summary.is_some() + } + + fn provider_only_value(&self) -> Result, serde_json::Error> { + let mut provider_only = serialize_object(self)?; + for field in RESPONSES_REASONING_RECONSTRUCTED_FIELDS { + provider_only.remove(*field); + } + + if provider_only.is_empty() { + return Ok(None); + } + + Ok(Some(Value::Object(provider_only))) + } +} + +fn serialize_object(value: &T) -> Result, serde_json::Error> { + match serde_json::to_value(value)? { + Value::Object(mut map) => { + map.retain(|_, value| !value.is_null()); + Ok(map) + } + _ => Ok(Map::new()), + } } #[cfg(test)] diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index 7792ed8ad..5d92cfa7a 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -259,6 +259,24 @@ pub(crate) fn parse_responses_extras( .map(|v: Option| v.unwrap_or_default()) } +fn merge_reasoning_values( + canonical: Option, + provider_only: Option<&Value>, +) -> Option { + match (canonical, provider_only) { + (Some(Value::Object(mut canonical)), Some(Value::Object(provider_only))) => { + for (key, value) in provider_only { + canonical.insert(key.clone(), value.clone()); + } + Some(Value::Object(canonical)) + } + (Some(_), Some(provider_only)) => Some(provider_only.clone()), + (None, Some(provider_only)) => Some(provider_only.clone()), + (Some(canonical), None) => Some(canonical), + (None, None) => None, + } +} + impl ProviderAdapter for ResponsesAdapter { fn format(&self) -> ProviderFormat { ProviderFormat::Responses @@ -280,6 +298,9 @@ impl ProviderAdapter for ResponsesAdapter { // Single parse: typed params now includes typed input via #[serde(flatten)] let typed_params: OpenAIResponsesParams = serde_json::from_value(payload) .map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?; + let extras_map = typed_params + .provider_only_extras() + .map_err(|e| TransformError::SerializationFailed(e.to_string()))?; // Extract input items from typed_params.input (partial move - other fields remain accessible) let input_items: Vec = match typed_params.input { @@ -344,6 +365,7 @@ impl ProviderAdapter for ResponsesAdapter { let reasoning = typed_params .reasoning .as_ref() + .filter(|r| r.has_reconstructed_fields()) .map(|r| (r, max_tokens).into()); let canonical_metadata = typed_params.metadata.clone().or_else(|| { @@ -389,34 +411,6 @@ impl ProviderAdapter for ResponsesAdapter { extras: Default::default(), }; - // Collect provider-specific extras for round-trip preservation - // This includes both unknown fields (from serde flatten) and known Responses API fields - // that aren't part of UniversalParams - let mut extras_map: Map = typed_params.extras.into_iter().collect(); - - // Add Responses API specific known fields that aren't in UniversalParams - if let Some(instructions) = typed_params.instructions { - extras_map.insert("instructions".into(), Value::String(instructions)); - } - if let Some(text) = typed_params.text { - extras_map.insert("text".into(), text); - } - if let Some(truncation) = typed_params.truncation { - extras_map.insert("truncation".into(), truncation); - } - if let Some(user) = typed_params.user { - extras_map.insert("user".into(), Value::String(user)); - } - if let Some(safety_identifier) = typed_params.safety_identifier { - extras_map.insert("safety_identifier".into(), Value::String(safety_identifier)); - } - if let Some(v) = typed_params.max_output_tokens { - extras_map.insert("max_output_tokens".into(), Value::Number(v.into())); - } - if let Some(moderation) = typed_params.moderation { - extras_map.insert("moderation".into(), moderation); - } - if !extras_map.is_empty() { params.extras.insert(ProviderFormat::Responses, extras_map); } @@ -545,21 +539,24 @@ impl ProviderAdapter for ResponsesAdapter { obj.insert("text".into(), text_val); } - // Add reasoning from canonical params - if let Some(raw_reasoning) = responses_extras_view.reasoning.as_ref() { - obj.insert("reasoning".into(), raw_reasoning.clone()); - } else if let Some(reasoning) = req.params.reasoning.as_ref() { + // Add reasoning from canonical params and merge provider-only Responses fields. + let canonical_reasoning = if let Some(reasoning) = req.params.reasoning.as_ref() { let mut reasoning = reasoning.clone(); if let Some(effort) = reasoning.effort { reasoning.effort = Some(clamp_reasoning_effort_for_model(model, effort)); } - if let Some(reasoning_val) = reasoning + reasoning .to_provider(ProviderFormat::Responses, req.params.output_token_budget()) .ok() .flatten() - { - obj.insert("reasoning".into(), reasoning_val); - } + } else { + None + }; + if let Some(reasoning) = merge_reasoning_values( + canonical_reasoning, + responses_extras_view.reasoning.as_ref(), + ) { + obj.insert("reasoning".into(), reasoning); } if let Some(raw_moderation) = responses_extras_view .moderation diff --git a/crates/lingua/src/universal/reasoning.rs b/crates/lingua/src/universal/reasoning.rs index 18c6ba546..caa4c72fb 100644 --- a/crates/lingua/src/universal/reasoning.rs +++ b/crates/lingua/src/universal/reasoning.rs @@ -689,6 +689,8 @@ mod tests { effort: Some(OpenAIReasoningEffortParam::High), summary: Some(OpenAISummary::Detailed), generate_summary: None, + context: None, + extras: Default::default(), }; // Test fallback conversion (uses DEFAULT_MAX_TOKENS) diff --git a/payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json b/payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json new file mode 100644 index 000000000..e8e2aa8ab --- /dev/null +++ b/payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json @@ -0,0 +1,17 @@ +{ + "model": "gpt-5.6-sol", + "input": [ + { + "type": "message", + "role": "user", + "content": "Use the available project tools." + } + ], + "reasoning": { + "effort": "medium", + "context": "all_turns", + "provider_roundtrip_marker": { + "preserve": true + } + } +} From a3b8f8cc84cd7d60fe99b4031fcf04089909a288 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sat, 11 Jul 2026 16:49:04 -0700 Subject: [PATCH 2/9] clean it up --- crates/lingua/src/providers/openai/params.rs | 76 +++++++------------ .../src/providers/openai/responses_adapter.rs | 27 ++++++- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/crates/lingua/src/providers/openai/params.rs b/crates/lingua/src/providers/openai/params.rs index f47eac23f..38b56aed5 100644 --- a/crates/lingua/src/providers/openai/params.rs +++ b/crates/lingua/src/providers/openai/params.rs @@ -6,7 +6,7 @@ eliminating the need for explicit KNOWN_KEYS arrays. */ use crate::providers::openai::generated::{ChatCompletionRequestMessage, Instructions, Summary}; -use crate::serde_json::{self, Map, Value}; +use crate::serde_json::{Map, Value}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -146,41 +146,25 @@ pub struct OpenAIResponsesParams { pub extras: BTreeMap, } -const RESPONSES_RECONSTRUCTED_FIELDS: &[&str] = &[ - "input", - "metadata", - "model", - "parallel_tool_calls", - "prompt_cache_key", - "reasoning", - "service_tier", - "store", - "stream", - "temperature", - "tool_choice", - "tools", - "top_logprobs", - "top_p", -]; - impl OpenAIResponsesParams { - pub(crate) fn provider_only_extras(&self) -> Result, serde_json::Error> { - let mut extras = serialize_object(self)?; - for field in RESPONSES_RECONSTRUCTED_FIELDS { - extras.remove(*field); - } - - if let Some(reasoning) = self - .reasoning + pub(crate) fn provider_only_reasoning(&self) -> Option { + self.reasoning .as_ref() - .map(OpenAIReasoning::provider_only_value) - .transpose()? - .flatten() - { + .and_then(OpenAIReasoning::provider_only_value) + } + + pub(crate) fn extras_map(&self) -> Map { + let mut extras = self + .extras + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + + if let Some(reasoning) = self.provider_only_reasoning() { extras.insert("reasoning".into(), reasoning); } - Ok(extras) + extras } } @@ -270,35 +254,27 @@ pub struct OpenAIReasoning { pub extras: BTreeMap, } -const RESPONSES_REASONING_RECONSTRUCTED_FIELDS: &[&str] = - &["effort", "summary", "generate_summary"]; - impl OpenAIReasoning { pub(crate) fn has_reconstructed_fields(&self) -> bool { self.effort.is_some() || self.summary.is_some() || self.generate_summary.is_some() } - fn provider_only_value(&self) -> Result, serde_json::Error> { - let mut provider_only = serialize_object(self)?; - for field in RESPONSES_REASONING_RECONSTRUCTED_FIELDS { - provider_only.remove(*field); + fn provider_only_value(&self) -> Option { + let mut provider_only = self + .extras + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + + if let Some(context) = self.context.as_ref() { + provider_only.insert("context".into(), Value::String(context.clone())); } if provider_only.is_empty() { - return Ok(None); + return None; } - Ok(Some(Value::Object(provider_only))) - } -} - -fn serialize_object(value: &T) -> Result, serde_json::Error> { - match serde_json::to_value(value)? { - Value::Object(mut map) => { - map.retain(|_, value| !value.is_null()); - Ok(map) - } - _ => Ok(Map::new()), + Some(Value::Object(provider_only)) } } diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index 5d92cfa7a..3a94773a2 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -298,9 +298,7 @@ impl ProviderAdapter for ResponsesAdapter { // Single parse: typed params now includes typed input via #[serde(flatten)] let typed_params: OpenAIResponsesParams = serde_json::from_value(payload) .map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?; - let extras_map = typed_params - .provider_only_extras() - .map_err(|e| TransformError::SerializationFailed(e.to_string()))?; + let mut extras_map: Map = typed_params.extras_map(); // Extract input items from typed_params.input (partial move - other fields remain accessible) let input_items: Vec = match typed_params.input { @@ -411,6 +409,29 @@ impl ProviderAdapter for ResponsesAdapter { extras: Default::default(), }; + // Collect provider-specific extras for round-trip preservation. + if let Some(instructions) = typed_params.instructions { + extras_map.insert("instructions".into(), Value::String(instructions)); + } + if let Some(text) = typed_params.text { + extras_map.insert("text".into(), text); + } + if let Some(truncation) = typed_params.truncation { + extras_map.insert("truncation".into(), truncation); + } + if let Some(user) = typed_params.user { + extras_map.insert("user".into(), Value::String(user)); + } + if let Some(safety_identifier) = typed_params.safety_identifier { + extras_map.insert("safety_identifier".into(), Value::String(safety_identifier)); + } + if let Some(v) = typed_params.max_output_tokens { + extras_map.insert("max_output_tokens".into(), Value::Number(v.into())); + } + if let Some(moderation) = typed_params.moderation { + extras_map.insert("moderation".into(), moderation); + } + if !extras_map.is_empty() { params.extras.insert(ProviderFormat::Responses, extras_map); } From 0327c1497a380c8c00f9351828c679a57a95c7b1 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sat, 11 Jul 2026 23:33:04 -0700 Subject: [PATCH 3/9] fix custom tool calls --- crates/lingua/src/processing/transform.rs | 1 + .../lingua/src/providers/anthropic/adapter.rs | 2 + .../lingua/src/providers/bedrock/adapter.rs | 1 + crates/lingua/src/providers/google/adapter.rs | 1 + .../src/providers/openai/responses_adapter.rs | 321 ++++++++++++++---- crates/lingua/src/universal/stream.rs | 2 + ...ditional-tools-custom-tool.assertions.json | 15 + ...es-additional-tools-custom-tool.spans.json | 52 +++ 8 files changed, 327 insertions(+), 68 deletions(-) create mode 100644 payloads/import-cases/openai-responses-additional-tools-custom-tool.assertions.json create mode 100644 payloads/import-cases/openai-responses-additional-tools-custom-tool.spans.json diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index fa6c7c224..9d2e394c2 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -694,6 +694,7 @@ fn assistant_content_to_stream_delta(content: &AssistantContent) -> UniversalStr index: Some(tool_call_index), id: Some(tool_call_id.clone()), call_type: Some("function".to_string()), + custom_tool_call: None, function: Some(UniversalToolFunctionDelta { name: Some(tool_name.clone()), arguments: Some(arguments.to_string()), diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index f29c21da5..00c0455f2 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -1045,6 +1045,7 @@ impl ProviderAdapter for AnthropicAdapter { index: Some(0), id: part.id, call_type: Some("function".to_string()), + custom_tool_call: None, function: Some(UniversalToolFunctionDelta { name: part.name, arguments: Some(arguments), @@ -1108,6 +1109,7 @@ impl ProviderAdapter for AnthropicAdapter { index: Some(block_index), id: Some(id.to_string()), call_type: Some("function".to_string()), + custom_tool_call: None, function: Some(UniversalToolFunctionDelta { name: Some(name.to_string()), arguments: Some(String::new()), diff --git a/crates/lingua/src/providers/bedrock/adapter.rs b/crates/lingua/src/providers/bedrock/adapter.rs index 369511401..d7b923130 100644 --- a/crates/lingua/src/providers/bedrock/adapter.rs +++ b/crates/lingua/src/providers/bedrock/adapter.rs @@ -424,6 +424,7 @@ impl ProviderAdapter for BedrockAdapter { index: Some(content_block_start.content_block_index), id: Some(tool_use.tool_use_id), call_type: Some("function".to_string()), + custom_tool_call: None, function: Some(UniversalToolFunctionDelta { name: Some(tool_use.name), arguments: Some(String::new()), diff --git a/crates/lingua/src/providers/google/adapter.rs b/crates/lingua/src/providers/google/adapter.rs index 1da21a3c5..9f440ca6c 100644 --- a/crates/lingua/src/providers/google/adapter.rs +++ b/crates/lingua/src/providers/google/adapter.rs @@ -642,6 +642,7 @@ impl ProviderAdapter for GoogleAdapter { }) }), call_type: Some("function".to_string()), + custom_tool_call: None, function: Some(UniversalToolFunctionDelta { name: function_call.name.clone(), arguments: function_call diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index 3a94773a2..e2fe3643a 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -119,17 +119,31 @@ pub(crate) fn responses_stream_events_from_universal_with_output_index_offset( .and_then(|f| f.name.as_deref()) .unwrap_or(""); if let Some(call_id) = call_id { - events.push(serde_json::json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": { - "type": "function_call", - "status": "in_progress", - "call_id": call_id, - "name": name, - "arguments": "" - } - })); + if tool_call.custom_tool_call == Some(true) { + events.push(serde_json::json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "type": "custom_tool_call", + "status": "in_progress", + "call_id": call_id, + "name": name, + "input": "" + } + })); + } else { + events.push(serde_json::json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "type": "function_call", + "status": "in_progress", + "call_id": call_id, + "name": name, + "arguments": "" + } + })); + } } if let Some(arguments) = tool_call @@ -138,11 +152,19 @@ pub(crate) fn responses_stream_events_from_universal_with_output_index_offset( .and_then(|f| f.arguments.as_deref()) .filter(|arguments| !arguments.is_empty()) { - events.push(serde_json::json!({ - "type": "response.function_call_arguments.delta", - "output_index": output_index, - "delta": arguments - })); + if tool_call.custom_tool_call == Some(true) { + events.push(serde_json::json!({ + "type": "response.custom_tool_call_input.delta", + "output_index": output_index, + "delta": arguments + })); + } else { + events.push(serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": output_index, + "delta": arguments + })); + } } } } @@ -219,16 +241,121 @@ fn responses_terminal_stream_event(chunk: &UniversalStreamChunk) -> Value { #[derive(Debug, Deserialize, Default)] struct ResponsesOutputItemAddedEvent { - item: Option, + item: Option, output_index: Option, } +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum ResponsesOutputItemAddedItem { + #[serde(rename = "function_call")] + FunctionCall { + call_id: Option, + name: Option, + }, + #[serde(rename = "custom_tool_call")] + CustomToolCall { + call_id: Option, + name: Option, + }, + #[serde(other)] + Other, +} + +impl Default for ResponsesOutputItemAddedItem { + fn default() -> Self { + Self::Other + } +} + +impl ResponsesOutputItemAddedItem { + fn tool_call_start(&self) -> Option<(&str, &str, bool)> { + match self { + Self::FunctionCall { call_id, name } => Some(( + call_id.as_deref().unwrap_or(""), + name.as_deref().unwrap_or(""), + false, + )), + Self::CustomToolCall { call_id, name } => Some(( + call_id.as_deref().unwrap_or(""), + name.as_deref().unwrap_or(""), + true, + )), + Self::Other => None, + } + } +} + #[derive(Debug, Deserialize, Default)] struct ResponsesFunctionCallArgumentsDeltaEvent { delta: Option, output_index: Option, } +#[derive(Debug, Deserialize, Default)] +struct ResponsesCustomToolCallInputDeltaEvent { + delta: Option, + output_index: Option, +} + +fn responses_tool_call_start_chunk( + call_id: &str, + name: &str, + output_index: u32, + custom_tool_call: bool, +) -> UniversalStreamChunk { + UniversalStreamChunk::new( + None, + None, + vec![UniversalStreamChoice { + index: 0, + delta: Some(serde_json::json!({ + "role": "assistant", + "content": Value::Null, + "tool_calls": [{ + "index": output_index, + "id": call_id, + "type": "function", + "custom_tool_call": custom_tool_call, + "function": { + "name": name, + "arguments": "" + } + }] + })), + finish_reason: None, + }], + None, + None, + ) +} + +fn responses_tool_call_arguments_delta_chunk( + arguments: String, + output_index: u32, + custom_tool_call: bool, +) -> UniversalStreamChunk { + UniversalStreamChunk::new( + None, + None, + vec![UniversalStreamChoice { + index: 0, + delta: Some(serde_json::json!({ + "tool_calls": [{ + "index": output_index, + "custom_tool_call": custom_tool_call, + "function": { + "arguments": arguments + } + }] + })), + finish_reason: None, + }], + None, + None, + ) +} + #[derive(Debug, Deserialize, Default)] struct ResponsesReasoningTextDeltaEvent { delta: Option, @@ -1002,45 +1129,21 @@ impl ProviderAdapter for ResponsesAdapter { let parsed = serde_json::from_value::(payload.clone()) .unwrap_or_default(); - let item = parsed.item.as_ref(); - let item_type = item.and_then(|i| i.get("type")).and_then(Value::as_str); - - if item_type == Some("function_call") { - let call_id = item - .and_then(|i| i.get("call_id")) - .and_then(Value::as_str) - .unwrap_or(""); - let name = item - .and_then(|i| i.get("name")) - .and_then(Value::as_str) - .unwrap_or(""); + if let Some((call_id, name, custom_tool_call)) = parsed + .item + .as_ref() + .and_then(ResponsesOutputItemAddedItem::tool_call_start) + { let output_index = parsed.output_index.unwrap_or(0); // Preserve Responses output_index as a correlation key. Stateful // stream transforms remap it to a tool-relative index before // serializing to non-Responses targets. - return Ok(Some(UniversalStreamChunk::new( - None, - None, - vec![UniversalStreamChoice { - index: 0, - delta: Some(serde_json::json!({ - "role": "assistant", - "content": Value::Null, - "tool_calls": [{ - "index": output_index, - "id": call_id, - "type": "function", - "function": { - "name": name, - "arguments": "" - } - }] - })), - finish_reason: None, - }], - None, - None, + return Ok(Some(responses_tool_call_start_chunk( + call_id, + name, + output_index, + custom_tool_call, ))); } @@ -1058,23 +1161,25 @@ impl ProviderAdapter for ResponsesAdapter { // Preserve Responses output_index as a correlation key. Stateful // stream transforms remap it to the same tool-relative index as // the corresponding response.output_item.added event. - Ok(Some(UniversalStreamChunk::new( - None, - None, - vec![UniversalStreamChoice { - index: 0, - delta: Some(serde_json::json!({ - "tool_calls": [{ - "index": output_index, - "function": { - "arguments": arguments - } - }] - })), - finish_reason: None, - }], - None, - None, + Ok(Some(responses_tool_call_arguments_delta_chunk( + arguments, + output_index, + false, + ))) + } + + "response.custom_tool_call_input.delta" => { + let parsed = serde_json::from_value::( + payload.clone(), + ) + .unwrap_or_default(); + let arguments = parsed.delta.unwrap_or_default(); + let output_index = parsed.output_index.unwrap_or(0); + + Ok(Some(responses_tool_call_arguments_delta_chunk( + arguments, + output_index, + true, ))) } @@ -2456,6 +2561,86 @@ mod tests { ); } + #[test] + fn test_responses_stream_custom_tool_call_roundtrips() { + let adapter = ResponsesAdapter; + let custom_tool_start = json!({ + "type": "response.output_item.added", + "item": { + "type": "custom_tool_call", + "status": "in_progress", + "call_id": "call_exec", + "input": "", + "name": "exec" + }, + "output_index": 7 + }); + let start_chunk = adapter + .stream_to_universal(custom_tool_start.clone()) + .expect("custom tool start should parse") + .expect("custom tool start should produce a chunk"); + assert!(!start_chunk.is_keep_alive()); + let start_delta = start_chunk + .choices + .first() + .expect("custom tool start should have a choice") + .delta_view() + .expect("custom tool start delta should parse"); + let start_tool_call = start_delta + .tool_calls + .first() + .expect("custom tool start should emit a tool call"); + assert_eq!(start_tool_call.index, Some(7)); + assert_eq!(start_tool_call.id.as_deref(), Some("call_exec")); + assert_eq!(start_tool_call.call_type.as_deref(), Some("function")); + assert_eq!(start_tool_call.custom_tool_call, Some(true)); + assert_eq!( + start_tool_call + .function + .as_ref() + .and_then(|function| function.name.as_deref()), + Some("exec") + ); + assert_eq!( + responses_stream_events_from_universal(&start_chunk), + vec![custom_tool_start] + ); + + let custom_tool_delta = json!({ + "type": "response.custom_tool_call_input.delta", + "delta": "await tools.exec_command({cmd: \"true\"});", + "output_index": 7 + }); + let delta_chunk = adapter + .stream_to_universal(custom_tool_delta.clone()) + .expect("custom tool delta should parse") + .expect("custom tool delta should produce a chunk"); + assert!(!delta_chunk.is_keep_alive()); + let delta = delta_chunk + .choices + .first() + .expect("custom tool delta should have a choice") + .delta_view() + .expect("custom tool delta should parse"); + let delta_tool_call = delta + .tool_calls + .first() + .expect("custom tool delta should emit tool call input"); + assert_eq!(delta_tool_call.index, Some(7)); + assert_eq!(delta_tool_call.custom_tool_call, Some(true)); + assert_eq!( + delta_tool_call + .function + .as_ref() + .and_then(|function| function.arguments.as_deref()), + Some("await tools.exec_command({cmd: \"true\"});") + ); + assert_eq!( + responses_stream_events_from_universal(&delta_chunk), + vec![custom_tool_delta] + ); + } + #[test] fn test_responses_stream_from_universal_reasoning_only_is_not_metadata() { #[derive(Deserialize)] diff --git a/crates/lingua/src/universal/stream.rs b/crates/lingua/src/universal/stream.rs index 59caf2c44..e0bd2e882 100644 --- a/crates/lingua/src/universal/stream.rs +++ b/crates/lingua/src/universal/stream.rs @@ -50,6 +50,8 @@ pub struct UniversalToolCallDelta { #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] pub call_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_tool_call: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub function: Option, } diff --git a/payloads/import-cases/openai-responses-additional-tools-custom-tool.assertions.json b/payloads/import-cases/openai-responses-additional-tools-custom-tool.assertions.json new file mode 100644 index 000000000..86866ceef --- /dev/null +++ b/payloads/import-cases/openai-responses-additional-tools-custom-tool.assertions.json @@ -0,0 +1,15 @@ +{ + "expectedMessageCount": 5, + "expectedRolesInOrder": [ + "additional_tools", + "user", + "assistant", + "tool", + "assistant" + ], + "mustContainText": [ + "Run a query and save the result to a file.", + "exec", + "saved /tmp/query-result.txt" + ] +} diff --git a/payloads/import-cases/openai-responses-additional-tools-custom-tool.spans.json b/payloads/import-cases/openai-responses-additional-tools-custom-tool.spans.json new file mode 100644 index 000000000..c46541d14 --- /dev/null +++ b/payloads/import-cases/openai-responses-additional-tools-custom-tool.spans.json @@ -0,0 +1,52 @@ +[ + { + "input": [ + { + "id": "at_code_mode", + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Runs JavaScript code to orchestrate tool calls.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: SOURCE\nSOURCE: /[\\s\\S]+/" + } + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Run a query and save the result to a file." + } + ] + }, + { + "type": "custom_tool_call", + "call_id": "call_exec_1", + "name": "exec", + "input": "const rows = await tools.braintrust__sql_query({sql: \"select 1 as value\"});\nawait tools.exec_command({cmd: \"printf '%s\\n' done > /tmp/query-result.txt\"});" + }, + { + "type": "custom_tool_call_output", + "call_id": "call_exec_1", + "output": "saved /tmp/query-result.txt" + } + ], + "output": [ + { + "type": "custom_tool_call", + "call_id": "call_exec_2", + "name": "exec", + "input": "await tools.exec_command({cmd: \"wc -l /tmp/query-result.txt\"});" + } + ] + } +] From dcd14caca1d1c7400ebc54fc373bfb630d468a60 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sun, 12 Jul 2026 08:30:17 -0700 Subject: [PATCH 4/9] remove reasoning stuff --- .../tests/provider_roundtrip_test.rs | 21 -------- crates/lingua/src/providers/openai/params.rs | 53 +------------------ .../src/providers/openai/responses_adapter.rs | 46 +++++----------- crates/lingua/src/universal/reasoning.rs | 1 - ...ses-reasoning-provider-extras-request.json | 17 ------ 5 files changed, 15 insertions(+), 123 deletions(-) delete mode 100644 crates/coverage-report/tests/provider_roundtrip_test.rs delete mode 100644 payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json diff --git a/crates/coverage-report/tests/provider_roundtrip_test.rs b/crates/coverage-report/tests/provider_roundtrip_test.rs deleted file mode 100644 index 56c9936b6..000000000 --- a/crates/coverage-report/tests/provider_roundtrip_test.rs +++ /dev/null @@ -1,21 +0,0 @@ -use lingua::processing::adapters::ProviderAdapter; -use lingua::providers::openai::ResponsesAdapter; -use lingua::serde_json::{self, Value}; - -#[test] -fn responses_request_provider_roundtrip_preserves_reasoning_provider_fields() { - let original: Value = serde_json::from_slice(include_bytes!( - "../../../payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json" - )) - .expect("fixture should parse"); - - let adapter = ResponsesAdapter; - let universal = adapter - .request_to_universal(original.clone()) - .expect("fixture should convert to universal"); - let reconstructed = adapter - .request_from_universal(&universal) - .expect("fixture should convert back to Responses"); - - assert_eq!(reconstructed, original); -} diff --git a/crates/lingua/src/providers/openai/params.rs b/crates/lingua/src/providers/openai/params.rs index 5a7ecaa87..c4b953d6d 100644 --- a/crates/lingua/src/providers/openai/params.rs +++ b/crates/lingua/src/providers/openai/params.rs @@ -6,7 +6,7 @@ eliminating the need for explicit KNOWN_KEYS arrays. */ use crate::providers::openai::generated::{ChatCompletionRequestMessage, Instructions, Summary}; -use crate::serde_json::{Map, Value}; +use crate::serde_json::Value; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -150,28 +150,6 @@ pub struct OpenAIResponsesParams { pub extras: BTreeMap, } -impl OpenAIResponsesParams { - pub(crate) fn provider_only_reasoning(&self) -> Option { - self.reasoning - .as_ref() - .and_then(OpenAIReasoning::provider_only_value) - } - - pub(crate) fn extras_map(&self) -> Map { - let mut extras = self - .extras - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - if let Some(reasoning) = self.provider_only_reasoning() { - extras.insert("reasoning".into(), reasoning); - } - - extras - } -} - /// Typed view over `UniversalParams.extras[ChatCompletions]` used during /// universal -> OpenAI Chat reconstruction. /// @@ -261,35 +239,6 @@ pub struct OpenAIReasoning { pub summary: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generate_summary: Option, - #[serde(flatten)] - pub extras: BTreeMap, -} - -impl OpenAIReasoning { - pub(crate) fn has_reconstructed_fields(&self) -> bool { - self.effort.is_some() || self.summary.is_some() || self.generate_summary.is_some() - } - - fn provider_only_value(&self) -> Option { - let mut provider_only = self - .extras - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - if let Some(mode) = self.mode.as_ref() { - provider_only.insert("mode".into(), mode.clone()); - } - if let Some(context) = self.context.as_ref() { - provider_only.insert("context".into(), context.clone()); - } - - if provider_only.is_empty() { - return None; - } - - Some(Value::Object(provider_only)) - } } #[cfg(test)] diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index 3346232d9..e1b876bb0 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -389,24 +389,6 @@ pub(crate) fn parse_responses_extras( .map(|v: Option| v.unwrap_or_default()) } -fn merge_reasoning_values( - canonical: Option, - provider_only: Option<&Value>, -) -> Option { - match (canonical, provider_only) { - (Some(Value::Object(mut canonical)), Some(Value::Object(provider_only))) => { - for (key, value) in provider_only { - canonical.insert(key.clone(), value.clone()); - } - Some(Value::Object(canonical)) - } - (Some(_), Some(provider_only)) => Some(provider_only.clone()), - (None, Some(provider_only)) => Some(provider_only.clone()), - (Some(canonical), None) => Some(canonical), - (None, None) => None, - } -} - impl ProviderAdapter for ResponsesAdapter { fn format(&self) -> ProviderFormat { ProviderFormat::Responses @@ -428,7 +410,6 @@ impl ProviderAdapter for ResponsesAdapter { // Single parse: typed params now includes typed input via #[serde(flatten)] let typed_params: OpenAIResponsesParams = serde_json::from_value(payload) .map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?; - let mut extras_map: Map = typed_params.extras_map(); // Extract input items from typed_params.input (partial move - other fields remain accessible) let input_items: Vec = match typed_params.input { @@ -493,7 +474,6 @@ impl ProviderAdapter for ResponsesAdapter { let reasoning = typed_params .reasoning .as_ref() - .filter(|r| r.has_reconstructed_fields()) .map(|r| (r, max_tokens).into()); let canonical_metadata = typed_params.metadata.clone().or_else(|| { @@ -539,7 +519,12 @@ impl ProviderAdapter for ResponsesAdapter { extras: Default::default(), }; - // Collect provider-specific extras for round-trip preservation. + // Collect provider-specific extras for round-trip preservation + // This includes both unknown fields (from serde flatten) and known Responses API fields + // that aren't part of UniversalParams + let mut extras_map: Map = typed_params.extras.into_iter().collect(); + + // Add Responses API specific known fields that aren't in UniversalParams if let Some(instructions) = typed_params.instructions { extras_map.insert("instructions".into(), Value::String(instructions)); } @@ -742,24 +727,21 @@ impl ProviderAdapter for ResponsesAdapter { obj.insert("text".into(), text_val); } - // Add reasoning from canonical params and merge provider-only Responses fields. - let canonical_reasoning = if let Some(reasoning) = req.params.reasoning.as_ref() { + // Add reasoning from canonical params + if let Some(raw_reasoning) = responses_extras_view.reasoning.as_ref() { + obj.insert("reasoning".into(), raw_reasoning.clone()); + } else if let Some(reasoning) = req.params.reasoning.as_ref() { let mut reasoning = reasoning.clone(); if let Some(effort) = reasoning.effort { reasoning.effort = Some(clamp_reasoning_effort_for_model(model, effort)); } - reasoning + if let Some(reasoning_val) = reasoning .to_provider(ProviderFormat::Responses, req.params.output_token_budget()) .ok() .flatten() - } else { - None - }; - if let Some(reasoning) = merge_reasoning_values( - canonical_reasoning, - responses_extras_view.reasoning.as_ref(), - ) { - obj.insert("reasoning".into(), reasoning); + { + obj.insert("reasoning".into(), reasoning_val); + } } if let Some(raw_moderation) = responses_extras_view .moderation diff --git a/crates/lingua/src/universal/reasoning.rs b/crates/lingua/src/universal/reasoning.rs index 68a3529f6..39782a687 100644 --- a/crates/lingua/src/universal/reasoning.rs +++ b/crates/lingua/src/universal/reasoning.rs @@ -694,7 +694,6 @@ mod tests { context: None, summary: Some(OpenAISummary::Detailed), generate_summary: None, - extras: Default::default(), }; // Test fallback conversion (uses DEFAULT_MAX_TOKENS) diff --git a/payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json b/payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json deleted file mode 100644 index e8e2aa8ab..000000000 --- a/payloads/provider-roundtrip/responses-reasoning-provider-extras-request.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "model": "gpt-5.6-sol", - "input": [ - { - "type": "message", - "role": "user", - "content": "Use the available project tools." - } - ], - "reasoning": { - "effort": "medium", - "context": "all_turns", - "provider_roundtrip_marker": { - "preserve": true - } - } -} From e6aa2d3a727c1867daf74a0e3f1de68ea612930f Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sun, 12 Jul 2026 09:40:13 -0700 Subject: [PATCH 5/9] Fix Responses stream tool-call marker --- .../src/providers/openai/responses_adapter.rs | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index e1b876bb0..dea1a97a9 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -34,7 +34,8 @@ use crate::universal::tools::tools_to_responses_value; use crate::universal::{ ConversationReference, ConversationReferenceType, FinishReason, TokenBudget, UniversalParams, UniversalRequest, UniversalResponse, UniversalStreamChoice, UniversalStreamChunk, - UniversalUsage, PLACEHOLDER_ID, PLACEHOLDER_MODEL, + UniversalToolCallDelta, UniversalToolFunctionDelta, UniversalUsage, PLACEHOLDER_ID, + PLACEHOLDER_MODEL, }; use serde::Deserialize; use std::convert::TryInto; @@ -248,7 +249,7 @@ struct ResponsesOutputItemAddedEvent { output_index: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Default)] #[serde(tag = "type")] enum ResponsesOutputItemAddedItem { #[serde(rename = "function_call")] @@ -262,15 +263,10 @@ enum ResponsesOutputItemAddedItem { name: Option, }, #[serde(other)] + #[default] Other, } -impl Default for ResponsesOutputItemAddedItem { - fn default() -> Self { - Self::Other - } -} - impl ResponsesOutputItemAddedItem { fn tool_call_start(&self) -> Option<(&str, &str, bool)> { match self { @@ -315,15 +311,15 @@ fn responses_tool_call_start_chunk( delta: Some(serde_json::json!({ "role": "assistant", "content": Value::Null, - "tool_calls": [{ - "index": output_index, - "id": call_id, - "type": "function", - "custom_tool_call": custom_tool_call, - "function": { - "name": name, - "arguments": "" - } + "tool_calls": [UniversalToolCallDelta { + index: Some(output_index), + id: Some(call_id.to_string()), + call_type: Some("function".to_string()), + custom_tool_call: custom_tool_call.then_some(true), + function: Some(UniversalToolFunctionDelta { + name: Some(name.to_string()), + arguments: Some(String::new()), + }), }] })), finish_reason: None, @@ -344,12 +340,15 @@ fn responses_tool_call_arguments_delta_chunk( vec![UniversalStreamChoice { index: 0, delta: Some(serde_json::json!({ - "tool_calls": [{ - "index": output_index, - "custom_tool_call": custom_tool_call, - "function": { - "arguments": arguments - } + "tool_calls": [UniversalToolCallDelta { + index: Some(output_index), + id: None, + call_type: None, + custom_tool_call: custom_tool_call.then_some(true), + function: Some(UniversalToolFunctionDelta { + name: None, + arguments: Some(arguments), + }), }] })), finish_reason: None, From 92adcc97561f0b0e5a06ef6fc4ec9efe76386308 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sun, 12 Jul 2026 10:08:40 -0700 Subject: [PATCH 6/9] Omit internal custom tool marker from chat streams --- crates/lingua/src/providers/openai/adapter.rs | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/lingua/src/providers/openai/adapter.rs b/crates/lingua/src/providers/openai/adapter.rs index b36ba6bc4..90fd712ed 100644 --- a/crates/lingua/src/providers/openai/adapter.rs +++ b/crates/lingua/src/providers/openai/adapter.rs @@ -733,7 +733,10 @@ impl ProviderAdapter for OpenAIAdapter { choice_map.insert("index".into(), serde_json::json!(c.index)); choice_map.insert( "delta".into(), - c.delta.clone().unwrap_or(Value::Object(Map::new())), + c.delta + .clone() + .map(chat_stream_delta_from_universal) + .unwrap_or(Value::Object(Map::new())), ); let finish_reason_val = match &c.finish_reason { Some(reason) => Value::String(reason.clone()), @@ -771,6 +774,22 @@ impl ProviderAdapter for OpenAIAdapter { } } +fn chat_stream_delta_from_universal(mut delta: Value) -> Value { + if let Some(tool_calls) = delta + .as_object_mut() + .and_then(|delta| delta.get_mut("tool_calls")) + .and_then(Value::as_array_mut) + { + for tool_call in tool_calls { + if let Some(tool_call) = tool_call.as_object_mut() { + tool_call.remove("custom_tool_call"); + } + } + } + + delta +} + // ============================================================================= // Helper Functions // ============================================================================= @@ -918,6 +937,61 @@ mod tests { assert_eq!(value["prompt_cache_key"], json!("cache-key-1")); } + #[test] + fn test_openai_stream_from_universal_omits_custom_tool_call_marker() { + let adapter = OpenAIAdapter; + let chunk = UniversalStreamChunk::new( + None, + None, + vec![UniversalStreamChoice { + index: 0, + delta: Some(json!({ + "role": "assistant", + "content": null, + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "custom_tool_call": true, + "function": { + "name": "exec", + "arguments": "" + } + }] + })), + finish_reason: None, + }], + None, + None, + ); + + let value = adapter.stream_from_universal(&chunk).unwrap(); + + assert_eq!( + value, + json!({ + "object": "chat.completion.chunk", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "index": 0, + "id": "call_1", + "type": "function", + "function": { + "name": "exec", + "arguments": "" + } + }] + }, + "finish_reason": null + }] + }) + ); + } + #[test] fn test_openai_prompt_cache_key_canonical_value_overrides_stale_extra() { let adapter = OpenAIAdapter; From 7114252e1d473b780f9368120a2c8b50f7f057ef Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sun, 12 Jul 2026 10:18:34 -0700 Subject: [PATCH 7/9] Preserve custom tool marker in response stream fallback --- crates/lingua/src/processing/transform.rs | 47 ++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index de64b6317..455b9f90a 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -22,10 +22,10 @@ use crate::providers::openai::model_needs_transforms; use crate::serde_json; use crate::serde_json::Value; use crate::universal::{ - AssistantContent, AssistantContentPart, Message, TextContentPart, UniversalReasoningDelta, - UniversalRequest, UniversalResponse, UniversalStreamChoice, UniversalStreamChunk, - UniversalStreamDelta, UniversalToolCallDelta, UniversalToolFunctionDelta, UserContent, - UserContentPart, + AssistantContent, AssistantContentPart, Message, TextContentPart, ToolCallArguments, + UniversalReasoningDelta, UniversalRequest, UniversalResponse, UniversalStreamChoice, + UniversalStreamChunk, UniversalStreamDelta, UniversalToolCallDelta, UniversalToolFunctionDelta, + UserContent, UserContentPart, }; use serde::de::DeserializeOwned; use thiserror::Error; @@ -694,7 +694,8 @@ fn assistant_content_to_stream_delta(content: &AssistantContent) -> UniversalStr index: Some(tool_call_index), id: Some(tool_call_id.clone()), call_type: Some("function".to_string()), - custom_tool_call: None, + custom_tool_call: matches!(arguments, ToolCallArguments::Custom(_)) + .then_some(true), function: Some(UniversalToolFunctionDelta { name: Some(tool_name.clone()), arguments: Some(arguments.to_string()), @@ -2072,6 +2073,42 @@ mod tests { ); } + #[test] + #[cfg(feature = "openai")] + fn test_response_to_stream_chunk_preserves_responses_custom_tool_call() { + let response = UniversalResponse { + id: None, + id_format: None, + model: Some("gpt-5.6-terra".to_string()), + messages: vec![Message::Assistant { + id: None, + content: AssistantContent::Array(vec![AssistantContentPart::ToolCall { + tool_call_id: "call_custom".to_string(), + tool_name: "exec".to_string(), + arguments: ToolCallArguments::Custom("await tools.exec();".to_string()), + status: None, + caller: None, + encrypted_content: None, + provider_options: None, + provider_executed: None, + }]), + }], + usage: None, + finish_reason: None, + }; + + let chunk = response_to_stream_chunk(response); + let output = crate::providers::openai::responses_adapter::ResponsesAdapter + .stream_from_universal(&chunk) + .unwrap(); + + assert_eq!(output["type"], json!("response.output_item.added")); + assert_eq!(output["item"]["type"], json!("custom_tool_call")); + assert_eq!(output["item"]["input"], json!("")); + assert_eq!(output["item"]["call_id"], json!("call_custom")); + assert_eq!(output["item"]["name"], json!("exec")); + } + #[test] #[cfg(feature = "openai")] fn test_transform_response_passthrough() { From 027a65de9ed05755f7030ff496817726fa673631 Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Sun, 12 Jul 2026 11:11:50 -0700 Subject: [PATCH 8/9] Reject malformed Responses custom tool deltas --- .../src/providers/openai/responses_adapter.rs | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index dea1a97a9..ba4531680 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -291,10 +291,14 @@ struct ResponsesFunctionCallArgumentsDeltaEvent { output_index: Option, } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize)] struct ResponsesCustomToolCallInputDeltaEvent { - delta: Option, - output_index: Option, + delta: String, + output_index: u32, + #[serde(rename = "item_id")] + _item_id: String, + #[serde(rename = "sequence_number")] + _sequence_number: u32, } fn responses_tool_call_start_chunk( @@ -1205,13 +1209,16 @@ impl ProviderAdapter for ResponsesAdapter { let parsed = serde_json::from_value::( payload.clone(), ) - .unwrap_or_default(); - let arguments = parsed.delta.unwrap_or_default(); - let output_index = parsed.output_index.unwrap_or(0); + .map_err(|e| { + TransformError::DeserializationFailed(format!( + "Responses custom tool call input delta event: {}", + e + )) + })?; Ok(Some(responses_tool_call_arguments_delta_chunk( - arguments, - output_index, + parsed.delta, + parsed.output_index, true, ))) } @@ -2998,6 +3005,8 @@ mod tests { let custom_tool_delta = json!({ "type": "response.custom_tool_call_input.delta", "delta": "await tools.exec_command({cmd: \"true\"});", + "item_id": "ctc_exec", + "sequence_number": 8, "output_index": 7 }); let delta_chunk = adapter @@ -3024,12 +3033,33 @@ mod tests { .and_then(|function| function.arguments.as_deref()), Some("await tools.exec_command({cmd: \"true\"});") ); + let expected_custom_tool_delta = json!({ + "type": "response.custom_tool_call_input.delta", + "delta": "await tools.exec_command({cmd: \"true\"});", + "output_index": 7 + }); assert_eq!( responses_stream_events_from_universal(&delta_chunk), - vec![custom_tool_delta] + vec![expected_custom_tool_delta] ); } + #[test] + fn test_responses_stream_custom_tool_call_delta_rejects_missing_required_fields() { + let adapter = ResponsesAdapter; + let err = adapter + .stream_to_universal(json!({ + "type": "response.custom_tool_call_input.delta", + "delta": "malformed" + })) + .expect_err("malformed custom tool input delta should fail"); + + assert!(matches!(err, TransformError::DeserializationFailed(_))); + assert!(err + .to_string() + .contains("Responses custom tool call input delta event")); + } + #[test] fn test_responses_stream_from_universal_reasoning_only_is_not_metadata() { #[derive(Deserialize)] From 61a652051417fc2d6a5c0ba1fa797cc484319b5e Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Mon, 13 Jul 2026 11:55:15 -0700 Subject: [PATCH 9/9] improve streaming types --- crates/lingua/src/processing/transform.rs | 1 + .../lingua/src/providers/anthropic/adapter.rs | 2 + .../lingua/src/providers/bedrock/adapter.rs | 1 + crates/lingua/src/providers/google/adapter.rs | 1 + crates/lingua/src/providers/openai/adapter.rs | 4 + .../src/providers/openai/responses_adapter.rs | 160 ++++++++++++++---- crates/lingua/src/universal/stream.rs | 4 + 7 files changed, 137 insertions(+), 36 deletions(-) diff --git a/crates/lingua/src/processing/transform.rs b/crates/lingua/src/processing/transform.rs index 455b9f90a..3138c92d7 100644 --- a/crates/lingua/src/processing/transform.rs +++ b/crates/lingua/src/processing/transform.rs @@ -700,6 +700,7 @@ fn assistant_content_to_stream_delta(content: &AssistantContent) -> UniversalStr name: Some(tool_name.clone()), arguments: Some(arguments.to_string()), }), + ..Default::default() }); } AssistantContentPart::File { .. } diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index a44d878a5..21eb99ad0 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -1051,6 +1051,7 @@ impl ProviderAdapter for AnthropicAdapter { name: part.name, arguments: Some(arguments), }), + ..Default::default() }], ..Default::default() }) @@ -1115,6 +1116,7 @@ impl ProviderAdapter for AnthropicAdapter { name: Some(name.to_string()), arguments: Some(String::new()), }), + ..Default::default() }], ..Default::default() })), diff --git a/crates/lingua/src/providers/bedrock/adapter.rs b/crates/lingua/src/providers/bedrock/adapter.rs index d7b923130..c00fe32f3 100644 --- a/crates/lingua/src/providers/bedrock/adapter.rs +++ b/crates/lingua/src/providers/bedrock/adapter.rs @@ -429,6 +429,7 @@ impl ProviderAdapter for BedrockAdapter { name: Some(tool_use.name), arguments: Some(String::new()), }), + ..Default::default() }], ..Default::default() })), diff --git a/crates/lingua/src/providers/google/adapter.rs b/crates/lingua/src/providers/google/adapter.rs index b82f0e0dd..2ced356b5 100644 --- a/crates/lingua/src/providers/google/adapter.rs +++ b/crates/lingua/src/providers/google/adapter.rs @@ -650,6 +650,7 @@ impl ProviderAdapter for GoogleAdapter { .as_ref() .map(|args| Value::Object(args.clone()).to_string()), }), + ..Default::default() } }) }) diff --git a/crates/lingua/src/providers/openai/adapter.rs b/crates/lingua/src/providers/openai/adapter.rs index 90fd712ed..dad495f0e 100644 --- a/crates/lingua/src/providers/openai/adapter.rs +++ b/crates/lingua/src/providers/openai/adapter.rs @@ -783,6 +783,8 @@ fn chat_stream_delta_from_universal(mut delta: Value) -> Value { for tool_call in tool_calls { if let Some(tool_call) = tool_call.as_object_mut() { tool_call.remove("custom_tool_call"); + tool_call.remove("item_id"); + tool_call.remove("sequence_number"); } } } @@ -953,6 +955,8 @@ mod tests { "id": "call_1", "type": "function", "custom_tool_call": true, + "item_id": "ctc_1", + "sequence_number": 8, "function": { "name": "exec", "arguments": "" diff --git a/crates/lingua/src/providers/openai/responses_adapter.rs b/crates/lingua/src/providers/openai/responses_adapter.rs index ba4531680..fe37d3b50 100644 --- a/crates/lingua/src/providers/openai/responses_adapter.rs +++ b/crates/lingua/src/providers/openai/responses_adapter.rs @@ -37,7 +37,7 @@ use crate::universal::{ UniversalToolCallDelta, UniversalToolFunctionDelta, UniversalUsage, PLACEHOLDER_ID, PLACEHOLDER_MODEL, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::convert::TryInto; const OPENAI_RESPONSES_MIN_MAX_OUTPUT_TOKENS: i64 = 16; @@ -124,17 +124,13 @@ pub(crate) fn responses_stream_events_from_universal_with_output_index_offset( .unwrap_or(""); if let Some(call_id) = call_id { if tool_call.custom_tool_call == Some(true) { - events.push(serde_json::json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": { - "type": "custom_tool_call", - "status": "in_progress", - "call_id": call_id, - "name": name, - "input": "" - } - })); + events.push(responses_output_item_added_custom_tool_call_event( + output_index, + tool_call.sequence_number, + tool_call.item_id.as_deref(), + call_id, + name, + )); } else { events.push(serde_json::json!({ "type": "response.output_item.added", @@ -157,11 +153,12 @@ pub(crate) fn responses_stream_events_from_universal_with_output_index_offset( .filter(|arguments| !arguments.is_empty()) { if tool_call.custom_tool_call == Some(true) { - events.push(serde_json::json!({ - "type": "response.custom_tool_call_input.delta", - "output_index": output_index, - "delta": arguments - })); + events.push(responses_custom_tool_call_input_delta_event( + output_index, + tool_call.sequence_number, + tool_call.item_id.as_deref(), + arguments, + )); } else { events.push(serde_json::json!({ "type": "response.function_call_arguments.delta", @@ -243,10 +240,84 @@ fn responses_terminal_stream_event(chunk: &UniversalStreamChunk) -> Value { }) } +#[derive(Debug, Serialize)] +struct ResponsesOutputItemAddedCustomToolCallEvent<'a> { + #[serde(rename = "type")] + event_type: &'static str, + output_index: u32, + #[serde(skip_serializing_if = "Option::is_none")] + sequence_number: Option, + item: ResponsesCustomToolCallStreamItem<'a>, +} + +#[derive(Debug, Serialize)] +struct ResponsesCustomToolCallStreamItem<'a> { + #[serde(rename = "type")] + item_type: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option<&'a str>, + status: &'static str, + call_id: &'a str, + name: &'a str, + input: &'static str, +} + +fn responses_output_item_added_custom_tool_call_event( + output_index: u32, + sequence_number: Option, + item_id: Option<&str>, + call_id: &str, + name: &str, +) -> Value { + serde_json::to_value(ResponsesOutputItemAddedCustomToolCallEvent { + event_type: "response.output_item.added", + output_index, + sequence_number, + item: ResponsesCustomToolCallStreamItem { + item_type: "custom_tool_call", + id: item_id, + status: "in_progress", + call_id, + name, + input: "", + }, + }) + .expect("Responses custom tool call output item should serialize") +} + +#[derive(Debug, Serialize)] +struct ResponsesCustomToolCallInputDeltaOutputEvent<'a> { + #[serde(rename = "type")] + event_type: &'static str, + output_index: u32, + #[serde(skip_serializing_if = "Option::is_none")] + item_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + sequence_number: Option, + delta: &'a str, +} + +fn responses_custom_tool_call_input_delta_event( + output_index: u32, + sequence_number: Option, + item_id: Option<&str>, + delta: &str, +) -> Value { + serde_json::to_value(ResponsesCustomToolCallInputDeltaOutputEvent { + event_type: "response.custom_tool_call_input.delta", + output_index, + item_id, + sequence_number, + delta, + }) + .expect("Responses custom tool call input delta should serialize") +} + #[derive(Debug, Deserialize, Default)] struct ResponsesOutputItemAddedEvent { item: Option, output_index: Option, + sequence_number: Option, } #[derive(Debug, Deserialize, Default)] @@ -254,11 +325,13 @@ struct ResponsesOutputItemAddedEvent { enum ResponsesOutputItemAddedItem { #[serde(rename = "function_call")] FunctionCall { + id: Option, call_id: Option, name: Option, }, #[serde(rename = "custom_tool_call")] CustomToolCall { + id: Option, call_id: Option, name: Option, }, @@ -268,16 +341,18 @@ enum ResponsesOutputItemAddedItem { } impl ResponsesOutputItemAddedItem { - fn tool_call_start(&self) -> Option<(&str, &str, bool)> { + fn tool_call_start(&self) -> Option<(&str, &str, Option<&str>, bool)> { match self { - Self::FunctionCall { call_id, name } => Some(( + Self::FunctionCall { id, call_id, name } => Some(( call_id.as_deref().unwrap_or(""), name.as_deref().unwrap_or(""), + id.as_deref(), false, )), - Self::CustomToolCall { call_id, name } => Some(( + Self::CustomToolCall { id, call_id, name } => Some(( call_id.as_deref().unwrap_or(""), name.as_deref().unwrap_or(""), + id.as_deref(), true, )), Self::Other => None, @@ -296,9 +371,9 @@ struct ResponsesCustomToolCallInputDeltaEvent { delta: String, output_index: u32, #[serde(rename = "item_id")] - _item_id: String, + item_id: String, #[serde(rename = "sequence_number")] - _sequence_number: u32, + sequence_number: u32, } fn responses_tool_call_start_chunk( @@ -306,6 +381,8 @@ fn responses_tool_call_start_chunk( name: &str, output_index: u32, custom_tool_call: bool, + item_id: Option<&str>, + sequence_number: Option, ) -> UniversalStreamChunk { UniversalStreamChunk::new( None, @@ -320,6 +397,8 @@ fn responses_tool_call_start_chunk( id: Some(call_id.to_string()), call_type: Some("function".to_string()), custom_tool_call: custom_tool_call.then_some(true), + item_id: item_id.map(ToString::to_string), + sequence_number, function: Some(UniversalToolFunctionDelta { name: Some(name.to_string()), arguments: Some(String::new()), @@ -337,6 +416,8 @@ fn responses_tool_call_arguments_delta_chunk( arguments: String, output_index: u32, custom_tool_call: bool, + item_id: Option, + sequence_number: Option, ) -> UniversalStreamChunk { UniversalStreamChunk::new( None, @@ -349,6 +430,8 @@ fn responses_tool_call_arguments_delta_chunk( id: None, call_type: None, custom_tool_call: custom_tool_call.then_some(true), + item_id, + sequence_number, function: Some(UniversalToolFunctionDelta { name: None, arguments: Some(arguments), @@ -1166,7 +1249,7 @@ impl ProviderAdapter for ResponsesAdapter { let parsed = serde_json::from_value::(payload.clone()) .unwrap_or_default(); - if let Some((call_id, name, custom_tool_call)) = parsed + if let Some((call_id, name, item_id, custom_tool_call)) = parsed .item .as_ref() .and_then(ResponsesOutputItemAddedItem::tool_call_start) @@ -1181,6 +1264,8 @@ impl ProviderAdapter for ResponsesAdapter { name, output_index, custom_tool_call, + item_id, + parsed.sequence_number, ))); } @@ -1202,6 +1287,8 @@ impl ProviderAdapter for ResponsesAdapter { arguments, output_index, false, + None, + None, ))) } @@ -1220,6 +1307,8 @@ impl ProviderAdapter for ResponsesAdapter { parsed.delta, parsed.output_index, true, + Some(parsed.item_id), + Some(parsed.sequence_number), ))) } @@ -2957,12 +3046,22 @@ mod tests { ); } + fn assert_responses_stream_event_roundtrips(adapter: &ResponsesAdapter, event: Value) { + let chunk = adapter + .stream_to_universal(event.clone()) + .expect("Responses stream event should parse") + .expect("Responses stream event should produce a chunk"); + let emitted = responses_stream_events_from_universal(&chunk); + assert_eq!(emitted, vec![event]); + } + #[test] fn test_responses_stream_custom_tool_call_roundtrips() { let adapter = ResponsesAdapter; let custom_tool_start = json!({ "type": "response.output_item.added", "item": { + "id": "ctc_exec", "type": "custom_tool_call", "status": "in_progress", "call_id": "call_exec", @@ -2997,10 +3096,7 @@ mod tests { .and_then(|function| function.name.as_deref()), Some("exec") ); - assert_eq!( - responses_stream_events_from_universal(&start_chunk), - vec![custom_tool_start] - ); + assert_responses_stream_event_roundtrips(&adapter, custom_tool_start); let custom_tool_delta = json!({ "type": "response.custom_tool_call_input.delta", @@ -3033,15 +3129,7 @@ mod tests { .and_then(|function| function.arguments.as_deref()), Some("await tools.exec_command({cmd: \"true\"});") ); - let expected_custom_tool_delta = json!({ - "type": "response.custom_tool_call_input.delta", - "delta": "await tools.exec_command({cmd: \"true\"});", - "output_index": 7 - }); - assert_eq!( - responses_stream_events_from_universal(&delta_chunk), - vec![expected_custom_tool_delta] - ); + assert_responses_stream_event_roundtrips(&adapter, custom_tool_delta); } #[test] diff --git a/crates/lingua/src/universal/stream.rs b/crates/lingua/src/universal/stream.rs index e0bd2e882..75791c0de 100644 --- a/crates/lingua/src/universal/stream.rs +++ b/crates/lingua/src/universal/stream.rs @@ -52,6 +52,10 @@ pub struct UniversalToolCallDelta { #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_tool_call: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub item_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sequence_number: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub function: Option, }