diff --git a/src/oauth/codex.rs b/src/oauth/codex.rs index 4613b6b..80c79b9 100644 --- a/src/oauth/codex.rs +++ b/src/oauth/codex.rs @@ -462,6 +462,7 @@ async fn wait_for_oauth_callback( /// - Strip provider prefix from model (e.g. "openai/gpt-5.2-codex" → "gpt-5.2-codex") /// - Set `store: false` (required by ChatGPT backend) /// - Set `stream: true` (required by ChatGPT backend) +/// - Supply Codex Responses defaults that native Codex clients normally send. fn fixup_for_chatgpt_backend(body: &[u8]) -> Vec { let Ok(mut parsed) = serde_json::from_slice::(body) else { return body.to_vec(); @@ -476,10 +477,93 @@ fn fixup_for_chatgpt_backend(body: &[u8]) -> Vec { // Codex backend does not support max_output_tokens if let Some(obj) = parsed.as_object_mut() { obj.remove("max_output_tokens"); + apply_codex_responses_defaults(obj); } serde_json::to_vec(&parsed).unwrap_or_else(|_| body.to_vec()) } +fn apply_codex_responses_defaults(obj: &mut serde_json::Map) { + let mut remove_reasoning = false; + let reasoning_enabled = match obj.get_mut("reasoning") { + Some(reasoning) if !reasoning.is_object() => { + *reasoning = serde_json::json!({"effort": "medium", "summary": "auto"}); + true + } + Some(reasoning) => { + let reasoning = reasoning + .as_object_mut() + .expect("object reasoning was checked above"); + let reasoning_disabled = reasoning + .get("enabled") + .and_then(serde_json::Value::as_bool) + == Some(false) + || reasoning + .get("effort") + .and_then(serde_json::Value::as_str) + .is_some_and(|effort| effort == "none"); + if reasoning_disabled { + remove_reasoning = true; + false + } else { + reasoning.remove("enabled"); + if reasoning + .get("effort") + .and_then(serde_json::Value::as_str) + .is_some_and(|effort| effort == "minimal") + { + reasoning.insert( + "effort".to_string(), + serde_json::Value::String("low".to_string()), + ); + } + reasoning + .entry("effort".to_string()) + .or_insert_with(|| serde_json::Value::String("medium".to_string())); + reasoning + .entry("summary".to_string()) + .or_insert_with(|| serde_json::Value::String("auto".to_string())); + true + } + } + None => { + obj.insert( + "reasoning".to_string(), + serde_json::json!({"effort": "medium", "summary": "auto"}), + ); + true + } + }; + if remove_reasoning { + obj.remove("reasoning"); + } + + if !obj.contains_key("include") { + let include = if reasoning_enabled { + serde_json::json!(["reasoning.encrypted_content"]) + } else { + serde_json::json!([]) + }; + obj.insert("include".to_string(), include); + } + + let has_tools = obj + .get("tools") + .and_then(serde_json::Value::as_array) + .is_some_and(|tools| !tools.is_empty()); + if has_tools && !obj.contains_key("tool_choice") { + obj.insert( + "tool_choice".to_string(), + serde_json::Value::String("auto".to_string()), + ); + } + if has_tools && !obj.contains_key("parallel_tool_calls") { + obj.insert( + "parallel_tool_calls".to_string(), + serde_json::Value::Bool(true), + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -694,4 +778,80 @@ mod tests { assert_eq!(parsed["model"], "gpt-4o-mini"); assert_eq!(parsed["store"], false); } + + #[test] + fn test_fixup_sets_codex_responses_defaults_for_tools() { + let body = serde_json::to_vec(&serde_json::json!({ + "model": "gpt-5.3-codex", + "input": [{"role": "user", "content": "inspect the repo"}], + "tools": [{"type": "function", "name": "terminal"}] + })) + .unwrap(); + let result = fixup_for_chatgpt_backend(&body); + let parsed: serde_json::Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["reasoning"], + serde_json::json!({"effort": "medium", "summary": "auto"}) + ); + assert_eq!( + parsed["include"], + serde_json::json!(["reasoning.encrypted_content"]) + ); + assert_eq!(parsed["tool_choice"], "auto"); + assert_eq!(parsed["parallel_tool_calls"], true); + } + + #[test] + fn test_fixup_preserves_explicit_codex_responses_controls() { + let body = serde_json::to_vec(&serde_json::json!({ + "model": "gpt-5.3-codex", + "input": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", "name": "terminal"}], + "include": [], + "tool_choice": "required", + "parallel_tool_calls": false, + "reasoning": {"enabled": true, "effort": "high", "summary": "auto"} + })) + .unwrap(); + let result = fixup_for_chatgpt_backend(&body); + let parsed: serde_json::Value = serde_json::from_slice(&result).unwrap(); + + assert_eq!( + parsed["reasoning"], + serde_json::json!({"effort": "high", "summary": "auto"}) + ); + assert_eq!(parsed["include"], serde_json::json!([])); + assert_eq!(parsed["tool_choice"], "required"); + assert_eq!(parsed["parallel_tool_calls"], false); + } + + #[test] + fn test_fixup_normalizes_chat_reasoning_controls_for_codex() { + let disabled_body = serde_json::to_vec(&serde_json::json!({ + "model": "gpt-5.3-codex", + "input": [{"role": "user", "content": "hi"}], + "reasoning": {"effort": "none"} + })) + .unwrap(); + let disabled_result = fixup_for_chatgpt_backend(&disabled_body); + let disabled: serde_json::Value = serde_json::from_slice(&disabled_result).unwrap(); + + assert!(disabled.get("reasoning").is_none()); + assert_eq!(disabled["include"], serde_json::json!([])); + + let minimal_body = serde_json::to_vec(&serde_json::json!({ + "model": "gpt-5.3-codex", + "input": [{"role": "user", "content": "hi"}], + "reasoning": {"enabled": true, "effort": "minimal"} + })) + .unwrap(); + let minimal_result = fixup_for_chatgpt_backend(&minimal_body); + let minimal: serde_json::Value = serde_json::from_slice(&minimal_result).unwrap(); + + assert_eq!( + minimal["reasoning"], + serde_json::json!({"effort": "low", "summary": "auto"}) + ); + } } diff --git a/src/translate.rs b/src/translate.rs index 1e6dac8..61ab355 100644 --- a/src/translate.rs +++ b/src/translate.rs @@ -20,7 +20,19 @@ pub enum TranslateError { } /// Fields that are compatible between chat/completions and responses API. -const PASSTHROUGH_FIELDS: &[&str] = &["model", "stream", "top_p", "stop"]; +/// +/// Some clients attach Responses-compatible fields as chat-completions +/// `extra_body` values when they are talking through a compatibility proxy. +const PASSTHROUGH_FIELDS: &[&str] = &[ + "model", + "stream", + "top_p", + "stop", + "include", + "parallel_tool_calls", + "prompt_cache_key", + "reasoning", +]; /// Fields that must be stripped from chat/completions requests (not supported by responses API). const STRIP_FIELDS: &[&str] = &[ @@ -129,21 +141,24 @@ fn convert_message_to_input_items(msg: &Value) -> Vec { } let mut items = Vec::new(); - let mut msg = msg.clone(); - let is_assistant = role == "assistant"; - - // Responses API requires "type": "message" on each input item - if let Some(obj) = msg.as_object_mut() { - if !obj.contains_key("type") { - obj.insert("type".to_string(), Value::String("message".to_string())); - } + let mut input_message = serde_json::Map::new(); + input_message.insert("role".to_string(), Value::String(role.to_string())); + if let Some(content) = msg.get("content") { + input_message.insert("content".to_string(), content.clone()); + } + if let Some(phase) = msg.get("phase") { + input_message.insert("phase".to_string(), phase.clone()); } + input_message.insert("type".to_string(), Value::String("message".to_string())); - let has_content = msg + let mut input_message = Value::Object(input_message); + let is_assistant = role == "assistant"; + + let has_content = input_message .get("content") .is_some_and(|content| !content.is_null() && content != ""); if has_content { - if let Some(content) = msg.get_mut("content") { + if let Some(content) = input_message.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 { @@ -169,7 +184,7 @@ fn convert_message_to_input_items(msg: &Value) -> Vec { } } } - items.push(msg.clone()); + items.push(input_message.clone()); } if let Some(tool_calls) = msg.get("tool_calls").and_then(Value::as_array) { @@ -198,7 +213,7 @@ fn convert_message_to_input_items(msg: &Value) -> Vec { } if items.is_empty() { - items.push(msg); + items.push(input_message); } items } @@ -1053,6 +1068,33 @@ mod tests { } } + #[test] + fn test_chat_to_responses_strips_unsupported_message_fields() { + let body = serde_json::json!({ + "model": "gpt-5-codex", + "messages": [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "previous answer", + "phase": "final_answer", + "name": "weixin-agent", + "reasoning_content": "provider-specific reasoning" + } + ] + }); + let result = chat_completions_to_responses(body.to_string().as_bytes()).unwrap(); + let parsed: Value = serde_json::from_slice(&result).unwrap(); + let assistant = &parsed["input"][1]; + + assert_eq!(assistant["type"], "message"); + assert_eq!(assistant["role"], "assistant"); + assert_eq!(assistant["content"], "previous answer"); + assert_eq!(assistant["phase"], "final_answer"); + assert!(assistant.get("name").is_none()); + assert!(assistant.get("reasoning_content").is_none()); + } + #[test] fn test_chat_to_responses_passthrough() { let body = serde_json::json!({ @@ -1062,7 +1104,11 @@ mod tests { "stream_options": {"include_usage": true}, "temperature": 0.7, "top_p": 0.9, - "stop": ["\n"] + "stop": ["\n"], + "include": ["reasoning.encrypted_content"], + "parallel_tool_calls": false, + "prompt_cache_key": "session-123", + "reasoning": {"effort": "high"} }); let result = chat_completions_to_responses(body.to_string().as_bytes()).unwrap(); let parsed: Value = serde_json::from_slice(&result).unwrap(); @@ -1073,6 +1119,13 @@ mod tests { assert!(parsed.get("temperature").is_none()); assert_eq!(parsed["top_p"], 0.9); assert_eq!(parsed["stop"], serde_json::json!(["\n"])); + assert_eq!( + parsed["include"], + serde_json::json!(["reasoning.encrypted_content"]) + ); + assert_eq!(parsed["parallel_tool_calls"], false); + assert_eq!(parsed["prompt_cache_key"], "session-123"); + assert_eq!(parsed["reasoning"], serde_json::json!({"effort": "high"})); } #[test]