diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts index b0154b3b41b..8fad8b31ed2 100644 --- a/packages/api/src/endpoints/custom/config.spec.ts +++ b/packages/api/src/endpoints/custom/config.spec.ts @@ -1,4 +1,4 @@ -import { AuthType, EModelEndpoint } from 'librechat-data-provider'; +import { AuthType, EModelEndpoint, ReasoningParameterFormat } from 'librechat-data-provider'; import type { TCustomEndpoints } from 'librechat-data-provider'; import { loadCustomEndpointsConfig } from './config'; @@ -43,6 +43,103 @@ describe('loadCustomEndpointsConfig – native provider param set', () => { }); }); +describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { + const load = (endpoint: Record) => + loadCustomEndpointsConfig([ + { ...baseEndpoint, name: 'Gateway', ...endpoint }, + ] as unknown as TCustomEndpoints)?.Gateway?.customParams; + + it('declares effort-style reasoning for OpenRouter', () => { + expect(load({ baseURL: 'https://openrouter.ai/api/v1' })?.reasoningFormat).toBe( + ReasoningParameterFormat.reasoningEffort, + ); + }); + + it.each(['https://api.openai.com/v1', 'https://api.x.ai/v1'])( + 'leaves %s to explicit config: its capability depends on the model', + (baseURL) => { + expect(load({ baseURL })).toBeUndefined(); + }, + ); + + it.each([['reasoning_effort'], ['reasoning']])( + 'does not advertise reasoning that dropParams %j removes', + (...dropParams) => { + expect(load({ baseURL: 'https://openrouter.ai/api/v1', dropParams })).toBeUndefined(); + }, + ); + + it('advertises reasoning for the OpenRouter params endpoint the config loader injects', () => { + expect( + load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'promptCache', default: true }], + }, + }), + ).toEqual({ + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'promptCache', default: true }], + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + }); + }); + + it('still advertises reasoning when dropParams removes something else', () => { + expect( + load({ baseURL: 'https://openrouter.ai/api/v1', dropParams: ['temperature'] }) + ?.reasoningFormat, + ).toBe(ReasoningParameterFormat.reasoningEffort); + }); + + it('does not infer reasoning from the endpoint name alone', () => { + expect(load({ name: 'Gateway', baseURL: 'http://localhost:8080/v1', iconURL: 'xai' })).toBe( + undefined, + ); + }); + + it.each(['https://api.mistral.ai/v1', 'https://api.groq.com/openai/v1'])( + 'does not infer reasoning for the host %s without verified effort support', + (baseURL) => { + expect(load({ baseURL })).toBeUndefined(); + }, + ); + + it('keeps an explicit reasoning format', () => { + expect( + load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { reasoningFormat: ReasoningParameterFormat.disabled }, + })?.reasoningFormat, + ).toBe(ReasoningParameterFormat.disabled); + }); + + it('defers to explicit reasoning parameter definitions', () => { + const customParams = load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { paramDefinitions: [{ key: 'thinkingLevel' }] }, + }); + expect(customParams?.reasoningFormat).toBeUndefined(); + expect(customParams?.paramDefinitions).toEqual([{ key: 'thinkingLevel' }]); + }); + + it('defers to an explicit non-default params endpoint', () => { + expect( + load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { defaultParamsEndpoint: EModelEndpoint.anthropic }, + })?.reasoningFormat, + ).toBeUndefined(); + }); + + it('leaves a native provider endpoint to its own param set', () => { + expect( + load({ baseURL: 'https://openrouter.ai/api/v1', provider: EModelEndpoint.anthropic }) + ?.reasoningFormat, + ).toBeUndefined(); + }); +}); + describe('loadCustomEndpointsConfig – user credential prompts', () => { it('requires a user key when the custom base URL is user-provided', () => { const config = loadCustomEndpointsConfig([ diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index 2ebc495086c..19cc4a2e7a9 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -1,9 +1,76 @@ -import { EModelEndpoint, extractEnvVariable, normalizeEndpointName } from 'librechat-data-provider'; +import { + ProviderId, + EModelEndpoint, + extractEnvVariable, + normalizeEndpointName, + reasoningSettingKeys, + ReasoningParameterFormat, +} from 'librechat-data-provider'; import type { TCustomEndpoints, TEndpoint } from 'librechat-data-provider'; import type { TCustomEndpointsConfig } from '~/types/endpoints'; -import { resolveEndpointProviderId } from './providers'; +import { resolveEndpointProviderId, providerFromBaseURL } from './providers'; import { isUserProvided } from '~/utils'; +/** + * Hosts that accept an effort for any model they serve. OpenRouter takes it as + * `reasoning.effort`, which the OpenRouter request path builds itself. Matched on + * the base URL host only: a name or icon says nothing about what the server + * behind it accepts. + * + * OpenAI and xAI are excluded on purpose. Their support and allowed values depend + * on the model (gpt-4o takes no effort, grok-4.6 and grok-4.7 take different + * sets, GPT-5.6 with tools needs the Responses route a custom endpoint does not + * get), and this inference is made per endpoint, before a model is chosen. + */ +const effortReasoningHosts: ReadonlySet = new Set([ProviderId.openrouter]); + +/** Backend params whose removal also removes the effort this inference would advertise. */ +const reasoningDropParams: ReadonlySet = new Set(['reasoning_effort', 'reasoning']); + +type CustomParams = NonNullable; + +/** + * Declares reasoning support for a known host so the effort control appears + * without per-endpoint config. Anything the admin stated wins: a native + * `provider`, a non-default `defaultParamsEndpoint`, a `reasoningFormat` + * (including `disabled`), or reasoning parameter definitions. An endpoint that + * drops the effort before sending it is not advertised as supporting it. + */ +function withHostReasoning( + customParams: TEndpoint['customParams'], + baseURL: string, + provider?: string, + dropParams?: string[], +): TEndpoint['customParams'] { + const host = providerFromBaseURL(baseURL); + if ( + provider != null || + host == null || + !effortReasoningHosts.has(host) || + dropParams?.some((param) => reasoningDropParams.has(param)) === true + ) { + return customParams; + } + const params = (customParams ?? {}) as Partial; + const declaresReasoning = params.paramDefinitions?.some((setting) => + reasoningSettingKeys.some((key) => key === setting.key), + ); + const paramsEndpoint = params.defaultParamsEndpoint; + if ( + params.reasoningFormat != null || + declaresReasoning === true || + (paramsEndpoint != null && + paramsEndpoint !== EModelEndpoint.custom && + paramsEndpoint !== ProviderId.openrouter) + ) { + return customParams; + } + return { + ...params, + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + } as TEndpoint['customParams']; +} + /** * Load config endpoints from the cached configuration object * @param customEndpointsConfig - The configuration object @@ -37,6 +104,7 @@ export function loadCustomEndpointsConfig( modelDisplayLabel, customParams, provider, + dropParams, } = endpoint; const name = normalizeEndpointName(configName); @@ -55,7 +123,7 @@ export function loadCustomEndpointsConfig( (customParams?.defaultParamsEndpoint == null || customParams.defaultParamsEndpoint === EModelEndpoint.custom) ? { ...customParams, defaultParamsEndpoint: provider } - : customParams; + : withHostReasoning(customParams, resolvedBaseURL, provider, dropParams); customEndpointsConfig[name] = { type: EModelEndpoint.custom, diff --git a/packages/api/src/endpoints/custom/providers.ts b/packages/api/src/endpoints/custom/providers.ts index 1fed231e75b..a7491a2fb7a 100644 --- a/packages/api/src/endpoints/custom/providers.ts +++ b/packages/api/src/endpoints/custom/providers.ts @@ -35,7 +35,7 @@ export const providerHosts: ReadonlyArray = [ * Ollama run on operator-defined hosts. They resolve by iconURL, provider or name. */ -function providerFromBaseURL(baseURL?: string): ProviderId | undefined { +export function providerFromBaseURL(baseURL?: string): ProviderId | undefined { if (!baseURL) { return undefined; } diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 7d21c90d8ce..1fd98abeb53 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -1322,7 +1322,7 @@ export const agentParamSettings: Record