diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index d48dcdf181..5c9f98d738 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -768,6 +768,15 @@ export function openaiResponsesToOpenAIRequest( } } + // #12141: When translated Chat tools is empty/absent, strip neutral tool_choice + // ("auto" / "none") so strict Chat endpoints (e.g. vLLM) do not reject with 400 + // ("When using tool_choice, tools must be set"). Contradictory choices like "required" + // or forced functions are preserved so the upstream error remains visible. + const finalChatTools = Array.isArray(result.tools) ? result.tools : []; + if (finalChatTools.length === 0 && (result.tool_choice === "auto" || result.tool_choice === "none")) { + delete result.tool_choice; + } + // Cleanup Responses API specific fields // Note: prompt_cache_key is intentionally preserved for OpenAI destinations — it is // used by Codex as a cache-affinity signal and stripping it unconditionally broke diff --git a/tests/unit/responses-to-chat-no-tools-tool-choice-12141.test.ts b/tests/unit/responses-to-chat-no-tools-tool-choice-12141.test.ts new file mode 100644 index 0000000000..ceaf1c4480 --- /dev/null +++ b/tests/unit/responses-to-chat-no-tools-tool-choice-12141.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + +test("#12141: Responses-to-Chat strips tool_choice='auto' when tools is empty array", () => { + const body = { + model: "vllm/qwen-2.5-72b", + input: "hello", + tools: [], + tool_choice: "auto", + stream: false, + }; + + const result = openaiResponsesToOpenAIRequest(null, body, null, null) as Record; + + assert.equal( + result.tool_choice, + undefined, + "Neutral tool_choice 'auto' must be omitted when no tools exist" + ); +}); + +test("#12141: Responses-to-Chat strips tool_choice='none' when tools is absent or empty", () => { + const body = { + model: "vllm/qwen-2.5-72b", + input: "hello", + tool_choice: "none", + stream: false, + }; + + const result = openaiResponsesToOpenAIRequest(null, body, null, null) as Record; + + assert.equal( + result.tool_choice, + undefined, + "Neutral tool_choice 'none' must be omitted when no tools exist" + ); +}); + +test("#12141: Responses-to-Chat preserves tools and tool_choice='auto' when valid tools are present", () => { + const body = { + model: "vllm/qwen-2.5-72b", + input: "what is the weather?", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { location: { type: "string" } } }, + }, + ], + tool_choice: "auto", + stream: false, + }; + + const result = openaiResponsesToOpenAIRequest(null, body, null, null) as Record; + + assert.ok(Array.isArray(result.tools), "tools must be preserved as an array"); + assert.equal((result.tools as unknown[]).length, 1); + assert.equal(result.tool_choice, "auto", "tool_choice 'auto' must be preserved when tools exist"); +}); + +test("#12141: Responses-to-Chat preserves tool_choice='required' when tools is empty (explicit contradiction)", () => { + const body = { + model: "vllm/qwen-2.5-72b", + input: "hello", + tools: [], + tool_choice: "required", + stream: false, + }; + + const result = openaiResponsesToOpenAIRequest(null, body, null, null) as Record; + + assert.equal( + result.tool_choice, + "required", + "Contradictory tool_choice 'required' must be preserved so upstream surfaces the error" + ); +}); diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 66807e11ec..9e166a39f9 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -405,10 +405,19 @@ test("Chat→Responses: tool_choice {type:'function', function:{name}} unwrapped }); }); -test("Responses→Chat: string tool_choice passes through unchanged", () => { - const body = { model: "gpt-4", input: "hello", tool_choice: "auto" }; - const result = openaiResponsesToOpenAIRequest(null, body, null, null); - assert.equal((result as any).tool_choice, "auto"); +test("Responses->Chat: string tool_choice passes through when tools present, stripped when absent (#12141)", () => { + const withTools = { + model: "gpt-4", + input: "hello", + tools: [{ type: "function", name: "f", parameters: {} }], + tool_choice: "auto", + }; + const resWith = openaiResponsesToOpenAIRequest(null, withTools, null, null) as Record; + assert.equal(resWith.tool_choice, "auto"); + + const noTools = { model: "gpt-4", input: "hello", tool_choice: "auto" }; + const resWithout = openaiResponsesToOpenAIRequest(null, noTools, null, null) as Record; + assert.equal(resWithout.tool_choice, undefined); }); test("Chat→Responses: string tool_choice passes through unchanged", () => {