From 5a5cd7b196606c9f64be1a0e264324c0eaa09c92 Mon Sep 17 00:00:00 2001 From: nexxeln <95541290+nexxeln@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:40:54 +0000 Subject: [PATCH] fix(ai): constrain GPT-6 Chat function calls to no reasoning --- packages/ai/src/protocols/openai-chat.ts | 27 +++++- packages/ai/test/provider/openai-chat.test.ts | 82 +++++++++++++++++++ .../ai/test/provider/openai-responses.test.ts | 16 ++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index b72864abbcbf..c1b7c3dd13a7 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -766,8 +766,12 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode return true } -const lowerOptions = (request: LLMRequest, supportsStore: boolean) => { - const options = OpenAIOptions.resolve(request) +const lowerOptions = ( + request: LLMRequest, + options: ReturnType, + supportsStore: boolean, + requiresNoneEffort: boolean, +) => { // Default off: strict providers 400 on unknown body fields, so only send // the key where compatibility explicitly allows it. Header-based affinity // (x-session-affinity, x-grok-conv-id, ...) is unaffected. @@ -780,7 +784,11 @@ const lowerOptions = (request: LLMRequest, supportsStore: boolean) => { // native OpenAI Chat default. Non-standard providers omit `store` entirely. ...(supportsStore && options.store === undefined ? { store: false } : {}), ...(cacheKey ? { prompt_cache_key: cacheKey } : {}), - ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}), + ...(requiresNoneEffort + ? { reasoning_effort: "none" } + : options.reasoningEffort + ? { reasoning_effort: options.reasoningEffort } + : {}), } } @@ -810,6 +818,17 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id) const hasHistory = hasToolHistory(request.messages) const hasActiveTools = flattened.tools.length > 0 + // GPT-6 Sol/Luna only support Chat function calling without reasoning. + // Responses supports tools at every effort, so this applies to native OpenAI Chat only. + const requiresNoneEffort = + provider === "openai" && + (request.model.id === "gpt-6-sol" || request.model.id === "gpt-6-luna") && + (hasActiveTools || hasHistory) + const nativeOptions = OpenAIOptions.resolve(request) + if (requiresNoneEffort && nativeOptions.reasoningEffort !== undefined && nativeOptions.reasoningEffort !== "none") + return yield* ProviderShared.invalidRequest( + `${request.model.id} Chat function calling requires reasoningEffort "none"; use the Responses API for reasoning with tools`, + ) return { model: request.model.id, messages: yield* lowerMessages(flattened.request, options), @@ -832,7 +851,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( presence_penalty: generation?.presencePenalty, seed: generation?.seed, stop: generation?.stop, - ...lowerOptions(request, supportsStore), + ...lowerOptions(request, nativeOptions, supportsStore, requiresNoneEffort), } }) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 994459048788..ec0274770858 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -249,6 +249,88 @@ describe("OpenAI Chat route", () => { }), ) + for (const id of ["gpt-6-sol", "gpt-6-luna"]) { + it.effect(`enables function calling for ${id} on OpenAI Chat`, () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAI.configure({ apiKey: "test" }).chat(id), + prompt: "Look up the weather.", + tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })], + }), + ) + + expect(prepared.body.tools).toHaveLength(1) + expect(prepared.body.reasoning_effort).toBe("none") + }), + ) + } + + it.effect("rejects GPT-6 Chat tools with explicit reasoning instead of silently downgrading it", () => + Effect.gen(function* () { + const error = yield* compileRequest( + LLM.request({ + model: OpenAI.configure({ apiKey: "test", providerOptions: { reasoningEffort: "high" } }).chat("gpt-6-sol"), + prompt: "Look up the weather.", + tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain('requires reasoningEffort "none"') + expect(error.message).toContain("Responses API") + }), + ) + + it.effect("keeps GPT-6 Chat reasoning when no function calling is involved", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAI.configure({ apiKey: "test" }).chat("gpt-6-luna"), + prompt: "Think carefully.", + providerOptions: { reasoningEffort: "high" }, + }), + ) + + expect(prepared.body.reasoning_effort).toBe("high") + }), + ) + + it.effect("does not impose OpenAI's GPT-6 Chat restriction on compatible providers", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "test" }).model( + "gpt-6-sol", + ), + prompt: "Look up the weather.", + tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })], + providerOptions: { reasoningEffort: "high" }, + }), + ) + + expect(prepared.body.tools).toHaveLength(1) + expect(prepared.body.reasoning_effort).toBe("high") + }), + ) + + it.effect("keeps GPT-6 Chat tool history valid without active tools", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAI.configure({ apiKey: "test" }).chat("gpt-6-sol"), + messages: [ + Message.user("Look up the weather."), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "weather", input: {} })]), + Message.tool({ id: "call_1", name: "weather", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body.tools).toEqual([]) + expect(prepared.body.reasoning_effort).toBe("none") + }), + ) + it.effect("keeps valid Chat options when a sibling option is malformed", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index cb982f88e897..5137654a6114 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -153,6 +153,22 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("keeps GPT-6 reasoning with function tools on the Responses API", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAI.configure({ apiKey: "test" }).responses("gpt-6-sol"), + prompt: "Look up the weather.", + tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })], + providerOptions: { reasoningEffort: "high" }, + }), + ) + + expect(prepared.body.tools).toHaveLength(1) + expect(prepared.body.reasoning).toMatchObject({ effort: "high" }) + }), + ) + it.effect("lowers the hosted OpenAI image generation tool", () => Effect.gen(function* () { const prepared = yield* compileRequest(