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({