fix(openai): map content types for Responses API (v1/responses)
This commit is contained in:
@@ -92,97 +92,7 @@ impl super::Provider for OpenAIProvider {
|
|||||||
// Read error body to diagnose. If the model requires the Responses
|
// Read error body to diagnose. If the model requires the Responses
|
||||||
// API (v1/responses), retry against that endpoint.
|
// 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") {
|
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.
|
return self.chat_responses(request).await;
|
||||||
let messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
|
||||||
let mut inputs: Vec<String> = 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::error!("OpenAI API error ({}): {}", status, error_text);
|
tracing::error!("OpenAI API error ({}): {}", status, error_text);
|
||||||
@@ -203,8 +113,32 @@ impl super::Provider for OpenAIProvider {
|
|||||||
let mut input_parts = Vec::new();
|
let mut input_parts = Vec::new();
|
||||||
for m in &messages_json {
|
for m in &messages_json {
|
||||||
let role = m["role"].as_str().unwrap_or("user");
|
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!({
|
input_parts.push(serde_json::json!({
|
||||||
"role": role,
|
"role": role,
|
||||||
"content": content
|
"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()))?;
|
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
|
// Normalize Responses API output into ProviderResponse
|
||||||
let mut content_text = String::new();
|
let mut content_text = String::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()) {
|
||||||
@@ -314,6 +253,12 @@ impl super::Provider for OpenAIProvider {
|
|||||||
&self,
|
&self,
|
||||||
request: UnifiedRequest,
|
request: UnifiedRequest,
|
||||||
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, 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 messages_json = helpers::messages_to_openai_json(&request.messages).await?;
|
||||||
let mut body = helpers::build_openai_body(&request, messages_json, true);
|
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();
|
let mut input_parts = Vec::new();
|
||||||
for m in &messages_json {
|
for m in &messages_json {
|
||||||
let role = m["role"].as_str().unwrap_or("user");
|
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!({
|
input_parts.push(serde_json::json!({
|
||||||
"role": role,
|
"role": role,
|
||||||
"content": content
|
"content": content
|
||||||
|
|||||||
Reference in New Issue
Block a user