Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src-tauri/src/proxy/providers/streaming_codex_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
60 changes: 10 additions & 50 deletions src-tauri/src/proxy/providers/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -257,10 +251,6 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
}

let mut parts = Vec::new();
let mut inherited_cache_control: Option<Value> = 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;
Expand All @@ -281,27 +271,11 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
_ => {}
}

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);
}
}
Expand Down Expand Up @@ -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" => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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");
}

Expand Down Expand Up @@ -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,
Expand All @@ -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]
Expand All @@ -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 {
Expand Down
26 changes: 22 additions & 4 deletions src-tauri/src/proxy/providers/transform_codex_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}
31 changes: 11 additions & 20 deletions src-tauri/tests/proxy_claude_openai_chat/transform_cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down Expand Up @@ -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"
);
}

Expand Down
Loading