From 2ab432bd0aea57dc287677fe57242666d9444f71 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 1 Jun 2026 18:20:20 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AD=20fix:=20Preserve=20Custom=20Endpo?= =?UTF-8?q?int=20Reasoning=20Params=20(#13447)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Preserve custom endpoint reasoning params * fix: Address custom reasoning review cases * fix: Format configured reasoning defaults * fix: Honor dropped reasoning params * fix: Configure custom reasoning response key --- .../services/Config/loadCustomConfig.js | 3 +- .../services/Config/loadCustomConfig.spec.js | 30 ++- packages/api/src/agents/initialize.ts | 4 + packages/api/src/agents/run.spec.ts | 51 +++++ packages/api/src/agents/run.ts | 16 +- .../openai/config.backward-compat.spec.ts | 8 +- .../api/src/endpoints/openai/config.spec.ts | 129 +++++++++-- packages/api/src/endpoints/openai/config.ts | 22 +- packages/api/src/endpoints/openai/llm.spec.ts | 145 +++++++++++- packages/api/src/endpoints/openai/llm.ts | 207 +++++++++++++++--- .../specs/config-schemas.spec.ts | 59 ++++- packages/data-provider/src/config.ts | 10 +- packages/data-provider/src/schemas.ts | 13 ++ packages/data-provider/src/types.ts | 4 + 14 files changed, 635 insertions(+), 66 deletions(-) diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index c914754974..c719a84665 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -177,7 +177,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`); // Validate and fill out missing values for custom parameters function parseCustomParams(endpointName, customParams) { - const paramEndpoint = customParams.defaultParamsEndpoint; + const paramEndpoint = customParams.defaultParamsEndpoint ?? 'custom'; + customParams.defaultParamsEndpoint = paramEndpoint; customParams.paramDefinitions = customParams.paramDefinitions || []; // Checks if `defaultParamsEndpoint` is a key in `paramSettings`. diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index 3fce8777e3..ff7ee90629 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -11,7 +11,7 @@ jest.mock('librechat-data-provider', () => { paramSettings: { foo: {}, bar: {}, - custom: {}, + custom: [], openrouter: [ { key: 'promptCache', @@ -59,6 +59,7 @@ jest.mock('@librechat/data-schemas', () => { const axios = require('axios'); const { loadYaml } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { ReasoningParameterFormat, ReasoningResponseKey } = require('librechat-data-provider'); const loadCustomConfig = require('./loadCustomConfig'); describe('loadCustomConfig', () => { @@ -307,11 +308,28 @@ describe('loadCustomConfig', () => { ); }); - it('throws an error when defaultParamsEndpoint is not provided', async () => { - const malformedCustomParams = { defaultParamsEndpoint: undefined }; - await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow( - 'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, openrouter, google', - ); + it('defaults defaultParamsEndpoint when only reasoningFormat is provided', async () => { + const parsedConfig = await loadCustomParams({ + reasoningFormat: ReasoningParameterFormat.reasoningObject, + }); + + expect(parsedConfig.endpoints.custom[0].customParams).toEqual({ + defaultParamsEndpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + paramDefinitions: [], + }); + }); + + it('defaults defaultParamsEndpoint when only reasoningKey is provided', async () => { + const parsedConfig = await loadCustomParams({ + reasoningKey: ReasoningResponseKey.reasoning, + }); + + expect(parsedConfig.endpoints.custom[0].customParams).toEqual({ + defaultParamsEndpoint: 'custom', + reasoningKey: ReasoningResponseKey.reasoning, + paramDefinitions: [], + }); }); it('fills the paramDefinitions with missing values', async () => { diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 85152a9afd..f9ff1510a2 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -15,6 +15,7 @@ import type { AgentToolResources, AgentToolOptions, TEndpointOption, + ReasoningResponseKey, TFile, Agent, TUser, @@ -169,6 +170,8 @@ export type InitializedAgent = Agent & { actionsEnabled?: boolean; /** Maximum characters allowed in a single tool result before truncation. */ maxToolResultChars?: number; + /** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */ + reasoningKey?: ReasoningResponseKey; /** * Whether the code-execution environment is available *for this agent*. * Narrower than the incoming `params.codeEnvAvailable` admin flag — this @@ -1035,6 +1038,7 @@ export async function initializeAgent( actionsEnabled, baseContextTokens, codeEnvAvailable: effectiveCodeEnvAvailable, + reasoningKey: customEndpointConfig?.customParams?.reasoningKey, skillCount, accessibleSkillIds: executableSkillIds, activeSkillNames, diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index 0cc280d778..cf909546e1 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -1,5 +1,6 @@ import { Providers } from '@librechat/agents'; import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages'; +import { ReasoningResponseKey } from 'librechat-data-provider'; import { extractDiscoveredToolsFromHistory, @@ -150,6 +151,56 @@ describe('getReasoningKey', () => { expect(reasoningKey).toBe('reasoning'); }); + + it('keeps Vercel AI Gateway on ChatOpenAI normalized reasoning_content', () => { + const llmConfig = { + configuration: { + baseURL: 'https://ai-gateway.vercel.sh/v1', + }, + } as Parameters[1]; + + const reasoningKey = getReasoningKey(Providers.OPENAI, llmConfig); + + expect(reasoningKey).toBe('reasoning_content'); + }); + + it('keeps Vercel custom endpoint names on ChatOpenAI normalized reasoning_content', () => { + const llmConfig = {} as Parameters[1]; + + const reasoningKey = getReasoningKey(Providers.OPENAI, llmConfig, 'Vercel'); + + expect(reasoningKey).toBe('reasoning_content'); + }); + + it('uses explicit reasoning response keys for Vercel when configured', () => { + const llmConfig = { + configuration: { + baseURL: 'https://ai-gateway.vercel.sh/v1', + }, + } as Parameters[1]; + + const reasoningKey = getReasoningKey( + Providers.OPENAI, + llmConfig, + 'Vercel', + ReasoningResponseKey.reasoning, + ); + + expect(reasoningKey).toBe('reasoning'); + }); + + it('uses explicit reasoning response keys for otherwise default OpenAI-compatible endpoints', () => { + const llmConfig = {} as Parameters[1]; + + const reasoningKey = getReasoningKey( + Providers.OPENAI, + llmConfig, + 'Company Gateway', + ReasoningResponseKey.reasoning, + ); + + expect(reasoningKey).toBe('reasoning'); + }); }); describe('isDeepSeekReasoningProvider', () => { diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 10437096ce..85b614f4f7 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -26,6 +26,7 @@ import type { Agent, AgentModelParameters, AgentSubagentsConfig, + ReasoningResponseKey, SummarizationConfig, } from 'librechat-data-provider'; import type { BaseMessage } from '@librechat/agents/langchain/messages'; @@ -212,6 +213,8 @@ const customProviders = new Set([ KnownEndpoints.ollama, ]); +type AgentReasoningKey = 'reasoning_content' | 'reasoning'; + function includesOpenRouter(value?: string | null): boolean { return typeof value === 'string' && value.toLowerCase().includes(KnownEndpoints.openrouter); } @@ -220,8 +223,13 @@ export function getReasoningKey( provider: Providers, llmConfig: t.RunLLMConfig, agentEndpoint?: string | null, -): 'reasoning_content' | 'reasoning' { - let reasoningKey: 'reasoning_content' | 'reasoning' = 'reasoning_content'; + customReasoningKey?: ReasoningResponseKey, +): AgentReasoningKey { + if (customReasoningKey) { + return customReasoningKey as AgentReasoningKey; + } + + let reasoningKey: AgentReasoningKey = 'reasoning_content'; if (provider === Providers.GOOGLE) { reasoningKey = 'reasoning'; } else if ( @@ -293,6 +301,8 @@ type RunAgent = Omit & { codeEnvAvailable?: boolean; /** Optional per-agent summarization overrides */ summarization?: SummarizationConfig; + /** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */ + reasoningKey?: ReasoningResponseKey; /** * Maximum characters allowed in a single tool result before truncation. * Overrides the default computed from maxContextTokens. @@ -946,7 +956,7 @@ export async function createRun({ agent.maxContextTokens, ); - const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint); + const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint, agent.reasoningKey); return { provider, reasoningKey, diff --git a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts index 11e9357ee8..acc3496b2b 100644 --- a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts +++ b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts @@ -10,7 +10,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => { describe('OpenAI endpoint', () => { it('should handle GPT-5 model with reasoning and web search', () => { const apiKey = 'sk-proj-somekey'; - const endpoint = undefined; + const endpoint = EModelEndpoint.openAI; const options = { modelOptions: { model: 'gpt-5-nano', @@ -138,7 +138,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => { it('should handle Azure OpenAI with Responses API and reasoning', () => { const apiKey = 'some_azure_key'; - const endpoint = undefined; + const endpoint = EModelEndpoint.azureOpenAI; const options = { modelOptions: { model: 'gpt-5', @@ -395,6 +395,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => { modelOptions: { model: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', user: 'some-user', + reasoning_effort: ReasoningEffort.high, }, reverseProxyUrl: 'https://gateway.ai.cloudflare.com/v1/${CF_ACCOUNT_ID}/${CF_GATEWAY_ID}/workers-ai/v1', @@ -419,6 +420,9 @@ describe('getOpenAIConfig - Backward Compatibility', () => { user: 'some-user', disableStreaming: true, apiKey: 'someKey', + modelKwargs: { + reasoning_effort: ReasoningEffort.high, + }, }, configOptions: { baseURL: diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index 9de28d4049..84ab68f92c 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -3,6 +3,7 @@ import { EModelEndpoint, ReasoningEffort, ReasoningSummary, + ReasoningParameterFormat, } from 'librechat-data-provider'; import type { RequestInit } from 'undici'; import type { OpenAIParameters, AzureOptions } from '~/types'; @@ -79,7 +80,7 @@ describe('getOpenAIConfig', () => { expect(result.llmConfig.modelKwargs).toBeUndefined(); }); - it('should handle reasoning params for `useResponsesApi`', () => { + it('should pass custom endpoint reasoning object through modelKwargs for `useResponsesApi`', () => { const modelOptions = { reasoning_effort: ReasoningEffort.high, reasoning_summary: ReasoningSummary.detailed, @@ -89,27 +90,29 @@ describe('getOpenAIConfig', () => { modelOptions: { ...modelOptions, useResponsesApi: true }, }); - expect(result.llmConfig.reasoning).toEqual({ - effort: ReasoningEffort.high, - summary: ReasoningSummary.detailed, + expect(result.llmConfig.reasoning).toBeUndefined(); + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning: { + effort: ReasoningEffort.high, + summary: ReasoningSummary.detailed, + }, }); expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); expect((result.llmConfig as Record).reasoning_summary).toBeUndefined(); }); - it('should handle reasoning params without `useResponsesApi`', () => { + it('should pass custom endpoint reasoning through modelKwargs without `useResponsesApi`', () => { const modelOptions = { reasoning_effort: ReasoningEffort.high, reasoning_summary: ReasoningSummary.detailed, }; - const result = getOpenAIConfig(mockApiKey, { modelOptions }); + const result = getOpenAIConfig(mockApiKey, { modelOptions }, 'custom-endpoint'); - /** When no endpoint is specified, it's treated as non-openAI/azureOpenAI, so uses reasoning object */ - expect(result.llmConfig.reasoning).toEqual({ - effort: ReasoningEffort.high, - summary: ReasoningSummary.detailed, + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning_effort: ReasoningEffort.high, }); + expect(result.llmConfig.reasoning).toBeUndefined(); expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); }); @@ -173,7 +176,7 @@ describe('getOpenAIConfig', () => { expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); }); - it('should use reasoning object for non-openAI/azureOpenAI endpoints', () => { + it('should pass reasoning_effort through modelKwargs for non-openAI/azureOpenAI endpoints', () => { const modelOptions = { reasoning_effort: ReasoningEffort.high, reasoning_summary: ReasoningSummary.detailed, @@ -181,13 +184,102 @@ describe('getOpenAIConfig', () => { const result = getOpenAIConfig(mockApiKey, { modelOptions }, 'custom-endpoint'); - expect(result.llmConfig.reasoning).toEqual({ - effort: ReasoningEffort.high, - summary: ReasoningSummary.detailed, + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning_effort: ReasoningEffort.high, + }); + expect(result.llmConfig.reasoning).toBeUndefined(); + expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); + }); + + it('should support custom endpoint reasoning object format', () => { + const result = getOpenAIConfig( + mockApiKey, + { + customParams: { + reasoningFormat: ReasoningParameterFormat.reasoningObject, + }, + modelOptions: { + reasoning_effort: ReasoningEffort.high, + reasoning_summary: ReasoningSummary.detailed, + }, + }, + 'custom-endpoint', + ); + + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning: { + effort: ReasoningEffort.high, + summary: ReasoningSummary.detailed, + }, + }); + expect(result.llmConfig.reasoning).toBeUndefined(); + expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); + }); + + it('should default Vercel custom endpoints to reasoning object format', () => { + const result = getOpenAIConfig( + mockApiKey, + { + reverseProxyUrl: 'https://ai-gateway.vercel.sh/v1', + modelOptions: { + reasoning_effort: ReasoningEffort.high, + }, + }, + 'Vercel', + ); + + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning: { + effort: ReasoningEffort.high, + }, + }); + expect(result.llmConfig.reasoning).toBeUndefined(); + expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); + }); + + it('should apply Vercel reasoning format to custom default params', () => { + const result = getOpenAIConfig( + mockApiKey, + { + reverseProxyUrl: 'https://ai-gateway.vercel.sh/v1', + customParams: { + paramDefinitions: [{ key: 'reasoning_effort', default: ReasoningEffort.low }], + }, + modelOptions: { + model: 'openai/gpt-5-mini', + }, + }, + 'Vercel', + ); + + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning: { + effort: ReasoningEffort.low, + }, }); expect((result.llmConfig as Record).reasoning_effort).toBeUndefined(); }); + it('should allow Vercel reasoning format override', () => { + const result = getOpenAIConfig( + mockApiKey, + { + reverseProxyUrl: 'https://ai-gateway.vercel.sh/v1', + customParams: { + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + }, + modelOptions: { + reasoning_effort: ReasoningEffort.high, + }, + }, + 'Vercel', + ); + + expect(result.llmConfig.modelKwargs).toEqual({ + reasoning_effort: ReasoningEffort.high, + }); + }); + it('should handle OpenRouter configuration', () => { const reverseProxyUrl = 'https://openrouter.ai/api/v1'; @@ -1006,11 +1098,12 @@ describe('getOpenAIConfig', () => { const result = getOpenAIConfig(mockApiKey, { modelOptions: { ...modelOptions, useResponsesApi: true } as Partial, }); + const reasoning = result.llmConfig?.reasoning ?? result.llmConfig?.modelKwargs?.reasoning; if (shouldHaveReasoning) { - expect(result.llmConfig?.reasoning).toBeDefined(); + expect(reasoning).toBeDefined(); } else { - expect(result.llmConfig?.reasoning).toBeUndefined(); + expect(reasoning).toBeUndefined(); } }); }); @@ -1088,6 +1181,7 @@ describe('getOpenAIConfig', () => { frequency_penalty: 0.5, presence_penalty: 0.6, max_tokens: 1000, + reasoning_effort: ReasoningEffort.high, custom_param: 'should-remain', }; @@ -1102,6 +1196,7 @@ describe('getOpenAIConfig', () => { /** `presence_penalty` is converted to `presencePenalty` */ expect(result.llmConfig.maxTokens).toBe(1000); // max_tokens is allowed expect((result.llmConfig as Record).custom_param).toBe('should-remain'); + expect(result.llmConfig.modelKwargs).toBeUndefined(); }); }); @@ -1209,9 +1304,11 @@ describe('getOpenAIConfig', () => { streaming: false, useResponsesApi: true, // From web_search }); + expect(result.llmConfig.reasoning).toBeUndefined(); expect(result.llmConfig.maxTokens).toBe(2000); expect(result.llmConfig.modelKwargs).toEqual({ text: { verbosity: Verbosity.medium }, + reasoning: { effort: ReasoningEffort.high }, customParam: 'custom-value', }); expect(result.tools).toEqual([{ type: 'web_search' }]); diff --git a/packages/api/src/endpoints/openai/config.ts b/packages/api/src/endpoints/openai/config.ts index b239a2b7d0..3cc1c68cc1 100644 --- a/packages/api/src/endpoints/openai/config.ts +++ b/packages/api/src/endpoints/openai/config.ts @@ -1,6 +1,6 @@ import { ProxyAgent } from 'undici'; import { Providers } from '@librechat/agents'; -import { KnownEndpoints, EModelEndpoint } from 'librechat-data-provider'; +import { KnownEndpoints, EModelEndpoint, ReasoningParameterFormat } from 'librechat-data-provider'; import type * as t from '~/types'; import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm'; import { getOpenAILLMConfig, extractDefaultParams } from './llm'; @@ -34,6 +34,22 @@ function getDefaultParams({ }; } +function getReasoningFormat({ + customFormat, + isVercel, +}: { + customFormat?: ReasoningParameterFormat; + isVercel: boolean; +}): ReasoningParameterFormat | undefined { + if (customFormat) { + return customFormat; + } + if (isVercel) { + return ReasoningParameterFormat.reasoningObject; + } + return undefined; +} + function mergeHeadersPreservingAnthropicBeta( headers: Record | undefined, defaultHeaders: Record, @@ -159,6 +175,10 @@ export function getOpenAIConfig( defaultParams, modelOptions, useOpenRouter, + reasoningFormat: getReasoningFormat({ + customFormat: options.customParams?.reasoningFormat, + isVercel: Boolean(isVercel), + }), }); llmConfig = openaiResult.llmConfig; azure = openaiResult.azure; diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts index 366176978a..cb38b294b1 100644 --- a/packages/api/src/endpoints/openai/llm.spec.ts +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -3,6 +3,7 @@ import { EModelEndpoint, ReasoningEffort, ReasoningSummary, + ReasoningParameterFormat, } from 'librechat-data-provider'; import { getOpenAILLMConfig, extractDefaultParams, applyDefaultParams } from './llm'; import type * as t from '~/types'; @@ -463,23 +464,159 @@ describe('getOpenAILLMConfig', () => { expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high); }); - it('should use reasoning object for non-OpenAI endpoints', () => { + it('should pass reasoning_effort through modelKwargs for custom endpoints', () => { const result = getOpenAILLMConfig({ apiKey: 'test-api-key', streaming: true, endpoint: 'custom', modelOptions: { - model: 'o1', + model: 'provider/reasoning-model', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning_effort', ReasoningEffort.high); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + + it('should support reasoning object passthrough for custom endpoints', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + modelOptions: { + model: 'provider/reasoning-model', reasoning_effort: ReasoningEffort.high, reasoning_summary: ReasoningSummary.concise, }, }); - expect(result.llmConfig).toHaveProperty('reasoning'); - expect(result.llmConfig.reasoning).toEqual({ + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { effort: ReasoningEffort.high, summary: ReasoningSummary.concise, }); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + + it('should apply reasoning format to default reasoning params', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + defaultParams: { + reasoning_effort: ReasoningEffort.low, + reasoning_summary: ReasoningSummary.concise, + }, + modelOptions: { + model: 'provider/reasoning-model', + }, + }); + + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.low, + summary: ReasoningSummary.concise, + }); + expect(result.llmConfig.modelKwargs).not.toHaveProperty('reasoning_effort'); + }); + + it('should let addParams reasoning override default reasoning params before formatting', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + defaultParams: { + reasoning_effort: ReasoningEffort.low, + }, + addParams: { + reasoning_effort: ReasoningEffort.high, + }, + modelOptions: { + model: 'provider/reasoning-model', + }, + }); + + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.high, + }); + expect(result.llmConfig.modelKwargs).not.toHaveProperty('reasoning_effort'); + }); + + it('should allow custom endpoints to disable reasoning passthrough', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.disabled, + modelOptions: { + model: 'provider/reasoning-model', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + expect(result.llmConfig.modelKwargs).toBeUndefined(); + }); + + it('should use Responses API reasoning when web_search enables Responses API', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + modelOptions: { + model: 'provider/reasoning-model', + reasoning_effort: ReasoningEffort.high, + reasoning_summary: ReasoningSummary.concise, + web_search: true, + }, + }); + + expect(result.llmConfig).toHaveProperty('useResponsesApi', true); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.high, + summary: ReasoningSummary.concise, + }); + expect(result.tools).toContainEqual({ type: 'web_search' }); + }); + + it('should remove reasoning kwargs for GPT-4o search models', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + modelOptions: { + model: 'gpt-4o-search', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + expect(result.llmConfig.modelKwargs).toBeUndefined(); + }); + + it('should honor dropParams after reasoning object conversion', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + endpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + dropParams: ['reasoning_effort'], + modelOptions: { + model: 'provider/reasoning-model', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + expect(result.llmConfig.modelKwargs).toBeUndefined(); }); it('should use reasoning object when useResponsesApi is true', () => { diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index d2148cebb4..94dc412fa5 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -1,5 +1,6 @@ import { EModelEndpoint, + ReasoningParameterFormat, removeNullishValues, supportsAdaptiveThinking, } from 'librechat-data-provider'; @@ -86,6 +87,78 @@ function hasReasoningParams({ ); } +function getReasoningObject({ + reasoningEffort, + reasoningSummary, +}: { + reasoningEffort?: OpenAILLMConfig['reasoning_effort']; + reasoningSummary?: OpenAILLMConfig['reasoning_summary']; +}): OpenAI.Reasoning { + return removeNullishValues( + { + effort: reasoningEffort, + summary: reasoningSummary, + }, + true, + ) as OpenAI.Reasoning; +} + +function isOpenAIEndpoint(endpoint?: EModelEndpoint | string | null): boolean { + return endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI; +} + +function removeReasoningSummary(target: Record) { + const { reasoning } = target; + if (reasoning == null || typeof reasoning !== 'object' || Array.isArray(reasoning)) { + return; + } + + const rest = { ...(reasoning as Record) }; + delete rest.summary; + if (Object.keys(rest).length === 0) { + delete target.reasoning; + return; + } + + target.reasoning = rest; +} + +function removeReasoningPayload(target: Record) { + delete target.reasoning; + delete target.reasoning_effort; +} + +function deleteConfigParam({ + param, + llmConfig, + modelKwargs, +}: { + param: string; + llmConfig: OpenAILLMConfig; + modelKwargs: Record; +}) { + if (param === 'reasoning_effort') { + removeReasoningPayload(llmConfig as Record); + removeReasoningPayload(modelKwargs); + return; + } + + if (param === 'reasoning_summary') { + delete (llmConfig as Record).reasoning_summary; + delete modelKwargs.reasoning_summary; + removeReasoningSummary(llmConfig as Record); + removeReasoningSummary(modelKwargs); + return; + } + + if (param in llmConfig) { + delete llmConfig[param as keyof t.OAIClientOptions]; + } + if (param in modelKwargs) { + delete modelKwargs[param]; + } +} + const openRouterAnthropicVerbosityByEffort: Record< string, NonNullable @@ -204,6 +277,64 @@ function applyOpenRouterReasoningConfig({ return true; } +function applyReasoningConfig({ + endpoint, + llmConfig, + modelKwargs, + reasoningEffort, + reasoningFormat, + reasoningSummary, +}: { + endpoint?: EModelEndpoint | string | null; + llmConfig: OpenAILLMConfig; + modelKwargs: Record; + reasoningEffort?: OpenAILLMConfig['reasoning_effort']; + reasoningFormat?: ReasoningParameterFormat; + reasoningSummary?: OpenAILLMConfig['reasoning_summary']; +}): boolean { + if ( + !hasReasoningParams({ + reasoning_effort: reasoningEffort, + reasoning_summary: reasoningSummary, + }) + ) { + return false; + } + + const reasoning = getReasoningObject({ reasoningEffort, reasoningSummary }); + if (reasoningFormat === ReasoningParameterFormat.disabled) { + return false; + } + + if (isOpenAIEndpoint(endpoint)) { + if (llmConfig.useResponsesApi === true) { + llmConfig.reasoning = reasoning; + return false; + } + if (reasoningEffort) { + llmConfig.reasoning_effort = reasoningEffort; + } + return false; + } + + if (llmConfig.useResponsesApi === true) { + modelKwargs.reasoning = reasoning; + return true; + } + + if (reasoningFormat === ReasoningParameterFormat.reasoningObject) { + modelKwargs.reasoning = reasoning; + return true; + } + + if (reasoningEffort) { + modelKwargs.reasoning_effort = reasoningEffort; + return true; + } + + return false; +} + function getModelKwargsText(modelKwargs: Record): Record { const { text } = modelKwargs; if (text == null || typeof text !== 'object' || Array.isArray(text)) { @@ -294,6 +425,7 @@ export function getOpenAILLMConfig({ dropParams, defaultParams, useOpenRouter, + reasoningFormat = ReasoningParameterFormat.reasoningEffort, modelOptions: _modelOptions, }: { apiKey: string; @@ -305,6 +437,7 @@ export function getOpenAILLMConfig({ dropParams?: string[]; defaultParams?: Record; useOpenRouter?: boolean; + reasoningFormat?: ReasoningParameterFormat; azure?: false | t.AzureOptions; }): Pick & { azure?: t.AzureOptions; @@ -343,6 +476,8 @@ export function getOpenAILLMConfig({ const modelKwargs: Record = {}; let hasModelKwargs = false; + let reasoningEffort = reasoning_effort; + let reasoningSummary = reasoning_summary; if (verbosity != null && verbosity !== '' && useOpenRouter) { llmConfig.verbosity = verbosity; @@ -369,6 +504,18 @@ export function getOpenAILLMConfig({ } continue; } + if (key === 'reasoning_effort') { + if (!reasoningEffort && typeof value === 'string') { + reasoningEffort = value as OpenAILLMConfig['reasoning_effort']; + } + continue; + } + if (key === 'reasoning_summary') { + if (!reasoningSummary && typeof value === 'string') { + reasoningSummary = value as OpenAILLMConfig['reasoning_summary']; + } + continue; + } if (key === 'verbosity') { hasModelKwargs = applyVerbosityParam({ @@ -408,6 +555,18 @@ export function getOpenAILLMConfig({ } continue; } + if (key === 'reasoning_effort') { + if (typeof value === 'string' || value == null) { + reasoningEffort = value as OpenAILLMConfig['reasoning_effort']; + } + continue; + } + if (key === 'reasoning_summary') { + if (typeof value === 'string' || value == null) { + reasoningSummary = value as OpenAILLMConfig['reasoning_summary']; + } + continue; + } if (key === 'verbosity') { hasModelKwargs = applyVerbosityParam({ @@ -437,25 +596,11 @@ export function getOpenAILLMConfig({ */ hasModelKwargs = applyOpenRouterReasoningConfig({ - reasoningEffort: reasoning_effort, + reasoningEffort, model: modelOptions.model, modelKwargs, llmConfig, }) || hasModelKwargs; - } else if ( - hasReasoningParams({ reasoning_effort, reasoning_summary }) && - (llmConfig.useResponsesApi === true || - (endpoint !== EModelEndpoint.openAI && endpoint !== EModelEndpoint.azureOpenAI)) - ) { - llmConfig.reasoning = removeNullishValues( - { - effort: reasoning_effort, - summary: reasoning_summary, - }, - true, - ) as OpenAI.Reasoning; - } else if (hasReasoningParams({ reasoning_effort })) { - llmConfig.reasoning_effort = reasoning_effort; } if (llmConfig.max_tokens != null) { @@ -486,6 +631,18 @@ export function getOpenAILLMConfig({ llmConfig.promptCache = true; } + if (!useOpenRouter) { + hasModelKwargs = + applyReasoningConfig({ + endpoint, + llmConfig, + modelKwargs, + reasoningFormat, + reasoningEffort, + reasoningSummary, + }) || hasModelKwargs; + } + /** DeepSeek thinking-mode requires `reasoning_content` replay on tool turns (#13366). */ if ( typeof modelOptions.model === 'string' && @@ -515,11 +672,7 @@ export function getOpenAILLMConfig({ const updatedDropParams = dropParams || []; const combinedDropParams = [...new Set([...updatedDropParams, ...reasoningExcludeParams])]; - combinedDropParams.forEach((param) => { - if (param in llmConfig) { - delete llmConfig[param as keyof t.OAIClientOptions]; - } - }); + combinedDropParams.forEach((param) => deleteConfigParam({ param, llmConfig, modelKwargs })); } else if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) { /** * Note: OpenAI Web Search models do not support any known parameters besides `max_tokens` @@ -544,17 +697,9 @@ export function getOpenAILLMConfig({ const updatedDropParams = dropParams || []; const combinedDropParams = [...new Set([...updatedDropParams, ...searchExcludeParams])]; - combinedDropParams.forEach((param) => { - if (param in llmConfig) { - delete llmConfig[param as keyof t.OAIClientOptions]; - } - }); + combinedDropParams.forEach((param) => deleteConfigParam({ param, llmConfig, modelKwargs })); } else if (dropParams && Array.isArray(dropParams)) { - dropParams.forEach((param) => { - if (param in llmConfig) { - delete llmConfig[param as keyof t.OAIClientOptions]; - } - }); + dropParams.forEach((param) => deleteConfigParam({ param, llmConfig, modelKwargs })); } hasModelKwargs = @@ -576,7 +721,7 @@ export function getOpenAILLMConfig({ hasModelKwargs = true; } - if (hasModelKwargs) { + if (hasModelKwargs && Object.keys(modelKwargs).length > 0) { llmConfig.modelKwargs = modelKwargs; } diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 5420fb7354..4a29484728 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -10,7 +10,12 @@ import { summarizationTriggerSchema, summarizationConfigSchema, } from '../src/config'; -import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas'; +import { + tModelSpecPresetSchema, + EModelEndpoint, + ReasoningParameterFormat, + ReasoningResponseKey, +} from '../src/schemas'; import { specsConfigSchema } from '../src/models'; import { FileSources } from '../src/types/files'; @@ -305,6 +310,58 @@ describe('endpointSchema addParams validation', () => { expect(result.success).toBe(true); }); + it('accepts custom reasoning format config', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { + reasoningFormat: ReasoningParameterFormat.reasoningObject, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customParams?.reasoningFormat).toBe( + ReasoningParameterFormat.reasoningObject, + ); + } + }); + + it('rejects invalid custom reasoning format config', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { + reasoningFormat: 'provider_magic', + }, + }); + + expect(result.success).toBe(false); + }); + + it('accepts custom reasoning response key config', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { + reasoningKey: ReasoningResponseKey.reasoning, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customParams?.reasoningKey).toBe(ReasoningResponseKey.reasoning); + } + }); + + it('rejects invalid custom reasoning response key config', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { + reasoningKey: 'reasoning_text', + }, + }); + + expect(result.success).toBe(false); + }); + it('rejects non-boolean web_search objects in addParams', () => { const result = endpointSchema.safeParse({ ...validEndpoint, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 0799e52f35..b9e569ad08 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1,7 +1,13 @@ import { z } from 'zod'; import type { ZodError } from 'zod'; import type { TEndpointsConfig, TModelsConfig, TConfig } from './types'; -import { EModelEndpoint, eModelEndpointSchema, isAgentsEndpoint } from './schemas'; +import { + EModelEndpoint, + eModelEndpointSchema, + isAgentsEndpoint, + eReasoningParameterFormatSchema, + eReasoningResponseKeySchema, +} from './schemas'; import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; import { specsConfigSchema, TSpecsConfig } from './models'; import { fileConfigSchema } from './file-config'; @@ -630,6 +636,8 @@ export const endpointSchema = baseEndpointSchema.merge( customParams: z .object({ defaultParamsEndpoint: z.string().default('custom'), + reasoningFormat: eReasoningParameterFormatSchema.optional(), + reasoningKey: eReasoningResponseKeySchema.optional(), paramDefinitions: z.array(paramDefinitionSchema).optional(), }) .strict() diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 5693bdac46..2ca2567d84 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -177,6 +177,17 @@ export enum ReasoningEffort { xhigh = 'xhigh', } +export enum ReasoningParameterFormat { + disabled = 'disabled', + reasoningEffort = 'reasoning_effort', + reasoningObject = 'reasoning_object', +} + +export enum ReasoningResponseKey { + reasoning = 'reasoning', + reasoningContent = 'reasoning_content', +} + export enum AnthropicEffort { unset = '', low = 'low', @@ -250,6 +261,8 @@ export const imageDetailValue = { export const eImageDetailSchema = z.nativeEnum(ImageDetail); export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort); +export const eReasoningParameterFormatSchema = z.nativeEnum(ReasoningParameterFormat); +export const eReasoningResponseKeySchema = z.nativeEnum(ReasoningResponseKey); export const eAnthropicEffortSchema = z.nativeEnum(AnthropicEffort); export const eThinkingDisplaySchema = z.nativeEnum(ThinkingDisplay); export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary); diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 35774dd664..134ffc024e 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -7,6 +7,8 @@ import type { TAttachment, TMessage, TBanner, + ReasoningResponseKey, + ReasoningParameterFormat, } from './schemas'; import type { RefillIntervalUnit } from './balance'; import type { SettingDefinition } from './generate'; @@ -398,6 +400,8 @@ export type TConfig = { capabilities?: string[]; customParams?: { defaultParamsEndpoint?: string; + reasoningFormat?: ReasoningParameterFormat; + reasoningKey?: ReasoningResponseKey; paramDefinitions?: Partial[]; }; };