fix(proxy): map Anthropic tool_choice to OpenAI Chat nested form

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.
This commit is contained in:
Jason
2026-05-13 23:20:55 +08:00
parent cb4ecd3951
commit 84aa87c3dd
+85 -1
View File
@@ -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": "<X>"}
///
/// OpenAI Chat forms:
/// "auto" / "none" / "required" (note: no "any" — use "required")
/// {"type": "function", "function": {"name": "<X>"}}
///
/// 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<Value>) {
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"}}),
);
}
}