fix: Preserve custom endpoint reasoning params

This commit is contained in:
Danny Avila 2026-06-01 09:00:35 -04:00
parent 7dba640c9f
commit c839e61918
9 changed files with 273 additions and 31 deletions

View file

@ -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:

View file

@ -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<string, unknown>).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<string, unknown>).reasoning_effort).toBeUndefined();
});
@ -173,7 +173,7 @@ describe('getOpenAIConfig', () => {
expect((result.llmConfig as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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' }]);

View file

@ -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<string, string> | undefined,
defaultHeaders: Record<string, string>,
@ -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;

View file

@ -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', () => {

View file

@ -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<OpenAILLMConfig['verbosity']>
@ -204,6 +225,60 @@ function applyOpenRouterReasoningConfig({
return true;
}
function applyReasoningConfig({
endpoint,
llmConfig,
modelKwargs,
reasoningEffort,
reasoningFormat,
reasoningSummary,
}: {
endpoint?: EModelEndpoint | string | null;
llmConfig: OpenAILLMConfig;
modelKwargs: Record<string, unknown>;
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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
useOpenRouter?: boolean;
reasoningFormat?: ReasoningParameterFormat;
azure?: false | t.AzureOptions;
}): Pick<t.LLMConfigResult, 'llmConfig' | 'tools'> & {
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) {

View file

@ -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,

View file

@ -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()

View file

@ -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);

View file

@ -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<SettingDefinition>[];
};
};