From d0be16d8e3b7a87ff9ae23b38441acbfc6ce940f Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Wed, 18 Mar 2026 14:28:38 +0000 Subject: [PATCH] fix(openai): parse embedded 'tool_uses' JSON for gpt-5.4 parallel calls - Add static parse_tool_uses_json helper to extract embedded tool calls - Update synchronous and streaming Responses API parsers to detect tool_uses blocks - Strip tool_uses JSON from content to prevent raw JSON leakage to client - Resolve lifetime issues by avoiding &self capture in streaming closure --- src/providers/openai.rs | 90 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/providers/openai.rs b/src/providers/openai.rs index ed654295..9791f989 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -36,6 +36,57 @@ impl OpenAIProvider { pricing: app_config.pricing.openai.clone(), }) } + + /// GPT-5.4 models sometimes emit parallel tool calls as a JSON block starting with + /// '{"tool_uses":' inside a text message instead of discrete function_call items. + /// This method attempts to extract and parse such tool calls. + pub fn parse_tool_uses_json(text: &str) -> Vec { + let mut calls = Vec::new(); + if let Some(start) = text.find("{\"tool_uses\":") { + // Find the end of the JSON block by matching braces + let sub = &text[start..]; + let mut brace_count = 0; + let mut end_idx = 0; + let mut found = false; + + for (i, c) in sub.char_indices() { + if c == '{' { brace_count += 1; } + else if c == '}' { + brace_count -= 1; + if brace_count == 0 { + end_idx = i + 1; + found = true; + break; + } + } + } + + if found { + let json_str = &sub[..end_idx]; + if let Ok(val) = serde_json::from_str::(json_str) { + if let Some(uses) = val.get("tool_uses").and_then(|u| u.as_array()) { + for (idx, u) in uses.iter().enumerate() { + let name = u.get("recipient_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + // Strip "functions." prefix if present + .replace("functions.", ""); + let arguments = u.get("parameters") + .map(|v| v.to_string()) + .unwrap_or_else(|| "{}".to_string()); + + calls.push(crate::models::ToolCall { + id: format!("call_tu_{}_{}", uuid::Uuid::new_v4().to_string()[..8].to_string(), idx), + call_type: "function".to_string(), + function: crate::models::FunctionCall { name, arguments }, + }); + } + } + } + } + } + calls + } } #[async_trait] @@ -344,6 +395,16 @@ impl super::Provider for OpenAIProvider { let completion_tokens = resp_json.get("usage").and_then(|u| u.get("completion_tokens")).and_then(|v| v.as_u64()).unwrap_or(0) as u32; let total_tokens = resp_json.get("usage").and_then(|u| u.get("total_tokens")).and_then(|v| v.as_u64()).unwrap_or(0) as u32; + // GPT-5.4 parallel tool calls might be embedded in content_text as a JSON block + let embedded_calls = Self::parse_tool_uses_json(&content_text); + if !embedded_calls.is_empty() { + // Strip the JSON part from content_text to keep it clean + if let Some(start) = content_text.find("{\"tool_uses\":") { + content_text = content_text[..start].to_string(); + } + tool_calls.extend(embedded_calls); + } + Ok(ProviderResponse { content: content_text, reasoning_content: None, @@ -720,6 +781,35 @@ impl super::Provider for OpenAIProvider { } } + // GPT-5.4 parallel tool calls might be embedded in content as a JSON block + let embedded_calls = Self::parse_tool_uses_json(&content); + + if !embedded_calls.is_empty() { + // Strip the JSON part from content to keep it clean + if let Some(start) = content.find("{\"tool_uses\":") { + content = content[..start].to_string(); + } + + // Convert ToolCall to ToolCallDelta for streaming + let deltas: Vec = embedded_calls.into_iter().enumerate().map(|(idx, tc)| { + crate::models::ToolCallDelta { + index: idx as u32, + id: Some(tc.id), + call_type: Some("function".to_string()), + function: Some(crate::models::FunctionCallDelta { + name: Some(tc.function.name), + arguments: Some(tc.function.arguments), + }), + } + }).collect(); + + if let Some(ref mut existing) = tool_calls { + existing.extend(deltas); + } else { + tool_calls = Some(deltas); + } + } + if !content.is_empty() || finish_reason.is_some() || tool_calls.is_some() { yield ProviderStreamChunk { content,