From 02a5b985e4efb872654d24d9ef2719c2d1ab38ea Mon Sep 17 00:00:00 2001 From: JOJO Date: Wed, 15 Jul 2026 03:34:48 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AD=20fix:=20Default=20GPT-5.6=20Reaso?= =?UTF-8?q?ning=20Requests=20to=20Responses=20API=20(#14232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💭 fix: Default GPT-5.6 Reasoning Requests to Responses API GPT-5.6 models reject function tools combined with `reasoning_effort` in /v1/chat/completions (400: "Function tools with reasoning_effort are not supported ... To use function tools, use /v1/responses or set reasoning_effort to 'none'"). Default `useResponsesApi` to `true` for GPT-5.6 models on the OpenAI endpoint when a reasoning effort other than 'none' is requested, unless the user explicitly set `useResponsesApi`. Co-Authored-By: Claude Opus 4.8 (1M context) * 💭 fix: Address GPT-5.6 Responses API guard review feedback - Skip the auto-switch for OpenRouter-backed OpenAI endpoint configs; OpenRouter keeps its own reasoning path - Respect `dropParams: ['useResponsesApi']` as an explicit opt-out so Chat Completions requests never carry a nested reasoning payload - Stop treating `dropParams: ['reasoning']` as dropping reasoning: `deleteConfigParam` only removes the nested object, so skipping the switch there would resend flat `reasoning_effort` and re-trigger the 400 - Reword docstring: reasoning without tools still works on Chat Completions; the default exists because tools bind after config time - Reconcile with #14233: the Chat Completions mode/context omission test now opts out explicitly, and a new test locks in that the Responses API default carries reasoning mode/context in the nested reasoning object Co-Authored-By: Claude Opus 4.8 (1M context) * 💭 fix: Scope GPT-5.6 Responses API default to first-party OpenAI - Skip the auto-switch for custom gateway base URLs (reverseProxyUrl/ directEndpoint may expose only /v1/chat/completions); the Responses default now applies to canonical api.openai.com only - Skip when reasoningFormat is 'disabled': no reasoning payload is sent, so there is no tool incompatibility to avoid and the request stays on the configured Chat Completions path - Add coverage for custom gateway base URL, canonical base URL, and disabled reasoning format --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Danny Avila --- packages/api/src/endpoints/openai/llm.spec.ts | 300 ++++++++++++++++++ packages/api/src/endpoints/openai/llm.ts | 67 ++++ 2 files changed, 367 insertions(+) diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts index 3bac33745e..23db811e1d 100644 --- a/packages/api/src/endpoints/openai/llm.spec.ts +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -695,6 +695,8 @@ describe('getOpenAILLMConfig', () => { reasoning_effort: ReasoningEffort.high, reasoning_mode: ReasoningMode.pro, reasoning_context: ReasoningContext.all_turns, + /** Explicit opt-out: GPT-5.6 reasoning otherwise defaults to the Responses API */ + useResponsesApi: false, }, }); @@ -707,6 +709,304 @@ describe('getOpenAILLMConfig', () => { }); }); + describe('GPT-5.6 Responses API Requirement', () => { + it.each(['gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.6-sol', 'gpt-5.6'])( + 'should default to Responses API for %s when reasoning_effort is set', + (model) => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model, + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig.reasoning).toEqual({ effort: ReasoningEffort.high }); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }, + ); + + it('should NOT default to Responses API without reasoning params', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + }); + + it('should NOT default to Responses API when reasoning_effort is none', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.none, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.none); + }); + + it('should respect an explicit useResponsesApi: false', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + useResponsesApi: false, + }, + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', false); + expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + }); + + it.each(['gpt-5', 'gpt-5-pro', 'gpt-5.4-nano', 'gpt-5.5-preview', 'gpt-5-chat', 'o3-mini'])( + 'should NOT default to Responses API for %s', + (model) => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model, + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high); + }, + ); + + it('should NOT default to Responses API for non-OpenAI endpoints', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning_effort', ReasoningEffort.high); + }); + + it('should default to Responses API when reasoning_effort comes from defaultParams', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + defaultParams: { + reasoning_effort: ReasoningEffort.medium, + }, + modelOptions: { + model: 'gpt-5.6-terra', + }, + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig.reasoning).toEqual({ effort: ReasoningEffort.medium }); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + + it('should evaluate the final model when addParams overrides it to GPT-5.6', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-4o', + reasoning_effort: ReasoningEffort.high, + }, + addParams: { + model: 'gpt-5.6-terra', + }, + }); + + expect(result.llmConfig).toHaveProperty('model', 'gpt-5.6-terra'); + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig.reasoning).toEqual({ effort: ReasoningEffort.high }); + }); + + it('should NOT default to Responses API when addParams overrides GPT-5.6 away', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + addParams: { + model: 'gpt-4.1', + }, + }); + + expect(result.llmConfig).toHaveProperty('model', 'gpt-4.1'); + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high); + }); + + it('should NOT default to Responses API when dropParams removes reasoning_effort', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + dropParams: ['reasoning_effort'], + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + + it('should still default to Responses API when dropParams removes only the reasoning object', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + dropParams: ['reasoning'], + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + + it('should NOT default to Responses API when dropParams removes useResponsesApi', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + dropParams: ['useResponsesApi'], + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high); + }); + + it('should NOT default to Responses API for OpenRouter-backed OpenAI endpoints', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + useOpenRouter: true, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.high, + }); + }); + + it('should carry reasoning_mode and reasoning_context when defaulting to Responses API', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + modelOptions: { + model: 'gpt-5.6', + reasoning_effort: ReasoningEffort.high, + reasoning_mode: ReasoningMode.pro, + reasoning_context: ReasoningContext.all_turns, + }, + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig.reasoning).toEqual({ + effort: ReasoningEffort.high, + mode: ReasoningMode.pro, + context: ReasoningContext.all_turns, + }); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + expect(result.llmConfig).not.toHaveProperty('reasoning_mode'); + expect(result.llmConfig).not.toHaveProperty('reasoning_context'); + }); + + it('should NOT default to Responses API for a custom gateway base URL', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + baseURL: 'https://gateway.example.com/v1', + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high); + }); + + it('should default to Responses API for the canonical OpenAI base URL', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + baseURL: 'https://api.openai.com/v1', + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig.reasoning).toEqual({ effort: ReasoningEffort.high }); + }); + + it('should NOT default to Responses API when reasoningFormat is disabled', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: EModelEndpoint.openAI, + reasoningFormat: ReasoningParameterFormat.disabled, + modelOptions: { + model: 'gpt-5.6-terra', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('useResponsesApi'); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + }); + describe('Default and Add Parameters', () => { it('should apply default parameters when fields are undefined', () => { const result = getOpenAILLMConfig({ diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index 23329e9030..733ba91cc5 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -1,5 +1,6 @@ import { EModelEndpoint, + ReasoningEffort, ReasoningParameterFormat, removeNullishValues, supportsAdaptiveThinking, @@ -119,6 +120,48 @@ function isOpenAIEndpoint(endpoint?: EModelEndpoint | string | null): boolean { return endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI; } +/** + * GPT-5.6 models reject function tools combined with `reasoning_effort` in + * `/v1/chat/completions` (400: "To use function tools, use /v1/responses or + * set reasoning_effort to 'none'"). Reasoning without tools still works on + * Chat Completions, but tools are bound after config time, so GPT-5.6 + * reasoning requests default to the Responses API to avoid tool failures. + */ +const responsesApiRequiredPattern = /\bgpt-5\.6\b/; + +function requiresResponsesApiForReasoning({ + model, + reasoningEffort, +}: { + model?: string; + reasoningEffort?: string | null; +}): boolean { + if (typeof model !== 'string' || !responsesApiRequiredPattern.test(model)) { + return false; + } + return ( + reasoningEffort != null && + reasoningEffort !== ReasoningEffort.unset && + reasoningEffort !== ReasoningEffort.none + ); +} + +/** + * The GPT-5.6 Responses API default is first-party OpenAI only. A + * `reverseProxyUrl`/`directEndpoint` gateway sets a custom base URL and may + * expose only `/v1/chat/completions`, so it keeps its configured path. + */ +function isCanonicalOpenAIBaseURL(baseURL?: string | null): boolean { + if (!baseURL) { + return true; + } + try { + return /(^|\.)api\.openai\.com$/i.test(new URL(baseURL).hostname); + } catch { + return false; + } +} + function removeReasoningField(target: Record, field: string) { const { reasoning } = target; if (reasoning == null || typeof reasoning !== 'object' || Array.isArray(reasoning)) { @@ -722,6 +765,30 @@ export function getOpenAILLMConfig({ } } + /** + * Default GPT-5.6 reasoning requests to the Responses API unless explicitly set. + * Reads `llmConfig.model` (reflects `addParams` overrides) and skips when + * `dropParams` removes `reasoning_effort` later anyway (`'reasoning'` only + * drops the nested object, not the flat param) or opts out of the Responses + * API entirely. Limited to first-party OpenAI: OpenRouter, custom gateways + * (non-canonical base URL), and `reasoningFormat: 'disabled'` (no reasoning + * payload is sent) keep their existing Chat Completions path. + */ + const responsesApiOptedOut = + dropParams != null && + (dropParams.includes('reasoning_effort') || dropParams.includes('useResponsesApi')); + if ( + !useOpenRouter && + endpoint === EModelEndpoint.openAI && + isCanonicalOpenAIBaseURL(baseURL) && + reasoningFormat !== ReasoningParameterFormat.disabled && + llmConfig.useResponsesApi == null && + !responsesApiOptedOut && + requiresResponsesApiForReasoning({ model: llmConfig.model, reasoningEffort }) + ) { + llmConfig.useResponsesApi = true; + } + if (!useOpenRouter) { hasModelKwargs = applyReasoningConfig({