From ea95f39adf49872a46a8c642399124f69b8fb9f1 Mon Sep 17 00:00:00 2001 From: oasis <53985742+Postroggy@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:20:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(codex):=20drop=20tool=5Fchoice=20when=20too?= =?UTF-8?q?ls=20is=20empty=20in=20Responses=E2=86=92Chat=20conversion=20(#?= =?UTF-8?q?3640)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../proxy/providers/transform_codex_chat.rs | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 906b49eb9..cd6c1dcaa 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -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"); + } }