From cb5b92155025c5f5c056540c2d4e04286e556332 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Wed, 18 Mar 2026 13:14:51 +0000 Subject: [PATCH] feat(openai): implement tool support for gpt-5.4 via Responses API - Implement polymorphic 'input' structure for /responses endpoint - Map 'tool' role to 'function_call_output' items - Handle assistant 'tool_calls' as separate 'function_call' items - Add synchronous and streaming parsers for function_call items - Fix 400 Bad Request 'Invalid value: tool' error --- src/providers/openai.rs | 214 +++++++++++++++++++++++++++++++++------- 1 file changed, 177 insertions(+), 37 deletions(-) diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 7bc5d86c..5665e6be 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -112,10 +112,57 @@ impl super::Provider for OpenAIProvider { let messages_json = helpers::messages_to_openai_json(&request.messages).await?; let mut input_parts = Vec::new(); for m in &messages_json { - let mut role = m["role"].as_str().unwrap_or("user").to_string(); - // Newer models (gpt-5, o1) prefer "developer" over "system" - if role == "system" { - role = "developer".to_string(); + let role = m["role"].as_str().unwrap_or("user"); + + if role == "tool" { + input_parts.push(serde_json::json!({ + "type": "function_call_output", + "call_id": m.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""), + "output": m.get("content").and_then(|v| v.as_str()).unwrap_or("") + })); + continue; + } + + if role == "assistant" && m.get("tool_calls").is_some() { + // Push message part if it exists + let content_val = m.get("content").cloned().unwrap_or(serde_json::json!("")); + if !content_val.is_null() && (content_val.is_array() && !content_val.as_array().unwrap().is_empty() || content_val.is_string() && !content_val.as_str().unwrap().is_empty()) { + let mut content = content_val.clone(); + if let Some(text) = content.as_str() { + content = serde_json::json!([{ "type": "output_text", "text": text }]); + } else if let Some(arr) = content.as_array_mut() { + for part in arr { + if let Some(obj) = part.as_object_mut() { + if obj.get("type").and_then(|v| v.as_str()) == Some("text") { + obj.insert("type".to_string(), serde_json::json!("output_text")); + } + } + } + } + input_parts.push(serde_json::json!({ + "type": "message", + "role": "assistant", + "content": content + })); + } + + // Push tool calls as separate items + if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) { + for tc in tcs { + input_parts.push(serde_json::json!({ + "type": "function_call", + "call_id": tc["id"], + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"] + })); + } + } + continue; + } + + let mut mapped_role = role.to_string(); + if mapped_role == "system" { + mapped_role = "developer".to_string(); } let mut content = m.get("content").cloned().unwrap_or(serde_json::json!([])); @@ -127,12 +174,11 @@ impl super::Provider for OpenAIProvider { if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) { match t { "text" => { - let new_type = if role == "assistant" { "output_text" } else { "input_text" }; + let new_type = if mapped_role == "assistant" { "output_text" } else { "input_text" }; part_obj.insert("type".to_string(), serde_json::json!(new_type)); } "image_url" => { - // Assistant typically doesn't have image_url in history this way, but for safety: - let new_type = if role == "assistant" { "output_image" } else { "input_image" }; + let new_type = if mapped_role == "assistant" { "output_image" } else { "input_image" }; part_obj.insert("type".to_string(), serde_json::json!(new_type)); if let Some(img_url) = part_obj.remove("image_url") { part_obj.insert("image".to_string(), img_url); @@ -144,12 +190,13 @@ impl super::Provider for OpenAIProvider { } } } else if let Some(text) = content.as_str() { - let new_type = if role == "assistant" { "output_text" } else { "input_text" }; + let new_type = if mapped_role == "assistant" { "output_text" } else { "input_text" }; content = serde_json::json!([{ "type": new_type, "text": text }]); } input_parts.push(serde_json::json!({ - "role": role, + "type": "message", + "role": mapped_role, "content": content })); } @@ -200,18 +247,43 @@ impl super::Provider for OpenAIProvider { // Normalize Responses API output into ProviderResponse let mut content_text = String::new(); + let mut tool_calls = Vec::new(); if let Some(output) = resp_json.get("output").and_then(|o| o.as_array()) { for out in output { - if let Some(contents) = out.get("content").and_then(|c| c.as_array()) { - for item in contents { - if let Some(text) = item.get("text").and_then(|t| t.as_str()) { - if !content_text.is_empty() { content_text.push_str("\n"); } - content_text.push_str(text); - } else if let Some(parts) = item.get("parts").and_then(|p| p.as_array()) { - for p in parts { - if let Some(t) = p.as_str() { + let item_type = out.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match item_type { + "message" => { + if let Some(contents) = out.get("content").and_then(|c| c.as_array()) { + for item in contents { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { if !content_text.is_empty() { content_text.push_str("\n"); } - content_text.push_str(t); + content_text.push_str(text); + } + } + } + } + "function_call" => { + let id = out.get("call_id") + .or_else(|| out.get("item_id")) + .or_else(|| out.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = out.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let arguments = out.get("arguments").and_then(|v| v.as_str()).unwrap_or("").to_string(); + tool_calls.push(crate::models::ToolCall { + id, + call_type: "function".to_string(), + function: crate::models::FunctionCall { name, arguments }, + }); + } + _ => { + // Fallback for older/nested structure + if let Some(contents) = out.get("content").and_then(|c| c.as_array()) { + for item in contents { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + if !content_text.is_empty() { content_text.push_str("\n"); } + content_text.push_str(text); } } } @@ -244,7 +316,7 @@ impl super::Provider for OpenAIProvider { Ok(ProviderResponse { content: content_text, reasoning_content: None, - tool_calls: None, + tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, prompt_tokens, completion_tokens, reasoning_tokens: 0, @@ -379,10 +451,57 @@ impl super::Provider for OpenAIProvider { let messages_json = helpers::messages_to_openai_json(&request.messages).await?; let mut input_parts = Vec::new(); for m in &messages_json { - let mut role = m["role"].as_str().unwrap_or("user").to_string(); - // Newer models (gpt-5, o1) prefer "developer" over "system" - if role == "system" { - role = "developer".to_string(); + let role = m["role"].as_str().unwrap_or("user"); + + if role == "tool" { + input_parts.push(serde_json::json!({ + "type": "function_call_output", + "call_id": m.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""), + "output": m.get("content").and_then(|v| v.as_str()).unwrap_or("") + })); + continue; + } + + if role == "assistant" && m.get("tool_calls").is_some() { + // Push message part if it exists + let content_val = m.get("content").cloned().unwrap_or(serde_json::json!("")); + if !content_val.is_null() && (content_val.is_array() && !content_val.as_array().unwrap().is_empty() || content_val.is_string() && !content_val.as_str().unwrap().is_empty()) { + let mut content = content_val.clone(); + if let Some(text) = content.as_str() { + content = serde_json::json!([{ "type": "output_text", "text": text }]); + } else if let Some(arr) = content.as_array_mut() { + for part in arr { + if let Some(obj) = part.as_object_mut() { + if obj.get("type").and_then(|v| v.as_str()) == Some("text") { + obj.insert("type".to_string(), serde_json::json!("output_text")); + } + } + } + } + input_parts.push(serde_json::json!({ + "type": "message", + "role": "assistant", + "content": content + })); + } + + // Push tool calls as separate items + if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) { + for tc in tcs { + input_parts.push(serde_json::json!({ + "type": "function_call", + "call_id": tc["id"], + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"] + })); + } + } + continue; + } + + let mut mapped_role = role.to_string(); + if mapped_role == "system" { + mapped_role = "developer".to_string(); } let mut content = m.get("content").cloned().unwrap_or(serde_json::json!([])); @@ -394,12 +513,11 @@ impl super::Provider for OpenAIProvider { if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) { match t { "text" => { - let new_type = if role == "assistant" { "output_text" } else { "input_text" }; + let new_type = if mapped_role == "assistant" { "output_text" } else { "input_text" }; part_obj.insert("type".to_string(), serde_json::json!(new_type)); } "image_url" => { - // Assistant typically doesn't have image_url in history this way, but for safety: - let new_type = if role == "assistant" { "output_image" } else { "input_image" }; + let new_type = if mapped_role == "assistant" { "output_image" } else { "input_image" }; part_obj.insert("type".to_string(), serde_json::json!(new_type)); if let Some(img_url) = part_obj.remove("image_url") { part_obj.insert("image".to_string(), img_url); @@ -411,12 +529,13 @@ impl super::Provider for OpenAIProvider { } } } else if let Some(text) = content.as_str() { - let new_type = if role == "assistant" { "output_text" } else { "input_text" }; + let new_type = if mapped_role == "assistant" { "output_text" } else { "input_text" }; content = serde_json::json!([{ "type": new_type, "text": text }]); } input_parts.push(serde_json::json!({ - "role": role, + "type": "message", + "role": mapped_role, "content": content })); } @@ -475,6 +594,7 @@ impl super::Provider for OpenAIProvider { // Responses API specific parsing for streaming let mut content = String::new(); let mut finish_reason = None; + let mut tool_calls = None; let event_type = chunk.get("type").and_then(|v| v.as_str()).unwrap_or(""); @@ -484,15 +604,35 @@ impl super::Provider for OpenAIProvider { content.push_str(delta); } } - "response.output_text.done" => { - if let Some(text) = chunk.get("text").and_then(|v| v.as_str()) { - // Some implementations send the full text at the end - // We usually prefer deltas, but if we haven't seen them, this is the fallback. - // However, if we're already yielding deltas, we might not want this. - // For now, let's just use it as a signal that we're done. - finish_reason = Some("stop".to_string()); + "response.item.delta" => { + if let Some(delta) = chunk.get("delta") { + let t = delta.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if t == "function_call" { + let call_id = delta.get("call_id") + .or_else(|| chunk.get("item_id")) + .and_then(|v| v.as_str()); + let name = delta.get("name").and_then(|v| v.as_str()); + let arguments = delta.get("arguments").and_then(|v| v.as_str()); + + tool_calls = Some(vec![crate::models::ToolCallDelta { + index: chunk.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + id: call_id.map(|s| s.to_string()), + call_type: Some("function".to_string()), + function: Some(crate::models::FunctionCallDelta { + name: name.map(|s| s.to_string()), + arguments: arguments.map(|s| s.to_string()), + }), + }]); + } else if t == "message" { + if let Some(text) = delta.get("text").and_then(|v| v.as_str()) { + content.push_str(text); + } + } } } + "response.output_text.done" | "response.item.done" => { + finish_reason = Some("stop".to_string()); + } "response.done" => { finish_reason = Some("stop".to_string()); } @@ -514,12 +654,12 @@ impl super::Provider for OpenAIProvider { } } - if !content.is_empty() || finish_reason.is_some() { + if !content.is_empty() || finish_reason.is_some() || tool_calls.is_some() { yield ProviderStreamChunk { content, reasoning_content: None, finish_reason, - tool_calls: None, + tool_calls, model: model.clone(), usage: None, };