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
This commit is contained in:
@@ -112,10 +112,57 @@ impl super::Provider for OpenAIProvider {
|
|||||||
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
||||||
let mut input_parts = Vec::new();
|
let mut input_parts = Vec::new();
|
||||||
for m in &messages_json {
|
for m in &messages_json {
|
||||||
let mut role = m["role"].as_str().unwrap_or("user").to_string();
|
let role = m["role"].as_str().unwrap_or("user");
|
||||||
// Newer models (gpt-5, o1) prefer "developer" over "system"
|
|
||||||
if role == "system" {
|
if role == "tool" {
|
||||||
role = "developer".to_string();
|
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!([]));
|
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()) {
|
if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) {
|
||||||
match t {
|
match t {
|
||||||
"text" => {
|
"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));
|
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||||
}
|
}
|
||||||
"image_url" => {
|
"image_url" => {
|
||||||
// Assistant typically doesn't have image_url in history this way, but for safety:
|
let new_type = if mapped_role == "assistant" { "output_image" } else { "input_image" };
|
||||||
let new_type = if role == "assistant" { "output_image" } else { "input_image" };
|
|
||||||
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||||
if let Some(img_url) = part_obj.remove("image_url") {
|
if let Some(img_url) = part_obj.remove("image_url") {
|
||||||
part_obj.insert("image".to_string(), img_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() {
|
} 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 }]);
|
content = serde_json::json!([{ "type": new_type, "text": text }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
input_parts.push(serde_json::json!({
|
input_parts.push(serde_json::json!({
|
||||||
"role": role,
|
"type": "message",
|
||||||
|
"role": mapped_role,
|
||||||
"content": content
|
"content": content
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -200,18 +247,43 @@ impl super::Provider for OpenAIProvider {
|
|||||||
|
|
||||||
// Normalize Responses API output into ProviderResponse
|
// Normalize Responses API output into ProviderResponse
|
||||||
let mut content_text = String::new();
|
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()) {
|
if let Some(output) = resp_json.get("output").and_then(|o| o.as_array()) {
|
||||||
for out in output {
|
for out in output {
|
||||||
|
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()) {
|
if let Some(contents) = out.get("content").and_then(|c| c.as_array()) {
|
||||||
for item in contents {
|
for item in contents {
|
||||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||||
if !content_text.is_empty() { content_text.push_str("\n"); }
|
if !content_text.is_empty() { content_text.push_str("\n"); }
|
||||||
content_text.push_str(text);
|
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() {
|
}
|
||||||
|
}
|
||||||
|
"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"); }
|
if !content_text.is_empty() { content_text.push_str("\n"); }
|
||||||
content_text.push_str(t);
|
content_text.push_str(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +316,7 @@ impl super::Provider for OpenAIProvider {
|
|||||||
Ok(ProviderResponse {
|
Ok(ProviderResponse {
|
||||||
content: content_text,
|
content: content_text,
|
||||||
reasoning_content: None,
|
reasoning_content: None,
|
||||||
tool_calls: None,
|
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||||
prompt_tokens,
|
prompt_tokens,
|
||||||
completion_tokens,
|
completion_tokens,
|
||||||
reasoning_tokens: 0,
|
reasoning_tokens: 0,
|
||||||
@@ -379,10 +451,57 @@ impl super::Provider for OpenAIProvider {
|
|||||||
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
||||||
let mut input_parts = Vec::new();
|
let mut input_parts = Vec::new();
|
||||||
for m in &messages_json {
|
for m in &messages_json {
|
||||||
let mut role = m["role"].as_str().unwrap_or("user").to_string();
|
let role = m["role"].as_str().unwrap_or("user");
|
||||||
// Newer models (gpt-5, o1) prefer "developer" over "system"
|
|
||||||
if role == "system" {
|
if role == "tool" {
|
||||||
role = "developer".to_string();
|
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!([]));
|
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()) {
|
if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) {
|
||||||
match t {
|
match t {
|
||||||
"text" => {
|
"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));
|
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||||
}
|
}
|
||||||
"image_url" => {
|
"image_url" => {
|
||||||
// Assistant typically doesn't have image_url in history this way, but for safety:
|
let new_type = if mapped_role == "assistant" { "output_image" } else { "input_image" };
|
||||||
let new_type = if role == "assistant" { "output_image" } else { "input_image" };
|
|
||||||
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
part_obj.insert("type".to_string(), serde_json::json!(new_type));
|
||||||
if let Some(img_url) = part_obj.remove("image_url") {
|
if let Some(img_url) = part_obj.remove("image_url") {
|
||||||
part_obj.insert("image".to_string(), img_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() {
|
} 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 }]);
|
content = serde_json::json!([{ "type": new_type, "text": text }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
input_parts.push(serde_json::json!({
|
input_parts.push(serde_json::json!({
|
||||||
"role": role,
|
"type": "message",
|
||||||
|
"role": mapped_role,
|
||||||
"content": content
|
"content": content
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -475,6 +594,7 @@ impl super::Provider for OpenAIProvider {
|
|||||||
// Responses API specific parsing for streaming
|
// Responses API specific parsing for streaming
|
||||||
let mut content = String::new();
|
let mut content = String::new();
|
||||||
let mut finish_reason = None;
|
let mut finish_reason = None;
|
||||||
|
let mut tool_calls = None;
|
||||||
|
|
||||||
let event_type = chunk.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
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);
|
content.push_str(delta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"response.output_text.done" => {
|
"response.item.delta" => {
|
||||||
if let Some(text) = chunk.get("text").and_then(|v| v.as_str()) {
|
if let Some(delta) = chunk.get("delta") {
|
||||||
// Some implementations send the full text at the end
|
let t = delta.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
// We usually prefer deltas, but if we haven't seen them, this is the fallback.
|
if t == "function_call" {
|
||||||
// However, if we're already yielding deltas, we might not want this.
|
let call_id = delta.get("call_id")
|
||||||
// For now, let's just use it as a signal that we're done.
|
.or_else(|| chunk.get("item_id"))
|
||||||
finish_reason = Some("stop".to_string());
|
.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" => {
|
"response.done" => {
|
||||||
finish_reason = Some("stop".to_string());
|
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 {
|
yield ProviderStreamChunk {
|
||||||
content,
|
content,
|
||||||
reasoning_content: None,
|
reasoning_content: None,
|
||||||
finish_reason,
|
finish_reason,
|
||||||
tool_calls: None,
|
tool_calls,
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
usage: None,
|
usage: None,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user