From b901cb28af5a2a648101f206567366a63ac437fc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:01:59 -0500 Subject: [PATCH] fix(ai): omit OpenAI tool choice when tools are empty (#48857) --- packages/ai/src/protocols/openai-chat.ts | 2 +- packages/ai/src/protocols/openai-responses.ts | 6 +- .../test/provider/empty-tool-choice.test.ts | 80 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 packages/ai/test/provider/empty-tool-choice.test.ts diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 9642b89a58c..7f16b542836 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -766,7 +766,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( supportsStrictMode, ), ), - tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, ...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}), ...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}), diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index d848cde2fc0..f853cac2041 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -199,8 +199,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: ? undefined : yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)), tool_choice: - OpenResponses.allowedToolChoice(request) ?? - (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined), + request.tools.length === 0 + ? undefined + : (OpenResponses.allowedToolChoice(request) ?? + (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)), }) }) diff --git a/packages/ai/test/provider/empty-tool-choice.test.ts b/packages/ai/test/provider/empty-tool-choice.test.ts new file mode 100644 index 00000000000..d071f2d20e5 --- /dev/null +++ b/packages/ai/test/provider/empty-tool-choice.test.ts @@ -0,0 +1,80 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { LLM, Message, ToolCallPart } from "../../src/index.js" +import { OpenAIChat } from "../../src/protocols/openai-chat.js" +import { OpenAIResponses } from "../../src/protocols/openai-responses.js" +import { Auth } from "../../src/route/auth.js" +import { compileRequest } from "../../src/route/client.js" +import { it } from "../lib/effect.js" + +const tool = { name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } } + +for (const route of [OpenAIChat.route, OpenAIResponses.route]) { + const model = route + .with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4.1-mini" }) + + for (const choice of [ + { type: "auto" }, + { type: "none" }, + { type: "required" }, + { type: "tool", name: "lookup" }, + ] as const) { + it.effect(`${route.id} omits ${choice.type} tool choice without active tools`, () => + Effect.gen(function* () { + for (const messages of [ + [Message.user("Say OK.")], + [ + Message.user("Look up the value."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: "OK", resultType: "text" }), + ], + ]) { + const prepared = yield* compileRequest( + LLM.request({ model, messages, tools: [], toolChoice: choice, cache: "none" }), + ) + expect(prepared.body.tool_choice).toBeUndefined() + } + }), + ) + + it.effect(`${route.id} preserves ${choice.type} tool choice with active tools`, () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ model, prompt: "Look up the value.", tools: [tool], toolChoice: choice }), + ) + expect(prepared.body.tool_choice).toEqual( + choice.type !== "tool" + ? choice.type + : route.id === "openai-chat" + ? { type: "function", function: { name: "lookup" } } + : { type: "function", name: "lookup" }, + ) + }), + ) + } +} + +it.effect("OpenAI Responses omits allowed tool choice without tool definitions", () => + Effect.gen(function* () { + const model = OpenAIResponses.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4.1-mini" }) + for (const tools of [[], [tool]]) { + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "Look up the value.", + tools, + toolChoice: "required", + providerOptions: { allowedTools: { mode: "required", toolNames: ["lookup"] } }, + }), + ) + expect(prepared.body.tool_choice).toEqual( + tools.length === 0 + ? undefined + : { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }] }, + ) + } + }), +)