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..bd9ace78a6 100644 --- a/packages/api/src/endpoints/openai/config.backward-compat.spec.ts +++ b/packages/api/src/endpoints/openai/config.backward-compat.spec.ts @@ -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..e97feedebf 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'; @@ -97,19 +98,18 @@ describe('getOpenAIConfig', () => { 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 +173,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 +181,79 @@ 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 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'; @@ -1212,6 +1278,7 @@ describe('getOpenAIConfig', () => { 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..df63759aec 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,58 @@ 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 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 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..d366e84588 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,26 @@ 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; +} + const openRouterAnthropicVerbosityByEffort: Record< string, NonNullable @@ -204,6 +225,60 @@ 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 (llmConfig.useResponsesApi === true) { + llmConfig.reasoning = reasoning; + return false; + } + + if (isOpenAIEndpoint(endpoint)) { + if (reasoningEffort) { + llmConfig.reasoning_effort = reasoningEffort; + } + return false; + } + + if (reasoningFormat === ReasoningParameterFormat.disabled) { + return false; + } + + 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 +369,7 @@ export function getOpenAILLMConfig({ dropParams, defaultParams, useOpenRouter, + reasoningFormat = ReasoningParameterFormat.reasoningEffort, modelOptions: _modelOptions, }: { apiKey: string; @@ -305,6 +381,7 @@ export function getOpenAILLMConfig({ dropParams?: string[]; defaultParams?: Record; useOpenRouter?: boolean; + reasoningFormat?: ReasoningParameterFormat; azure?: false | t.AzureOptions; }): Pick & { azure?: t.AzureOptions; @@ -442,20 +519,16 @@ export function getOpenAILLMConfig({ 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; + } else { + hasModelKwargs = + applyReasoningConfig({ + endpoint, + llmConfig, + modelKwargs, + reasoningFormat, + reasoningEffort: reasoning_effort, + reasoningSummary: reasoning_summary, + }) || hasModelKwargs; } if (llmConfig.max_tokens != null) { diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 5420fb7354..50e2488b40 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -10,7 +10,7 @@ import { summarizationTriggerSchema, summarizationConfigSchema, } from '../src/config'; -import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas'; +import { tModelSpecPresetSchema, EModelEndpoint, ReasoningParameterFormat } from '../src/schemas'; import { specsConfigSchema } from '../src/models'; import { FileSources } from '../src/types/files'; @@ -305,6 +305,33 @@ 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('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..7c9ff43b30 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1,7 +1,12 @@ 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, +} from './schemas'; import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; import { specsConfigSchema, TSpecsConfig } from './models'; import { fileConfigSchema } from './file-config'; @@ -630,6 +635,7 @@ export const endpointSchema = baseEndpointSchema.merge( customParams: z .object({ defaultParamsEndpoint: z.string().default('custom'), + reasoningFormat: eReasoningParameterFormatSchema.optional(), paramDefinitions: z.array(paramDefinitionSchema).optional(), }) .strict() diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index c3cb4cef39..8e2f374bcc 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -177,6 +177,12 @@ export enum ReasoningEffort { xhigh = 'xhigh', } +export enum ReasoningParameterFormat { + disabled = 'disabled', + reasoningEffort = 'reasoning_effort', + reasoningObject = 'reasoning_object', +} + export enum AnthropicEffort { unset = '', low = 'low', @@ -250,6 +256,7 @@ export const imageDetailValue = { export const eImageDetailSchema = z.nativeEnum(ImageDetail); export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort); +export const eReasoningParameterFormatSchema = z.nativeEnum(ReasoningParameterFormat); 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..6a8b6c2f88 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -7,6 +7,7 @@ import type { TAttachment, TMessage, TBanner, + ReasoningParameterFormat, } from './schemas'; import type { RefillIntervalUnit } from './balance'; import type { SettingDefinition } from './generate'; @@ -398,6 +399,7 @@ export type TConfig = { capabilities?: string[]; customParams?: { defaultParamsEndpoint?: string; + reasoningFormat?: ReasoningParameterFormat; paramDefinitions?: Partial[]; }; };