From 81a6fb4952d78fa1bf32b0e28713194370a4bc91 Mon Sep 17 00:00:00 2001 From: thedavidweng <95214375+thedavidweng@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:07:11 +0000 Subject: [PATCH 1/4] fix(proxy): strip cache_control from OpenAI conversion, guard tool_choice, handle truncated streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove cache_control passthrough from system messages, text blocks, and tools during Anthropic→OpenAI format conversion to prevent 400 errors on strict OpenAI-compatible endpoints - Always simplify single text block content to plain string format - Move tool_choice inside the tools guard so it is omitted when tools is empty, preventing upstream errors - Handle truncated chat streams with a three-way guard: complete normally when finish_reason is present, emit incomplete response when output exists without finish_reason, emit failed event for empty truncation instead of masking as completed Closes #257 --- .../proxy/providers/streaming_codex_chat.rs | 27 +++++++++++++ src-tauri/src/proxy/providers/transform.rs | 40 ++----------------- .../proxy/providers/transform_codex_chat.rs | 7 ++-- 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 1146d095..83566b05 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -533,6 +533,33 @@ 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..44220b50 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); diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index a3568dce..458767ab 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 { From aa674a6f290d04d3db9f272a295be5f38826cafb Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:20:51 -0700 Subject: [PATCH 2/4] fix: update tests for cache_control stripping and fix fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update 5 transform tests to assert cache_control is stripped (not preserved) since the PR intentionally removes cache_control from Anthropic→OpenAI conversion - Simplify text block test to reflect single-block-to-string optimization - Fix multiline assignment formatting in streaming_codex_chat.rs --- .../proxy/providers/streaming_codex_chat.rs | 3 +-- src-tauri/src/proxy/providers/transform.rs | 20 ++++++------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 83566b05..69a7ad18 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -546,8 +546,7 @@ impl ChatToResponsesState { )); } else { let mut response = self.base_response("incomplete", output); - response["incomplete_details"] = - json!({ "reason": "stream_truncated" }); + response["incomplete_details"] = json!({ "reason": "stream_truncated" }); events.push(sse_event( "response.completed", json!({ diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 44220b50..2e8298e9 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -624,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] @@ -690,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] @@ -713,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"); } @@ -762,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, @@ -778,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] @@ -805,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 { From 36a6e6be714fa5feb50cc5303e6eec254dd7776f Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:26:02 -0700 Subject: [PATCH 3/4] test: add test for tool_choice omission when tools is empty The PR moved tool_choice inside the tools guard so it is omitted when no tools are provided. Add a test verifying this behavior to prevent regression and upstream 400 errors on strict endpoints. --- .../proxy/providers/transform_codex_chat.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 458767ab..388860c6 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -2814,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" + ); + } } From 590e3e33b8c9457136fcedefe81a67e3d529c50e Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:16:01 -0700 Subject: [PATCH 4/4] test: update cache_control test to match stripping behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test on main asserted cache_control was preserved during Anthropic→OpenAI conversion, but this branch strips it to prevent 400 errors on strict OpenAI-compatible endpoints. --- .../transform_cases.rs | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) 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" ); }