diff --git a/src/app.rs b/src/app.rs index c062cd3..d7b886c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -60,6 +60,7 @@ impl AppState { config.upstream_url(Provider::Anthropic), ); upstream_urls.insert(Provider::Minimax, config.upstream_url(Provider::Minimax)); + upstream_urls.insert(Provider::Opencode, config.upstream_url(Provider::Opencode)); // Build key mappings for both static and OAuth keys let mut key_mappings: BTreeMap = BTreeMap::new(); @@ -1021,16 +1022,20 @@ async fn maybe_translate_response( None => return Ok(response), }; - let is_streaming = stream_requested - || response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .is_some_and(|ct| ct.contains("text/event-stream")); + let upstream_is_sse = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.contains("text/event-stream")); - debug!(format = ?format, is_streaming, "maybe_translate_response: translating response"); + debug!( + format = ?format, + stream_requested, + upstream_is_sse, + "maybe_translate_response: translating response" + ); - if is_streaming { + if stream_requested { let (parts, body) = response.into_parts(); let translated_body = match format { crate::oauth::ResponseFormat::ResponsesApi => { @@ -1056,9 +1061,20 @@ async fn maybe_translate_response( match format { crate::oauth::ResponseFormat::ResponsesApi => { - match crate::translate::responses_to_chat_completion(&body_bytes) { + let translated = if upstream_is_sse { + crate::translate::responses_sse_to_chat_completion(&body_bytes) + } else { + crate::translate::responses_to_chat_completion(&body_bytes) + }; + + match translated { Ok(translated) => { + parts.headers.remove("content-type"); parts.headers.remove("content-length"); + parts.headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); Ok(Response::from_parts(parts, Body::from(translated))) } Err(e) => { diff --git a/src/app/tests.rs b/src/app/tests.rs index bb2f895..3865d91 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -996,6 +996,59 @@ async fn test_openai_and_openrouter_keys_map_to_distinct_real_keys() { assert_eq!(openrouter_resp.status(), StatusCode::OK); } +#[tokio::test] +async fn test_opencode_key_uses_zen_upstream() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(header("authorization", "Bearer sk-opencode-real")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "provider": "opencode" + }))) + .mount(&mock_server) + .await; + + let mut key_map = BTreeMap::new(); + key_map.insert( + "vk-opencode".to_string(), + ResolvedKey { + source: KeySource::Static { + real_key: "sk-opencode-real".to_string(), + }, + provider: Provider::Opencode, + }, + ); + + let mut upstream_urls = BTreeMap::new(); + upstream_urls.insert(Provider::Opencode, mock_server.uri()); + + let app = build_router(AppState { + key_manager: Arc::new(KeyManager::new(key_map)), + dlp_scanner: Arc::new(DlpScanner::new(&[], false).unwrap()), + proxy_client: Arc::new(ProxyClient::with_upstream_urls( + upstream_urls, + "2023-06-01".to_string(), + )), + oauth_registry: Arc::new(OAuthRegistry::new(Default::default())), + email_enabled: false, + email_policy: None, + email_accounts: Arc::new(BTreeMap::new()), + email_service: Arc::new(EmailService::mock_disabled()), + stats: Arc::new(Stats::new(None)), + }); + + let req = Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("authorization", "Bearer vk-opencode") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + // ========== DLP Redaction Tests ========== fn make_app_with_redact(upstream_url: &str) -> axum::Router { diff --git a/src/config.rs b/src/config.rs index 192fe8a..8d33d4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub enum Provider { Openrouter, Anthropic, Minimax, + Opencode, } impl Provider { @@ -21,6 +22,7 @@ impl Provider { Provider::Openrouter => "https://openrouter.ai/api", Provider::Anthropic => "https://api.anthropic.com", Provider::Minimax => "https://api.minimax.io", + Provider::Opencode => "https://opencode.ai/zen", } } } @@ -82,6 +84,10 @@ pub struct UpstreamConfig { pub anthropic_version: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub minimax_base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode_zen_base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode_go_base_url: Option, } fn default_anthropic_version() -> String { @@ -445,6 +451,11 @@ impl Config { .minimax_base_url .clone() .unwrap_or_else(|| Provider::Minimax.default_base_url().to_string()), + Provider::Opencode => self + .upstream + .opencode_zen_base_url + .clone() + .unwrap_or_else(|| Provider::Opencode.default_base_url().to_string()), } } diff --git a/src/proxy.rs b/src/proxy.rs index e451d8a..fb0572b 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -71,7 +71,7 @@ impl ProxyClient { // Inject the real API key based on provider match provider { - Provider::Openai | Provider::Openrouter | Provider::Minimax => { + Provider::Openai | Provider::Openrouter | Provider::Minimax | Provider::Opencode => { req_headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", real_key)) diff --git a/src/translate.rs b/src/translate.rs index 1abca5a..1e6dac8 100644 --- a/src/translate.rs +++ b/src/translate.rs @@ -4,6 +4,7 @@ use axum::body::Body; use bytes::{Bytes, BytesMut}; use futures_util::Stream; use serde_json::Value; +use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; @@ -19,14 +20,7 @@ pub enum TranslateError { } /// Fields that are compatible between chat/completions and responses API. -const PASSTHROUGH_FIELDS: &[&str] = &[ - "model", - "stream", - "stream_options", - "temperature", - "top_p", - "stop", -]; +const PASSTHROUGH_FIELDS: &[&str] = &["model", "stream", "top_p", "stop"]; /// Fields that must be stripped from chat/completions requests (not supported by responses API). const STRIP_FIELDS: &[&str] = &[ @@ -67,7 +61,7 @@ pub fn chat_completions_to_responses(body: &[u8]) -> Result, TranslateEr system_parts.push(content); } } else { - input.push(convert_message_content(msg.clone())); + input.extend(convert_message_to_input_items(msg)); } } @@ -90,6 +84,17 @@ pub fn chat_completions_to_responses(body: &[u8]) -> Result, TranslateEr } } + if let Some(tools) = obj.get("tools").and_then(Value::as_array) { + let converted_tools: Vec = tools.iter().filter_map(convert_tool).collect(); + if !converted_tools.is_empty() { + result.insert("tools".to_string(), Value::Array(converted_tools)); + } + } + + if let Some(tool_choice) = obj.get("tool_choice").and_then(convert_tool_choice) { + result.insert("tool_choice".to_string(), tool_choice); + } + // Strip incompatible fields — they are simply not copied over. // (No action needed since we build a new object.) let _ = STRIP_FIELDS; // acknowledge the constant is used by design @@ -97,14 +102,34 @@ pub fn chat_completions_to_responses(body: &[u8]) -> Result, TranslateEr Ok(serde_json::to_vec(&Value::Object(result))?) } -/// Convert a chat/completions message to a Responses API input item. +/// Convert a chat/completions message to one or more Responses API input items. /// - Adds `type: "message"` (required by Responses API) /// - For user messages: converts content `type: "text"` → `type: "input_text"` /// - For assistant messages: converts content `type: "text"` → `type: "output_text"` /// - Converts content `type: "image_url"` → `type: "input_image"` +/// - Converts tool output messages to `function_call_output` items. +/// - Converts assistant tool_calls to `function_call` items. /// - String content is left as-is (the Responses API accepts string content directly). -fn convert_message_content(mut msg: Value) -> Value { +fn convert_message_to_input_items(msg: &Value) -> Vec { let role = msg.get("role").and_then(Value::as_str).unwrap_or(""); + if role == "tool" { + let call_id = msg + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let output = msg + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + return vec![serde_json::json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output, + })]; + } + + let mut items = Vec::new(); + let mut msg = msg.clone(); let is_assistant = role == "assistant"; // Responses API requires "type": "message" on each input item @@ -114,33 +139,151 @@ fn convert_message_content(mut msg: Value) -> Value { } } - let Some(content) = msg.get_mut("content") else { - return msg; - }; - let Some(parts) = content.as_array_mut() else { - // String content — no conversion needed - return msg; - }; - for part in parts.iter_mut() { - let Some(obj) = part.as_object_mut() else { - continue; - }; - match obj.get("type").and_then(Value::as_str) { - Some("text") => { - let text_type = if is_assistant { - "output_text" - } else { - "input_text" - }; - obj.insert("type".to_string(), Value::String(text_type.to_string())); + let has_content = msg + .get("content") + .is_some_and(|content| !content.is_null() && content != ""); + if has_content { + if let Some(content) = msg.get_mut("content") { + if let Some(parts) = content.as_array_mut() { + for part in parts.iter_mut() { + let Some(obj) = part.as_object_mut() else { + continue; + }; + match obj.get("type").and_then(Value::as_str) { + Some("text") => { + let text_type = if is_assistant { + "output_text" + } else { + "input_text" + }; + obj.insert("type".to_string(), Value::String(text_type.to_string())); + } + Some("image_url") => { + obj.insert( + "type".to_string(), + Value::String("input_image".to_string()), + ); + } + _ => {} + } + } } - Some("image_url") => { - obj.insert("type".to_string(), Value::String("input_image".to_string())); + } + items.push(msg.clone()); + } + + if let Some(tool_calls) = msg.get("tool_calls").and_then(Value::as_array) { + for tool_call in tool_calls { + let Some(function) = tool_call.get("function") else { + continue; + }; + let Some(name) = function.get("name").and_then(Value::as_str) else { + continue; + }; + let call_id = tool_call + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + let arguments = function + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + items.push(serde_json::json!({ + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments, + })); + } + } + + if items.is_empty() { + items.push(msg); + } + items +} + +fn convert_tool(tool: &Value) -> Option { + if tool.get("type").and_then(Value::as_str) != Some("function") { + return None; + } + let function = tool.get("function")?.as_object()?; + let name = function.get("name")?.clone(); + let mut converted = serde_json::Map::new(); + converted.insert("type".to_string(), Value::String("function".to_string())); + converted.insert("name".to_string(), name); + for field in ["description", "parameters", "strict"] { + if let Some(value) = function.get(field).or_else(|| tool.get(field)) { + converted.insert(field.to_string(), value.clone()); + } + } + Some(Value::Object(converted)) +} + +fn convert_tool_choice(tool_choice: &Value) -> Option { + if tool_choice.is_string() { + return Some(tool_choice.clone()); + } + let obj = tool_choice.as_object()?; + if obj.get("type").and_then(Value::as_str) == Some("function") { + if let Some(name) = obj + .get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + { + return Some(serde_json::json!({ + "type": "function", + "name": name, + })); + } + } + None +} + +fn response_output_to_chat_message(obj: &serde_json::Map) -> (String, Vec) { + let mut content_parts: Vec<&str> = Vec::new(); + let mut tool_calls = Vec::new(); + + if let Some(output) = obj.get("output").and_then(Value::as_array) { + for item in output { + match item.get("type").and_then(Value::as_str) { + Some("message") => { + if let Some(content) = item.get("content").and_then(Value::as_array) { + for part in content { + if part.get("type").and_then(Value::as_str) == Some("output_text") { + if let Some(text) = part.get("text").and_then(Value::as_str) { + content_parts.push(text); + } + } + } + } + } + Some("function_call") => { + let id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default(); + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + let arguments = item + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + tool_calls.push(serde_json::json!({ + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": arguments, + } + })); + } + _ => {} } - _ => {} } } - msg + + (content_parts.join(""), tool_calls) } /// Translate a `/v1/responses` response body to `/v1/chat/completions` format. @@ -159,27 +302,11 @@ pub fn responses_to_chat_completion(body: &[u8]) -> Result, TranslateErr .and_then(Value::as_str) .unwrap_or("unknown"); - // Extract text content from output[].content[].text where type == "output_text" - let mut content_parts: Vec<&str> = Vec::new(); - if let Some(output) = obj.get("output").and_then(Value::as_array) { - for item in output { - if item.get("type").and_then(Value::as_str) == Some("message") { - if let Some(content) = item.get("content").and_then(Value::as_array) { - for part in content { - if part.get("type").and_then(Value::as_str) == Some("output_text") { - if let Some(text) = part.get("text").and_then(Value::as_str) { - content_parts.push(text); - } - } - } - } - } - } - } - let content = content_parts.join(""); + let (content, tool_calls) = response_output_to_chat_message(obj); // Map status → finish_reason let finish_reason = match obj.get("status").and_then(Value::as_str) { + Some("completed") | None if !tool_calls.is_empty() => "tool_calls", Some("completed") | None => "stop", Some("incomplete") => "length", Some("failed") => "stop", @@ -199,10 +326,97 @@ pub fn responses_to_chat_completion(body: &[u8]) -> Result, TranslateErr serde_json::json!({ "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }) }; + let mut message = serde_json::json!({ + "role": "assistant", + "content": if tool_calls.is_empty() { Value::String(content) } else { Value::Null }, + }); + if !tool_calls.is_empty() { + message["tool_calls"] = Value::Array(tool_calls); + } + let result = serde_json::json!({ "id": id, "object": "chat.completion", "model": model, + "choices": [{ + "index": 0, + "message": message, + "finish_reason": finish_reason, + }], + "usage": usage, + }); + + Ok(serde_json::to_vec(&result)?) +} + +/// Translate a Responses API SSE body to a non-streaming `/v1/chat/completions` response. +pub fn responses_sse_to_chat_completion(body: &[u8]) -> Result, TranslateError> { + let text = String::from_utf8_lossy(body); + + let mut response_id: Option = None; + let mut model: Option = None; + let mut content = String::new(); + let mut finish_reason = "stop"; + let mut input_tokens = 0_u64; + let mut output_tokens = 0_u64; + + for line in text.lines() { + let Some(json_str) = line.strip_prefix("data: ") else { + continue; + }; + let json_str = json_str.trim(); + if json_str.is_empty() || json_str == "[DONE]" { + continue; + } + + let event: Value = serde_json::from_str(json_str)?; + match event.get("type").and_then(Value::as_str) { + Some("response.created") + | Some("response.in_progress") + | Some("response.completed") => { + if let Some(resp) = event.get("response") { + if let Some(id) = resp.get("id").and_then(Value::as_str) { + response_id = Some(id.to_string()); + } + if let Some(m) = resp.get("model").and_then(Value::as_str) { + model = Some(m.to_string()); + } + if let Some(status) = resp.get("status").and_then(Value::as_str) { + finish_reason = match status { + "incomplete" => "length", + _ => "stop", + }; + } + if let Some(usage) = resp.get("usage") { + input_tokens = usage + .get("input_tokens") + .and_then(Value::as_u64) + .unwrap_or(input_tokens); + output_tokens = usage + .get("output_tokens") + .and_then(Value::as_u64) + .unwrap_or(output_tokens); + } + } + } + Some("response.output_text.delta") => { + if let Some(delta) = event.get("delta").and_then(Value::as_str) { + content.push_str(delta); + } + } + Some("response.output_text.done") => { + if let Some(text) = event.get("text").and_then(Value::as_str) { + content = text.to_string(); + } + } + _ => {} + } + } + + let result = serde_json::json!({ + "id": response_id.unwrap_or_else(|| "chatcmpl-translate".to_string()), + "object": "chat.completion", + "model": model.unwrap_or_else(|| "unknown".to_string()), "choices": [{ "index": 0, "message": { @@ -211,7 +425,11 @@ pub fn responses_to_chat_completion(body: &[u8]) -> Result, TranslateErr }, "finish_reason": finish_reason, }], - "usage": usage, + "usage": { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + }, }); Ok(serde_json::to_vec(&result)?) @@ -227,6 +445,8 @@ pub fn translate_sse_line( line: &str, response_id: &mut Option, model: &mut Option, + tool_call_indices: &mut HashMap, + next_tool_call_index: &mut usize, ) -> Option { // Pass through [DONE] if line.starts_with("data: [DONE]") { @@ -273,9 +493,91 @@ pub fn translate_sse_line( )) } + "response.output_item.added" => { + let output_index = event.get("output_index").and_then(Value::as_u64)?; + let item = event.get("item")?; + if item.get("type").and_then(Value::as_str) != Some("function_call") { + return None; + } + let index = *next_tool_call_index; + *next_tool_call_index += 1; + tool_call_indices.insert(output_index, index); + + let id = response_id.as_deref().unwrap_or("chatcmpl-translate"); + let m = model.as_deref().unwrap_or("unknown"); + let call_id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default(); + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + let arguments = item + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let chunk = serde_json::json!({ + "id": id, + "object": "chat.completion.chunk", + "model": m, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": arguments, + } + }] + }, + "finish_reason": null, + }] + }); + Some(format!( + "data: {}", + serde_json::to_string(&chunk).unwrap_or_default() + )) + } + + "response.function_call_arguments.delta" => { + let output_index = event.get("output_index").and_then(Value::as_u64)?; + let index = *tool_call_indices.get(&output_index)?; + let delta = event.get("delta").and_then(Value::as_str).unwrap_or(""); + let id = response_id.as_deref().unwrap_or("chatcmpl-translate"); + let m = model.as_deref().unwrap_or("unknown"); + let chunk = serde_json::json!({ + "id": id, + "object": "chat.completion.chunk", + "model": m, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "function": { + "arguments": delta, + } + }] + }, + "finish_reason": null, + }] + }); + Some(format!( + "data: {}", + serde_json::to_string(&chunk).unwrap_or_default() + )) + } + "response.completed" => { let id = response_id.as_deref().unwrap_or("chatcmpl-translate"); let m = model.as_deref().unwrap_or("unknown"); + let finish_reason = if tool_call_indices.is_empty() { + "stop" + } else { + "tool_calls" + }; let final_chunk = serde_json::json!({ "id": id, "object": "chat.completion.chunk", @@ -283,7 +585,7 @@ pub fn translate_sse_line( "choices": [{ "index": 0, "delta": {}, - "finish_reason": "stop", + "finish_reason": finish_reason, }] }); Some(format!( @@ -332,9 +634,9 @@ pub fn translate_sse_line( // Suppress all structural/metadata events "response.output_text.done" + | "response.function_call_arguments.done" | "response.content_part.added" | "response.content_part.done" - | "response.output_item.added" | "response.output_item.done" => None, // Suppress any other unknown events @@ -349,6 +651,8 @@ pub struct TranslateStream { buffer: BytesMut, response_id: Option, model: Option, + tool_call_indices: HashMap, + next_tool_call_index: usize, output_buffer: Vec, } @@ -358,6 +662,7 @@ impl std::fmt::Debug for TranslateStream { .field("buffer_len", &self.buffer.len()) .field("response_id", &self.response_id) .field("model", &self.model) + .field("tool_call_indices", &self.tool_call_indices) .finish() } } @@ -379,6 +684,8 @@ impl TranslateStream { buffer: BytesMut::new(), response_id: None, model: None, + tool_call_indices: HashMap::new(), + next_tool_call_index: 0, output_buffer: Vec::new(), } } @@ -395,7 +702,11 @@ impl TranslateStream { let rid = &mut self.response_id; let mdl = &mut self.model; - if let Some(translated) = translate_sse_line(&line, rid, mdl) { + let tool_indices = &mut self.tool_call_indices; + let next_tool_index = &mut self.next_tool_call_index; + if let Some(translated) = + translate_sse_line(&line, rid, mdl, tool_indices, next_tool_index) + { self.output_buffer.extend_from_slice(translated.as_bytes()); self.output_buffer.extend_from_slice(b"\n\n"); } @@ -429,9 +740,13 @@ impl Stream for TranslateStream { let remaining = std::mem::take(&mut this.buffer); let line = String::from_utf8_lossy(&remaining).trim().to_string(); if !line.is_empty() { - if let Some(translated) = - translate_sse_line(&line, &mut this.response_id, &mut this.model) - { + if let Some(translated) = translate_sse_line( + &line, + &mut this.response_id, + &mut this.model, + &mut this.tool_call_indices, + &mut this.next_tool_call_index, + ) { return Poll::Ready(Some(Ok(Bytes::from(format!( "{translated}\n\n" ))))); @@ -622,6 +937,22 @@ pub fn wrap_body_with_dlp_sse_stream( mod tests { use super::*; + fn translate_test_sse_line( + line: &str, + response_id: &mut Option, + model: &mut Option, + ) -> Option { + let mut tool_call_indices = HashMap::new(); + let mut next_tool_call_index = 0; + translate_sse_line( + line, + response_id, + model, + &mut tool_call_indices, + &mut next_tool_call_index, + ) + } + #[test] fn test_chat_to_responses_basic() { let body = serde_json::json!({ @@ -738,15 +1069,84 @@ mod tests { assert_eq!(parsed["model"], "gpt-4o-mini"); assert_eq!(parsed["stream"], true); - assert_eq!( - parsed["stream_options"], - serde_json::json!({"include_usage": true}) - ); - assert_eq!(parsed["temperature"], 0.7); + assert!(parsed.get("stream_options").is_none()); + assert!(parsed.get("temperature").is_none()); assert_eq!(parsed["top_p"], 0.9); assert_eq!(parsed["stop"], serde_json::json!(["\n"])); } + #[test] + fn test_chat_to_responses_converts_tools() { + let body = serde_json::json!({ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "list files"}], + "tools": [{ + "type": "function", + "function": { + "name": "terminal", + "description": "Execute shell commands", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string"} + }, + "required": ["command"] + } + } + }], + "tool_choice": "auto" + }); + let result = chat_completions_to_responses(body.to_string().as_bytes()).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!(parsed["tools"][0]["type"], "function"); + assert_eq!(parsed["tools"][0]["name"], "terminal"); + assert_eq!(parsed["tools"][0]["description"], "Execute shell commands"); + assert_eq!( + parsed["tools"][0]["parameters"]["properties"]["command"]["type"], + "string" + ); + assert_eq!(parsed["tool_choice"], "auto"); + } + + #[test] + fn test_chat_to_responses_converts_tool_messages() { + let body = serde_json::json!({ + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "list files"}, + { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_123", + "type": "function", + "function": { + "name": "terminal", + "arguments": "{\"command\":\"ls\"}" + } + }] + }, + { + "role": "tool", + "tool_call_id": "call_123", + "content": "README.md\nsrc" + } + ] + }); + let result = chat_completions_to_responses(body.to_string().as_bytes()).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let input = parsed["input"].as_array().unwrap(); + + assert_eq!(input[1]["type"], "function_call"); + assert_eq!(input[1]["call_id"], "call_123"); + assert_eq!(input[1]["name"], "terminal"); + assert_eq!(input[1]["arguments"], "{\"command\":\"ls\"}"); + assert_eq!(input[2]["type"], "function_call_output"); + assert_eq!(input[2]["call_id"], "call_123"); + assert_eq!(input[2]["output"], "README.md\nsrc"); + } + #[test] fn test_responses_to_chat_completion_basic() { let body = serde_json::json!({ @@ -800,6 +1200,40 @@ mod tests { assert_eq!(parsed["usage"]["total_tokens"], 75); } + #[test] + fn test_responses_to_chat_completion_tool_call() { + let body = serde_json::json!({ + "id": "resp_tools", + "model": "gpt-4o", + "status": "completed", + "output": [{ + "type": "function_call", + "call_id": "call_123", + "name": "terminal", + "arguments": "{\"command\":\"ls\"}" + }], + "usage": { + "input_tokens": 50, + "output_tokens": 25 + } + }); + let result = responses_to_chat_completion(body.to_string().as_bytes()).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let choice = &parsed["choices"][0]; + + assert_eq!(choice["finish_reason"], "tool_calls"); + assert!(choice["message"]["content"].is_null()); + assert_eq!(choice["message"]["tool_calls"][0]["id"], "call_123"); + assert_eq!( + choice["message"]["tool_calls"][0]["function"]["name"], + "terminal" + ); + assert_eq!( + choice["message"]["tool_calls"][0]["function"]["arguments"], + "{\"command\":\"ls\"}" + ); + } + #[test] fn test_responses_to_chat_completion_incomplete() { let body = serde_json::json!({ @@ -827,7 +1261,7 @@ mod tests { let line = format!("data: {}", event); let mut response_id = Some("resp_123".to_string()); let mut model = Some("gpt-4o-mini".to_string()); - let result = translate_sse_line(&line, &mut response_id, &mut model).unwrap(); + let result = translate_test_sse_line(&line, &mut response_id, &mut model).unwrap(); assert!(result.starts_with("data: ")); let json_str = result.strip_prefix("data: ").unwrap(); @@ -849,7 +1283,7 @@ mod tests { let line = format!("data: {}", event); let mut response_id = Some("resp_456".to_string()); let mut model = Some("gpt-4o".to_string()); - let result = translate_sse_line(&line, &mut response_id, &mut model).unwrap(); + let result = translate_test_sse_line(&line, &mut response_id, &mut model).unwrap(); // Should contain a final chunk with finish_reason: "stop" and then [DONE] assert!(result.contains("\"finish_reason\":\"stop\"")); @@ -866,7 +1300,7 @@ mod tests { "response": {"id": "resp_789", "model": "gpt-4o"} }); let result = - translate_sse_line(&format!("data: {}", created), &mut response_id, &mut model); + translate_test_sse_line(&format!("data: {}", created), &mut response_id, &mut model); assert!(result.is_none()); assert_eq!(response_id.as_deref(), Some("resp_789")); assert_eq!(model.as_deref(), Some("gpt-4o")); @@ -875,7 +1309,7 @@ mod tests { "type": "response.in_progress", "response": {"id": "resp_789"} }); - let result = translate_sse_line( + let result = translate_test_sse_line( &format!("data: {}", in_progress), &mut response_id, &mut model, @@ -884,7 +1318,7 @@ mod tests { // Structural events should also be suppressed let content_part = serde_json::json!({"type": "response.content_part.added"}); - let result = translate_sse_line( + let result = translate_test_sse_line( &format!("data: {}", content_part), &mut response_id, &mut model, @@ -896,10 +1330,76 @@ mod tests { fn test_sse_done_passthrough() { let mut response_id = None; let mut model = None; - let result = translate_sse_line("data: [DONE]", &mut response_id, &mut model); + let result = translate_test_sse_line("data: [DONE]", &mut response_id, &mut model); assert_eq!(result, Some("data: [DONE]".to_string())); } + #[test] + fn test_sse_tool_call_chunks() { + let mut response_id = Some("resp_123".to_string()); + let mut model = Some("gpt-4o".to_string()); + let mut tool_call_indices = HashMap::new(); + let mut next_tool_call_index = 0; + + let added = serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_123", + "name": "terminal", + "arguments": "" + } + }); + let result = translate_sse_line( + &format!("data: {}", added), + &mut response_id, + &mut model, + &mut tool_call_indices, + &mut next_tool_call_index, + ) + .unwrap(); + let parsed: Value = serde_json::from_str(result.strip_prefix("data: ").unwrap()).unwrap(); + assert_eq!(parsed["choices"][0]["delta"]["tool_calls"][0]["index"], 0); + assert_eq!( + parsed["choices"][0]["delta"]["tool_calls"][0]["function"]["name"], + "terminal" + ); + + let delta = serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "delta": "{\"command\":\"ls\"}" + }); + let result = translate_sse_line( + &format!("data: {}", delta), + &mut response_id, + &mut model, + &mut tool_call_indices, + &mut next_tool_call_index, + ) + .unwrap(); + let parsed: Value = serde_json::from_str(result.strip_prefix("data: ").unwrap()).unwrap(); + assert_eq!( + parsed["choices"][0]["delta"]["tool_calls"][0]["function"]["arguments"], + "{\"command\":\"ls\"}" + ); + + let completed = serde_json::json!({ + "type": "response.completed", + "response": {"id": "resp_123", "status": "completed"} + }); + let result = translate_sse_line( + &format!("data: {}", completed), + &mut response_id, + &mut model, + &mut tool_call_indices, + &mut next_tool_call_index, + ) + .unwrap(); + assert!(result.contains("\"finish_reason\":\"tool_calls\"")); + } + #[test] fn test_chat_to_responses_multipart_content_types() { let body = serde_json::to_vec(&serde_json::json!({ diff --git a/tests/snapshots/config_fixtures__unknown_provider.snap b/tests/snapshots/config_fixtures__unknown_provider.snap index 100dd90..f3217c3 100644 --- a/tests/snapshots/config_fixtures__unknown_provider.snap +++ b/tests/snapshots/config_fixtures__unknown_provider.snap @@ -1,10 +1,10 @@ --- source: src/config.rs -assertion_line: 651 +assertion_line: 669 expression: err.to_string() --- TOML parse error at line 7, column 12 | 7 | provider = "azure" | ^^^^^^^ -unknown variant `azure`, expected one of `openai`, `openrouter`, `anthropic`, `minimax` +unknown variant `azure`, expected one of `openai`, `openrouter`, `anthropic`, `minimax`, `opencode`