mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(codex): drop tool_choice when tools is empty in Responses→Chat conversion (#3640)
Strict OpenAI-compatible upstreams (vLLM, enterprise gateways) reject requests that carry tool_choice or parallel_tool_calls without a non-empty tools array, returning 503/400 with: "When using `tool_choice`, `tools` must be set." The Responses→Chat Completions converter unconditionally forwarded tool_choice and parallel_tool_calls even when tools ended up absent or empty after conversion. This commit adds a guard that removes both fields when the resulting tools array is missing or empty. Added 9 regression tests covering: - tool_choice dropped when tools absent - tool_choice dropped when tools is empty array - parallel_tool_calls dropped when tools absent - tool_choice dropped when all tools filtered (e.g., missing name) - tool_choice preserved when tools present (auto + function type) - clean output when neither tool_choice nor tools present - tool_choice 'none' dropped when no tools - tool_search_output providing tools keeps tool_choice Refs #3557 Co-authored-by: yueqi.guo <guo_yueqi@qq.com>
This commit is contained in:
@@ -319,6 +319,19 @@ pub fn responses_to_chat_completions_with_reasoning(
|
||||
}
|
||||
}
|
||||
|
||||
// Strict OpenAI-compatible upstreams (vLLM, enterprise gateways) reject
|
||||
// requests that carry tool_choice or parallel_tool_calls without a non-empty
|
||||
// tools array. Drop both fields when tools ended up absent or empty after
|
||||
// conversion to avoid 503/400 from such providers.
|
||||
let has_tools = result
|
||||
.get("tools")
|
||||
.is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty()));
|
||||
if !has_tools {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.remove("tool_choice");
|
||||
obj.remove("parallel_tool_calls");
|
||||
}
|
||||
}
|
||||
// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明
|
||||
// include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、
|
||||
// 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的
|
||||
@@ -2868,4 +2881,238 @@ mod tests {
|
||||
assert_eq!(result["error"]["message"], "rate limit exceeded");
|
||||
assert_eq!(result["error"]["type"], "upstream_error");
|
||||
}
|
||||
// Regression tests for tool_choice without tools guard
|
||||
// https://github.com/farion1231/cc-switch/issues/3557
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_tool_choice_when_no_tools() {
|
||||
// When tools is absent from the request, tool_choice must be dropped
|
||||
// to avoid 503/400 from strict OpenAI-compatible upstreams.
|
||||
let input = json!({
|
||||
"model": "qwen3-7-max",
|
||||
"tool_choice": "auto",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped when tools is absent"
|
||||
);
|
||||
assert!(result.get("tools").is_none(), "tools should be absent");
|
||||
assert_eq!(result["model"], "qwen3-7-max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_tool_choice_when_tools_empty_array() {
|
||||
// When tools is an empty array, tool_choice must be dropped.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped when tools is empty"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"tools should be absent when input tools was empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_parallel_tool_calls_when_no_tools() {
|
||||
// parallel_tool_calls must also be dropped when tools is absent,
|
||||
// as it is part of EXTRA_CHAT_PASSTHROUGH_FIELDS.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped"
|
||||
);
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"parallel_tool_calls should be dropped"
|
||||
);
|
||||
assert!(result.get("tools").is_none(), "tools should be absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_tool_choice_when_all_tools_filtered() {
|
||||
// When all tools are filtered out (e.g., missing name), tool_choice must be dropped.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [
|
||||
{"type": "function"}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped when all tools filtered"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"tools should be absent when all filtered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_keeps_tool_choice_when_tools_present() {
|
||||
// When tools is present and non-empty, tool_choice must be preserved.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object"}
|
||||
}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_some(),
|
||||
"tool_choice should be kept when tools present"
|
||||
);
|
||||
assert_eq!(result["tool_choice"], "auto");
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_some(),
|
||||
"parallel_tool_calls should be kept"
|
||||
);
|
||||
assert_eq!(result["parallel_tool_calls"], true);
|
||||
assert!(
|
||||
result
|
||||
.get("tools")
|
||||
.is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())),
|
||||
"tools should be present"
|
||||
);
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_keeps_tool_choice_function_when_tools_present() {
|
||||
// When tools is present, function-type tool_choice must be preserved and mapped.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object"}
|
||||
}],
|
||||
"tool_choice": {"type": "function", "name": "get_weather"},
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_some(),
|
||||
"tool_choice should be kept"
|
||||
);
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_no_tool_choice_no_tools_stays_clean() {
|
||||
// When neither tool_choice nor tools are present, the output should be clean.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be absent"
|
||||
);
|
||||
assert!(result.get("tools").is_none(), "tools should be absent");
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"parallel_tool_calls should be absent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_tool_choice_none_dropped_when_no_tools() {
|
||||
// Even tool_choice: "none" should be dropped when tools is absent,
|
||||
// because strict upstreams reject the combination regardless of value.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tool_choice": "none",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice 'none' should be dropped when no tools"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_tool_search_output_provides_tools_keeps_tool_choice() {
|
||||
// When tool_search_output in input provides tools, tool_choice should be kept.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tool_choice": "auto",
|
||||
"input": [{
|
||||
"type": "tool_search_output",
|
||||
"call_id": "call_ts_1",
|
||||
"status": "completed",
|
||||
"execution": "client",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "search_docs",
|
||||
"description": "Search documentation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_some(),
|
||||
"tool_choice should be kept when tool_search_output provides tools"
|
||||
);
|
||||
assert_eq!(result["tool_choice"], "auto");
|
||||
assert!(
|
||||
result
|
||||
.get("tools")
|
||||
.is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())),
|
||||
"tools should be present from tool_search_output"
|
||||
);
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "search_docs");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user