From 84aa87c3dd678243d5e23bb1e16a922487247ce1 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 13 May 2026 23:20:55 +0800 Subject: [PATCH] fix(proxy): map Anthropic tool_choice to OpenAI Chat nested form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat-Completions transformer used to forward tool_choice verbatim, but the two APIs disagree on shape: Anthropic "any" | {"type":"tool","name":"X"} OpenAI Chat "required" | {"type":"function","function":{"name":"X"}} Pass-through made the upstream return 400 for any tool-forcing client (Claude Code, Copilot, etc.). The Responses-API transformer already had the equivalent map_tool_choice_to_responses helper; this commit adds a sibling map_tool_choice_to_chat with the chat-specific *nested* function selector and five regression tests covering string / object × any / auto / none / tool. The two helpers are intentionally not merged: the difference between flat and nested function selectors is exactly what the original bug was, so keeping them as separate self-documenting functions reduces the chance of the same regression returning. --- src-tauri/src/proxy/providers/transform.rs | 86 +++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index d734ef2e0..3569595c5 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -228,12 +228,49 @@ pub fn anthropic_to_openai_with_reasoning_content( } if let Some(v) = body.get("tool_choice") { - result["tool_choice"] = v.clone(); + result["tool_choice"] = map_tool_choice_to_chat(v); } Ok(result) } +/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form. +/// +/// Anthropic forms: +/// "auto" / "any" / "none" (string enum) +/// {"type": "auto" | "any" | "none"} +/// {"type": "tool", "name": ""} +/// +/// OpenAI Chat forms: +/// "auto" / "none" / "required" (note: no "any" — use "required") +/// {"type": "function", "function": {"name": ""}} +/// +/// The Responses API uses a flatter `{"type":"function","name":"X"}` selector, +/// so it has a sibling `map_tool_choice_to_responses` in `transform_responses.rs`. +/// Keep the two in sync. +fn map_tool_choice_to_chat(tool_choice: &Value) -> Value { + match tool_choice { + Value::String(s) => match s.as_str() { + "any" => json!("required"), + _ => json!(s), + }, + Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) { + Some("any") => json!("required"), + Some("auto") => json!("auto"), + Some("none") => json!("none"), + Some("tool") => { + let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or(""); + json!({ + "type": "function", + "function": { "name": name } + }) + } + _ => tool_choice.clone(), + }, + _ => tool_choice.clone(), + } +} + fn normalize_openai_system_messages(messages: &mut Vec) { let system_count = messages .iter() @@ -1510,4 +1547,51 @@ mod tests { assert_eq!(result["max_tokens"], 1024); assert!(result.get("max_completion_tokens").is_none()); } + + fn run_tool_choice(value: Value) -> Value { + let input = json!({ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello"}], + "tools": [{ + "name": "search", + "description": "search the web", + "input_schema": {"type": "object", "properties": {}} + }], + "tool_choice": value, + }); + anthropic_to_openai(input).unwrap()["tool_choice"].clone() + } + + #[test] + fn tool_choice_string_any_maps_to_required() { + assert_eq!(run_tool_choice(json!("any")), json!("required")); + } + + #[test] + fn tool_choice_string_auto_and_none_pass_through() { + assert_eq!(run_tool_choice(json!("auto")), json!("auto")); + assert_eq!(run_tool_choice(json!("none")), json!("none")); + } + + #[test] + fn tool_choice_object_any_maps_to_required() { + assert_eq!(run_tool_choice(json!({"type": "any"})), json!("required")); + } + + #[test] + fn tool_choice_object_auto_and_none_collapse_to_string() { + assert_eq!(run_tool_choice(json!({"type": "auto"})), json!("auto")); + assert_eq!(run_tool_choice(json!({"type": "none"})), json!("none")); + } + + #[test] + fn tool_choice_forced_tool_maps_to_nested_function_selector() { + // Anthropic {"type":"tool","name":"X"} must become OpenAI Chat + // {"type":"function","function":{"name":"X"}} — the *nested* form, not + // the flat Responses-API form. + assert_eq!( + run_tool_choice(json!({"type": "tool", "name": "search"})), + json!({"type": "function", "function": {"name": "search"}}), + ); + } }