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
This commit is contained in:
@@ -36,6 +36,57 @@ impl OpenAIProvider {
|
|||||||
pricing: app_config.pricing.openai.clone(),
|
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<crate::models::ToolCall> {
|
||||||
|
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::<serde_json::Value>(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]
|
#[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 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;
|
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 {
|
Ok(ProviderResponse {
|
||||||
content: content_text,
|
content: content_text,
|
||||||
reasoning_content: None,
|
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<crate::models::ToolCallDelta> = 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() {
|
if !content.is_empty() || finish_reason.is_some() || tool_calls.is_some() {
|
||||||
yield ProviderStreamChunk {
|
yield ProviderStreamChunk {
|
||||||
content,
|
content,
|
||||||
|
|||||||
Reference in New Issue
Block a user