diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 04f07e08..2333d7a2 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -92,97 +92,7 @@ impl super::Provider for OpenAIProvider { // Read error body to diagnose. If the model requires the Responses // API (v1/responses), retry against that endpoint. if error_text.to_lowercase().contains("v1/responses") || error_text.to_lowercase().contains("only supported in v1/responses") { - // Build a simple `input` string by concatenating message parts. - let messages_json = helpers::messages_to_openai_json(&request.messages).await?; - let mut inputs: Vec = Vec::new(); - for m in &messages_json { - let role = m["role"].as_str().unwrap_or(""); - let parts = m.get("content").and_then(|c| c.as_array()).cloned().unwrap_or_default(); - let mut text_parts = Vec::new(); - for p in parts { - if let Some(t) = p.get("text").and_then(|v| v.as_str()) { - text_parts.push(t.to_string()); - } - } - inputs.push(format!("{}: {}", role, text_parts.join(""))); - } - let input_text = inputs.join("\n"); - - let resp = self - .client - .post(format!("{}/responses", self.config.base_url)) - .header("Authorization", format!("Bearer {}", self.api_key)) - .json(&serde_json::json!({ "model": request.model, "input": input_text })) - .send() - .await - .map_err(|e| AppError::ProviderError(e.to_string()))?; - - if !resp.status().is_success() { - let err = resp.text().await.unwrap_or_default(); - return Err(AppError::ProviderError(format!("OpenAI Responses API error: {}", err))); - } - - let resp_json: serde_json::Value = resp.json().await.map_err(|e| AppError::ProviderError(e.to_string()))?; - // Try to normalize: if it's chat-style, use existing parser - if resp_json.get("choices").is_some() { - return helpers::parse_openai_response(&resp_json, request.model); - } - - // Responses API: try to extract text from `output` or `candidates` - let mut content_text = String::new(); - if let Some(output) = resp_json.get("output").and_then(|o| o.as_array()) { - if let Some(first) = output.get(0) { - if let Some(contents) = first.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() { - if !content_text.is_empty() { content_text.push_str("\n"); } - content_text.push_str(t); - } - } - } - } - } - } - } - - if content_text.is_empty() { - if let Some(cands) = resp_json.get("candidates").and_then(|c| c.as_array()) { - if let Some(c0) = cands.get(0) { - if let Some(content) = c0.get("content") { - if let Some(parts) = content.get("parts").and_then(|p| p.as_array()) { - for p in parts { - if let Some(t) = p.get("text").and_then(|v| v.as_str()) { - if !content_text.is_empty() { content_text.push_str("\n"); } - content_text.push_str(t); - } - } - } - } - } - } - } - - let prompt_tokens = resp_json.get("usage").and_then(|u| u.get("prompt_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; - - return Ok(ProviderResponse { - content: content_text, - reasoning_content: None, - tool_calls: None, - prompt_tokens, - completion_tokens, - reasoning_tokens: 0, - total_tokens, - cache_read_tokens: 0, - cache_write_tokens: 0, - model: request.model, - }); + return self.chat_responses(request).await; } tracing::error!("OpenAI API error ({}): {}", status, error_text); @@ -203,8 +113,32 @@ impl super::Provider for OpenAIProvider { let mut input_parts = Vec::new(); for m in &messages_json { let role = m["role"].as_str().unwrap_or("user"); - let content = m.get("content").cloned().unwrap_or(serde_json::json!("")); + let mut content = m.get("content").cloned().unwrap_or(serde_json::json!([])); + // Map "text" -> "input_text" and "image_url" -> "input_image" for Responses API + if let Some(content_array) = content.as_array_mut() { + for part in content_array { + if let Some(part_obj) = part.as_object_mut() { + if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) { + match t { + "text" => { + part_obj.insert("type".to_string(), serde_json::json!("input_text")); + } + "image_url" => { + part_obj.insert("type".to_string(), serde_json::json!("input_image")); + if let Some(img_url) = part_obj.remove("image_url") { + part_obj.insert("image".to_string(), img_url); + } + } + _ => {} + } + } + } + } + } else if let Some(text) = content.as_str() { + content = serde_json::json!([{ "type": "input_text", "text": text }]); + } + input_parts.push(serde_json::json!({ "role": role, "content": content @@ -227,6 +161,11 @@ impl super::Provider for OpenAIProvider { let resp_json: serde_json::Value = resp.json().await.map_err(|e| AppError::ProviderError(e.to_string()))?; + // Try to normalize: if it's chat-style, use existing parser + if resp_json.get("choices").is_some() { + return helpers::parse_openai_response(&resp_json, request.model); + } + // Normalize Responses API output into ProviderResponse let mut content_text = String::new(); if let Some(output) = resp_json.get("output").and_then(|o| o.as_array()) { @@ -314,6 +253,12 @@ impl super::Provider for OpenAIProvider { &self, request: UnifiedRequest, ) -> Result>, AppError> { + // Allow proactive routing to Responses API based on heuristic + let model_lc = request.model.to_lowercase(); + if model_lc.contains("gpt-5") || model_lc.contains("codex") { + return self.chat_responses_stream(request).await; + } + let messages_json = helpers::messages_to_openai_json(&request.messages).await?; let mut body = helpers::build_openai_body(&request, messages_json, true); @@ -403,8 +348,32 @@ impl super::Provider for OpenAIProvider { let mut input_parts = Vec::new(); for m in &messages_json { let role = m["role"].as_str().unwrap_or("user"); - let content = m.get("content").cloned().unwrap_or(serde_json::json!("")); + let mut content = m.get("content").cloned().unwrap_or(serde_json::json!([])); + // Map "text" -> "input_text" and "image_url" -> "input_image" for Responses API + if let Some(content_array) = content.as_array_mut() { + for part in content_array { + if let Some(part_obj) = part.as_object_mut() { + if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) { + match t { + "text" => { + part_obj.insert("type".to_string(), serde_json::json!("input_text")); + } + "image_url" => { + part_obj.insert("type".to_string(), serde_json::json!("input_image")); + if let Some(img_url) = part_obj.remove("image_url") { + part_obj.insert("image".to_string(), img_url); + } + } + _ => {} + } + } + } + } + } else if let Some(text) = content.as_str() { + content = serde_json::json!([{ "type": "input_text", "text": text }]); + } + input_parts.push(serde_json::json!({ "role": role, "content": content