mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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"}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user