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:
oasis
2026-06-04 15:20:04 +08:00
committed by GitHub
parent f5acef32fd
commit ea95f39adf
@@ -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");
}
}