diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 0e5c5839..08d26053 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -149,11 +149,34 @@ impl super::Provider for OpenAIProvider { })); } + let mut body = serde_json::json!({ + "model": request.model, + "input": input_parts, + }); + + // Add standard parameters + if let Some(temp) = request.temperature { + body["temperature"] = serde_json::json!(temp); + } + + // Newer models (gpt-5, o1) prefer max_completion_tokens + if let Some(max_tokens) = request.max_tokens { + if request.model.contains("gpt-5") || request.model.starts_with("o1-") || request.model.starts_with("o3-") { + body["max_completion_tokens"] = serde_json::json!(max_tokens); + } else { + body["max_tokens"] = serde_json::json!(max_tokens); + } + } + + if let Some(tools) = &request.tools { + body["tools"] = serde_json::json!(tools); + } + 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_parts })) + .json(&body) .send() .await .map_err(|e| AppError::ProviderError(e.to_string()))?; @@ -173,8 +196,8 @@ impl super::Provider for OpenAIProvider { // 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()) { - if let Some(first) = output.get(0) { - if let Some(contents) = first.get("content").and_then(|c| c.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"); } @@ -388,12 +411,27 @@ impl super::Provider for OpenAIProvider { })); } - let body = serde_json::json!({ + let mut body = serde_json::json!({ "model": request.model, "input": input_parts, - "stream": true + "stream": true, + "stream_options": { "include_usage": true } }); + // Add standard parameters + if let Some(temp) = request.temperature { + body["temperature"] = serde_json::json!(temp); + } + + // Newer models (gpt-5, o1) prefer max_completion_tokens + if let Some(max_tokens) = request.max_tokens { + if request.model.contains("gpt-5") || request.model.starts_with("o1-") || request.model.starts_with("o3-") { + body["max_completion_tokens"] = serde_json::json!(max_tokens); + } else { + body["max_tokens"] = serde_json::json!(max_tokens); + } + } + let url = format!("{}/responses", self.config.base_url); let api_key = self.api_key.clone(); let model = request.model.clone(); @@ -420,32 +458,33 @@ impl super::Provider for OpenAIProvider { let chunk: serde_json::Value = serde_json::from_str(&msg.data) .map_err(|e| AppError::ProviderError(format!("Failed to parse Responses stream chunk: {}", e)))?; - // Try standard OpenAI parsing first + // Try standard OpenAI parsing first (choices/usage) if let Some(p_chunk) = helpers::parse_openai_stream_chunk(&chunk, &model, None) { yield p_chunk?; } else { // Responses API specific parsing for streaming - // Often it follows a similar structure to the non-streaming response but in chunks let mut content = String::new(); // Check for output[0].content[0].text (similar to non-stream) if let Some(output) = chunk.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 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()) { content.push_str(text); + } else if let Some(delta) = item.get("delta").and_then(|d| d.get("text")).and_then(|t| t.as_str()) { + content.push_str(delta); } } } } } - // Check for candidates[0].content.parts[0].text (Gemini-like, which OpenAI sometimes uses for v1/responses) + // Check for candidates[0].content.parts[0].text if content.is_empty() { if let Some(cands) = chunk.get("candidates").and_then(|c| c.as_array()) { - if let Some(c0) = cands.get(0) { - if let Some(content_obj) = c0.get("content") { + for c in cands { + if let Some(content_obj) = c.get("content") { if let Some(parts) = content_obj.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()) { @@ -488,6 +527,8 @@ impl super::Provider for OpenAIProvider { Err(AppError::ProviderError(format!("OpenAI Responses API error ({}): {}", status, error_body)))?; } Ok(_) => { + // If the probe returned 200, but the stream ended, it might be a silent failure or timeout. + tracing::warn!("Responses stream ended prematurely (probe returned 200)"); Err(AppError::ProviderError(format!("Responses stream error (probe returned 200): {}", e)))?; } Err(probe_err) => {