diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 3e8f2a20e..a9b470cf6 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -2793,6 +2793,15 @@ mod tests { assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); } + #[test] + fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() { + let (endpoint, passthrough_query) = + rewrite_codex_responses_endpoint_to_chat("/v1/responses/compact?foo=bar"); + + assert_eq!(endpoint, "/chat/completions?foo=bar"); + assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); + } + #[test] fn rewrite_claude_transform_endpoint_uses_copilot_path() { let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index a46670b92..e5e54288b 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -464,4 +464,21 @@ wire_api = "chat" "/v1/responses/compact" )); } + + #[test] + fn test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact() { + let mut provider = create_provider(json!({ + "base_url": "https://example.com/v1" + })); + provider.meta = Some(crate::provider::ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }); + + assert!(codex_provider_uses_chat_completions(&provider)); + assert!(should_convert_codex_responses_to_chat( + &provider, + "/responses/compact?stream=true" + )); + } } diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index b479d7cb4..44a3e17f2 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -18,6 +18,15 @@ struct TextItemState { done: bool, } +#[derive(Debug, Default)] +struct ReasoningItemState { + output_index: Option, + item_id: String, + text: String, + added: bool, + done: bool, +} + #[derive(Debug, Default)] struct ToolCallState { output_index: Option, @@ -38,8 +47,9 @@ struct ChatToResponsesState { created_at: u64, next_output_index: u32, text: TextItemState, + reasoning: ReasoningItemState, tools: BTreeMap, - output_items: Vec, + output_items: Vec<(u32, Value)>, latest_usage: Option, finish_reason: Option, } @@ -54,6 +64,7 @@ impl Default for ChatToResponsesState { created_at: 0, next_output_index: 0, text: TextItemState::default(), + reasoning: ReasoningItemState::default(), tools: BTreeMap::new(), output_items: Vec::new(), latest_usage: None, @@ -93,13 +104,19 @@ impl ChatToResponsesState { }; if let Some(delta) = choice.get("delta") { + if let Some(reasoning) = chat_delta_reasoning_text(delta) { + events.extend(self.push_reasoning_delta(&reasoning)); + } + if let Some(content) = delta.get("content").and_then(|v| v.as_str()) { if !content.is_empty() { + events.extend(self.finalize_reasoning()); events.extend(self.push_text_delta(content)); } } if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) { + events.extend(self.finalize_reasoning()); for tool_call in tool_calls { events.extend(self.push_tool_call_delta(tool_call)); } @@ -139,6 +156,60 @@ impl ChatToResponsesState { ] } + fn push_reasoning_delta(&mut self, delta: &str) -> Vec { + let mut events = Vec::new(); + + if !self.reasoning.added { + let output_index = self.next_output_index(); + let item_id = format!("rs_{}", self.response_id); + self.reasoning.output_index = Some(output_index); + self.reasoning.item_id = item_id.clone(); + self.reasoning.added = true; + + events.push(sse_event( + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "id": item_id, + "type": "reasoning", + "status": "in_progress", + "summary": [] + } + }), + )); + events.push(sse_event( + "response.reasoning_summary_part.added", + json!({ + "type": "response.reasoning_summary_part.added", + "item_id": self.reasoning.item_id, + "output_index": output_index, + "summary_index": 0, + "part": { + "type": "summary_text", + "text": "" + } + }), + )); + } + + self.reasoning.text.push_str(delta); + let output_index = self.reasoning.output_index.unwrap_or(0); + events.push(sse_event( + "response.reasoning_summary_text.delta", + json!({ + "type": "response.reasoning_summary_text.delta", + "item_id": self.reasoning.item_id, + "output_index": output_index, + "summary_index": 0, + "delta": delta + }), + )); + + events + } + fn push_text_delta(&mut self, delta: &str) -> Vec { let mut events = Vec::new(); @@ -304,11 +375,12 @@ impl ChatToResponsesState { } let mut events = self.ensure_response_started(); + events.extend(self.finalize_reasoning()); events.extend(self.finalize_text()); events.extend(self.finalize_tools()); let status = response_status_from_finish_reason(self.finish_reason.as_deref()); - let mut response = self.base_response(status, self.output_items.clone()); + let mut response = self.base_response(status, self.completed_output_items()); if status == "incomplete" { response["incomplete_details"] = json!({ "reason": "max_output_tokens" }); } @@ -324,6 +396,60 @@ impl ChatToResponsesState { events } + fn finalize_reasoning(&mut self) -> Vec { + if !self.reasoning.added || self.reasoning.done { + return Vec::new(); + } + + let output_index = self.reasoning.output_index.unwrap_or(0); + let item_id = self.reasoning.item_id.clone(); + let text = self.reasoning.text.clone(); + let item = json!({ + "id": item_id, + "type": "reasoning", + "summary": [{ + "type": "summary_text", + "text": text + }] + }); + self.output_items.push((output_index, item.clone())); + self.reasoning.done = true; + + vec![ + sse_event( + "response.reasoning_summary_text.done", + json!({ + "type": "response.reasoning_summary_text.done", + "item_id": self.reasoning.item_id, + "output_index": output_index, + "summary_index": 0, + "text": self.reasoning.text + }), + ), + sse_event( + "response.reasoning_summary_part.done", + json!({ + "type": "response.reasoning_summary_part.done", + "item_id": self.reasoning.item_id, + "output_index": output_index, + "summary_index": 0, + "part": { + "type": "summary_text", + "text": self.reasoning.text + } + }), + ), + sse_event( + "response.output_item.done", + json!({ + "type": "response.output_item.done", + "output_index": output_index, + "item": item + }), + ), + ] + } + fn finalize_text(&mut self) -> Vec { if !self.text.added || self.text.done { return Vec::new(); @@ -341,7 +467,7 @@ impl ChatToResponsesState { "annotations": [] }] }); - self.output_items.push(item.clone()); + self.output_items.push((output_index, item.clone())); self.text.done = true; vec![ @@ -439,7 +565,7 @@ impl ChatToResponsesState { "arguments": state.arguments }); state.done = true; - self.output_items.push(item.clone()); + self.output_items.push((output_index, item.clone())); events.push(sse_event( "response.function_call_arguments.done", @@ -463,6 +589,15 @@ impl ChatToResponsesState { events } + fn completed_output_items(&self) -> Vec { + let mut output_items = self.output_items.clone(); + output_items.sort_by_key(|(output_index, _)| *output_index); + output_items + .into_iter() + .map(|(_, item)| item) + .collect::>() + } + fn base_response(&self, status: &str, output: Vec) -> Value { json!({ "id": self.response_id, @@ -494,7 +629,7 @@ impl ChatToResponsesState { error["type"] = json!(error_type); } - let mut response = self.base_response("failed", self.output_items.clone()); + let mut response = self.base_response("failed", self.completed_output_items()); response["error"] = error; sse_event( @@ -507,6 +642,27 @@ impl ChatToResponsesState { } } +fn chat_delta_reasoning_text(delta: &Value) -> Option { + for key in ["reasoning_content", "reasoning"] { + if let Some(text) = delta.get(key).and_then(|v| v.as_str()) { + if !text.is_empty() { + return Some(text.to_string()); + } + } + } + + let reasoning = delta.get("reasoning")?; + for key in ["content", "text", "summary"] { + if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) { + if !text.is_empty() { + return Some(text.to_string()); + } + } + } + + None +} + /// Create a stream that converts Chat Completions SSE chunks into Responses SSE events. pub fn create_responses_sse_stream_from_chat( stream: impl Stream> + Send + 'static, @@ -653,6 +809,29 @@ mod tests { assert!(output.contains("\"input_tokens\":4")); } + #[tokio::test] + async fn converts_reasoning_content_chat_sse_to_responses_reasoning_events() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_reason\",\"created\":123,\"model\":\"deepseek-reasoner\",\"choices\":[{\"delta\":{\"reasoning_content\":\"Need context. \"}}]}\n\n", + "data: {\"id\":\"chatcmpl_reason\",\"created\":123,\"model\":\"deepseek-reasoner\",\"choices\":[{\"delta\":{\"reasoning\":\"Now answer. \"}}]}\n\n", + "data: {\"id\":\"chatcmpl_reason\",\"created\":123,\"model\":\"deepseek-reasoner\",\"choices\":[{\"delta\":{\"content\":\"Done\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":6,\"total_tokens\":10,\"completion_tokens_details\":{\"reasoning_tokens\":3}}}\n\n", + "data: [DONE]\n\n", + ]) + .await; + + assert!(output.contains("event: response.reasoning_summary_part.added")); + assert!(output.contains("event: response.reasoning_summary_text.delta")); + assert!(output.contains("event: response.reasoning_summary_text.done")); + assert!(output.contains("Need context. Now answer. ")); + assert!(output.contains("\"type\":\"reasoning\"")); + assert!(output.contains("\"text\":\"Done\"")); + assert!(output.contains("\"reasoning_tokens\":3")); + + let reasoning_pos = output.find("\"type\":\"reasoning\"").unwrap(); + let message_pos = output.find("\"type\":\"message\"").unwrap(); + assert!(reasoning_pos < message_pos); + } + #[tokio::test] async fn converts_tool_call_chat_sse_to_responses_sse() { let output = collect(vec![ @@ -694,4 +873,18 @@ mod tests { assert!(output.contains("invalid_request_error")); assert!(!output.contains("event: response.completed")); } + + #[tokio::test] + async fn chat_sse_data_only_error_emits_failed_without_completed() { + let output = collect(vec![ + "data: {\"error\":{\"message\":\"quota exceeded\",\"code\":\"rate_limit_exceeded\"}}\n\n", + "data: [DONE]\n\n", + ]) + .await; + + assert!(output.contains("event: response.failed")); + assert!(output.contains("quota exceeded")); + assert!(output.contains("rate_limit_exceeded")); + assert!(!output.contains("event: response.completed")); + } } diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index de241a1d7..c92c76684 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -371,6 +371,9 @@ pub fn chat_completion_to_response(body: Value) -> Result { let finish_reason = choice.get("finish_reason").and_then(|v| v.as_str()); let mut output = Vec::new(); + if let Some(reasoning_item) = chat_reasoning_to_response_output_item(message, &response_id) { + output.push(reasoning_item); + } if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) { output.push(message_item); } @@ -393,6 +396,43 @@ pub fn chat_completion_to_response(body: Value) -> Result { Ok(response) } +fn chat_reasoning_to_response_output_item(message: &Value, response_id: &str) -> Option { + let reasoning = chat_reasoning_text(message)?; + if reasoning.is_empty() { + return None; + } + + Some(json!({ + "id": format!("rs_{response_id}"), + "type": "reasoning", + "summary": [{ + "type": "summary_text", + "text": reasoning + }] + })) +} + +fn chat_reasoning_text(message: &Value) -> Option { + for key in ["reasoning_content", "reasoning"] { + if let Some(text) = message.get(key).and_then(|v| v.as_str()) { + if !text.is_empty() { + return Some(text.to_string()); + } + } + } + + let reasoning = message.get("reasoning")?; + for key in ["content", "text", "summary"] { + if let Some(text) = reasoning.get(key).and_then(|v| v.as_str()) { + if !text.is_empty() { + return Some(text.to_string()); + } + } + } + + None +} + fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> Option { let mut content = Vec::new(); @@ -650,6 +690,55 @@ mod tests { assert_eq!(result["reasoning_effort"], "high"); } + #[test] + fn responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs() { + let input = json!({ + "model": "gpt-5.4", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": "{\"path\":\"README.md\"}" + }, + { + "type": "function_call", + "call_id": "call_2", + "name": "list_files", + "arguments": "{\"path\":\"src\"}" + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "Readme content" + }, + { + "type": "function_call_output", + "call_id": "call_2", + "output": ["main.rs", "lib.rs"] + }, + { + "role": "user", + "content": "Continue" + } + ] + }); + + let result = responses_to_chat_completions(input).unwrap(); + let messages = result["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 4); + assert_eq!(messages[0]["role"], "assistant"); + assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1"); + assert_eq!(messages[0]["tool_calls"][1]["id"], "call_2"); + assert_eq!(messages[1]["role"], "tool"); + assert_eq!(messages[1]["tool_call_id"], "call_1"); + assert_eq!(messages[2]["role"], "tool"); + assert_eq!(messages[2]["tool_call_id"], "call_2"); + assert_eq!(messages[2]["content"], "[\"main.rs\",\"lib.rs\"]"); + assert_eq!(messages[3]["role"], "user"); + } + #[test] fn chat_response_to_responses_maps_text_tool_calls_and_usage() { let input = json!({ @@ -660,6 +749,7 @@ mod tests { "choices": [{ "message": { "role": "assistant", + "reasoning_content": "I should check the weather before answering.", "content": "Let me check.", "tool_calls": [{ "id": "call_1", @@ -684,10 +774,15 @@ mod tests { assert_eq!(result["id"], "resp_chatcmpl_1"); assert_eq!(result["status"], "completed"); - assert_eq!(result["output"][0]["type"], "message"); - assert_eq!(result["output"][0]["content"][0]["text"], "Let me check."); - assert_eq!(result["output"][1]["type"], "function_call"); - assert_eq!(result["output"][1]["call_id"], "call_1"); + assert_eq!(result["output"][0]["type"], "reasoning"); + assert_eq!( + result["output"][0]["summary"][0]["text"], + "I should check the weather before answering." + ); + assert_eq!(result["output"][1]["type"], "message"); + assert_eq!(result["output"][1]["content"][0]["text"], "Let me check."); + assert_eq!(result["output"][2]["type"], "function_call"); + assert_eq!(result["output"][2]["call_id"], "call_1"); assert_eq!(result["usage"]["input_tokens"], 10); assert_eq!(result["usage"]["output_tokens"], 5); assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3);