💭 fix: Preserve Custom Endpoint Reasoning Params (#13447)

* 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
This commit is contained in:
Danny Avila 2026-06-01 18:20:20 -04:00 committed by GitHub
parent 730878bc5a
commit 2ab432bd0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 635 additions and 66 deletions

View file

@ -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`.

View file

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

View file

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

View file

@ -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<typeof getReasoningKey>[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<typeof getReasoningKey>[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<typeof getReasoningKey>[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<typeof getReasoningKey>[1];
const reasoningKey = getReasoningKey(
Providers.OPENAI,
llmConfig,
'Company Gateway',
ReasoningResponseKey.reasoning,
);
expect(reasoningKey).toBe('reasoning');
});
});
describe('isDeepSeekReasoningProvider', () => {

View file

@ -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<Agent, 'tools'> & {
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,

View file

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

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';
@ -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<string, unknown>).reasoning_effort).toBeUndefined();
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 +176,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 +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<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 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<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';
@ -1006,11 +1098,12 @@ describe('getOpenAIConfig', () => {
const result = getOpenAIConfig(mockApiKey, {
modelOptions: { ...modelOptions, useResponsesApi: true } as Partial<OpenAIParameters>,
});
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<string, unknown>).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' }]);

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

View file

@ -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<string, unknown>) {
const { reasoning } = target;
if (reasoning == null || typeof reasoning !== 'object' || Array.isArray(reasoning)) {
return;
}
const rest = { ...(reasoning as Record<string, unknown>) };
delete rest.summary;
if (Object.keys(rest).length === 0) {
delete target.reasoning;
return;
}
target.reasoning = rest;
}
function removeReasoningPayload(target: Record<string, unknown>) {
delete target.reasoning;
delete target.reasoning_effort;
}
function deleteConfigParam({
param,
llmConfig,
modelKwargs,
}: {
param: string;
llmConfig: OpenAILLMConfig;
modelKwargs: Record<string, unknown>;
}) {
if (param === 'reasoning_effort') {
removeReasoningPayload(llmConfig as Record<string, unknown>);
removeReasoningPayload(modelKwargs);
return;
}
if (param === 'reasoning_summary') {
delete (llmConfig as Record<string, unknown>).reasoning_summary;
delete modelKwargs.reasoning_summary;
removeReasoningSummary(llmConfig as Record<string, unknown>);
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<OpenAILLMConfig['verbosity']>
@ -204,6 +277,64 @@ 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 (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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
useOpenRouter?: boolean;
reasoningFormat?: ReasoningParameterFormat;
azure?: false | t.AzureOptions;
}): Pick<t.LLMConfigResult, 'llmConfig' | 'tools'> & {
azure?: t.AzureOptions;
@ -343,6 +476,8 @@ export function getOpenAILLMConfig({
const modelKwargs: Record<string, unknown> = {};
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;
}

View file

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

View file

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

View file

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

View file

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