diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 1146d095..69a7ad18 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -533,6 +533,32 @@ impl ChatToResponsesState { events.extend(self.finalize_text()); events.extend(self.finalize_tools()); + // When finish_reason is absent, the stream was truncated without a proper + // completion signal. Distinguish between streams that produced substantive + // output (report as incomplete) and those that produced nothing (report as + // failed with stream_truncated). + if self.finish_reason.is_none() { + let output = self.completed_output_items(); + if output.is_empty() { + events.push(self.failed_event( + "Stream truncated before any output was produced".to_string(), + Some("stream_truncated".to_string()), + )); + } else { + let mut response = self.base_response("incomplete", output); + response["incomplete_details"] = json!({ "reason": "stream_truncated" }); + events.push(sse_event( + "response.completed", + json!({ + "type": "response.completed", + "response": response + }), + )); + } + self.completed = true; + return events; + } + let status = response_status_from_finish_reason(self.finish_reason.as_deref()); let mut response = self.base_response(status, self.completed_output_items()); if status == "incomplete" { diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 3bc84386..2e8298e9 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -119,10 +119,7 @@ pub fn anthropic_to_openai_with_reasoning_content( let Some(text) = sanitize_system_text(text) else { continue; }; - let mut system_message = json!({"role": "system", "content": text}); - if let Some(cache_control) = msg.get("cache_control") { - system_message["cache_control"] = cache_control.clone(); - } + let system_message = json!({"role": "system", "content": text}); messages.push(system_message); } } @@ -184,9 +181,6 @@ pub fn anthropic_to_openai_with_reasoning_content( "parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({}))) } }); - if let Some(cache_control) = t.get("cache_control") { - tool["cache_control"] = cache_control.clone(); - } tool }) .collect(); @@ -257,10 +251,6 @@ fn normalize_openai_system_messages(messages: &mut Vec) { } let mut parts = Vec::new(); - let mut inherited_cache_control: Option = None; - let mut cache_control_conflict = false; - let mut saw_cache_control = false; - let mut saw_missing_cache_control = false; messages.retain(|message| { if message.get("role").and_then(|value| value.as_str()) != Some("system") { return true; @@ -281,27 +271,11 @@ fn normalize_openai_system_messages(messages: &mut Vec) { _ => {} } - if let Some(cache_control) = message.get("cache_control") { - saw_cache_control = true; - match &inherited_cache_control { - None => inherited_cache_control = Some(cache_control.clone()), - Some(existing) if existing == cache_control => {} - Some(_) => cache_control_conflict = true, - } - } else { - saw_missing_cache_control = true; - } - false }); if !parts.is_empty() { - let mut merged = json!({"role": "system", "content": parts.join("\n")}); - if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) { - if let Some(cache_control) = inherited_cache_control { - merged["cache_control"] = cache_control; - } - } + let merged = json!({"role": "system", "content": parts.join("\n")}); messages.insert(0, merged); } } @@ -336,11 +310,7 @@ fn convert_message_to_openai( match block_type { "text" => { if let Some(text) = block.get("text").and_then(|t| t.as_str()) { - let mut part = json!({"type": "text", "text": text}); - if let Some(cache_control) = block.get("cache_control") { - part["cache_control"] = cache_control.clone(); - } - content_parts.push(part); + content_parts.push(json!({"type": "text", "text": text})); } } "image" => { @@ -405,9 +375,7 @@ fn convert_message_to_openai( if content_parts.is_empty() { msg["content"] = Value::Null; } else if content_parts.len() == 1 { - if content_parts[0].get("cache_control").is_some() { - msg["content"] = json!(content_parts); - } else if let Some(text) = content_parts[0].get("text") { + if let Some(text) = content_parts[0].get("text") { msg["content"] = text.clone(); } else { msg["content"] = json!(content_parts); @@ -656,7 +624,7 @@ mod tests { let result = anthropic_to_openai(input, None).unwrap(); assert_eq!(result["messages"][0]["content"], "Project instructions"); - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["messages"][0].get("cache_control").is_none()); } #[test] @@ -722,7 +690,7 @@ mod tests { let result = anthropic_to_openai(input, None).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["messages"][0].get("cache_control").is_none()); } #[test] @@ -745,7 +713,7 @@ mod tests { result["messages"][0]["content"], "You are Claude Code.\nBe concise." ); - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["messages"][0].get("cache_control").is_none()); assert_eq!(result["messages"][1]["role"], "user"); } @@ -794,7 +762,7 @@ mod tests { } #[test] - fn anthropic_to_openai_preserves_text_block_cache_control_and_array_shape() { + fn anthropic_to_openai_strips_text_block_cache_control_and_simplifies_single_block() { let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -810,15 +778,7 @@ mod tests { let result = anthropic_to_openai(input, None).unwrap(); - assert!(result["messages"][0]["content"].is_array()); - assert_eq!( - result["messages"][0]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - assert_eq!( - result["messages"][0]["content"][0]["cache_control"]["ttl"], - "5m" - ); + assert_eq!(result["messages"][0]["content"], "Hello"); } #[test] @@ -837,7 +797,7 @@ mod tests { let result = anthropic_to_openai(input, None).unwrap(); - assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["tools"][0].get("cache_control").is_none()); } fn run_tool_choice(value: Value) -> Value { diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index a3568dce..388860c6 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -308,10 +308,9 @@ pub fn responses_to_chat_completions_with_reasoning( let tools = tool_context.chat_tools(); if !tools.is_empty() { result["tools"] = json!(tools); - } - - if let Some(tool_choice) = body.get("tool_choice") { - result["tool_choice"] = responses_tool_choice_to_chat(tool_choice, &tool_context); + if let Some(tool_choice) = body.get("tool_choice") { + result["tool_choice"] = responses_tool_choice_to_chat(tool_choice, &tool_context); + } } for key in EXTRA_CHAT_PASSTHROUGH_FIELDS { @@ -2815,4 +2814,23 @@ mod tests { assert_eq!(result["error"]["message"], "rate limit exceeded"); assert_eq!(result["error"]["type"], "upstream_error"); } + + #[test] + fn responses_request_to_chat_omits_tool_choice_when_tools_empty() { + // tool_choice should be omitted when no tools are provided, + // otherwise strict upstream endpoints reject the request. + let input = json!({ + "model": "gpt-5.4", + "tool_choice": "auto", + "input": "hello" + }); + + let result = responses_to_chat_completions(input).unwrap(); + + assert!(result.get("tools").is_none()); + assert!( + result.get("tool_choice").is_none(), + "tool_choice should be omitted when tools is empty" + ); + } } diff --git a/src-tauri/tests/proxy_claude_openai_chat/transform_cases.rs b/src-tauri/tests/proxy_claude_openai_chat/transform_cases.rs index e06a9cc4..dedd4c5c 100644 --- a/src-tauri/tests/proxy_claude_openai_chat/transform_cases.rs +++ b/src-tauri/tests/proxy_claude_openai_chat/transform_cases.rs @@ -58,7 +58,7 @@ async fn cache_openai_chat_omits_prompt_cache_key_without_explicit_override() { } #[tokio::test] -async fn cache_openai_chat_preserves_cache_control_metadata() { +async fn cache_openai_chat_strips_cache_control_metadata() { let upstream_body = capture_openai_chat_upstream_body( "provider-fallback-id", provider_meta_from_json(json!({ @@ -90,29 +90,20 @@ async fn cache_openai_chat_preserves_cache_control_metadata() { ) .await; - assert_eq!( - upstream_body - .pointer("/messages/0/cache_control/type") - .and_then(|value| value.as_str()), - Some("ephemeral") - ); - assert_eq!( - upstream_body - .pointer("/messages/1/content/0/cache_control/type") - .and_then(|value| value.as_str()), - Some("ephemeral") + assert!( + upstream_body.pointer("/messages/0/cache_control").is_none(), + "system message cache_control should be stripped" ); assert_eq!( upstream_body - .pointer("/messages/1/content/0/cache_control/ttl") - .and_then(|value| value.as_str()), - Some("5m") + .pointer("/messages/1/content") + .and_then(|v| v.as_str()), + Some("hello"), + "single text block should be simplified to plain string" ); - assert_eq!( - upstream_body - .pointer("/tools/0/cache_control/type") - .and_then(|value| value.as_str()), - Some("ephemeral") + assert!( + upstream_body.pointer("/tools/0/cache_control").is_none(), + "tool cache_control should be stripped" ); }