From 1ab2bda818e44efb855d50ac9c36c289677406be Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:22:17 +0200 Subject: [PATCH 1/3] feat: Infer Reasoning Effort Support From Known Custom Endpoint Hosts --- .../api/src/endpoints/custom/config.spec.ts | 63 ++++++++++++++++++- packages/api/src/endpoints/custom/config.ts | 60 +++++++++++++++++- .../api/src/endpoints/custom/providers.ts | 2 +- .../data-provider/src/parameterSettings.ts | 2 +- 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts index b0154b3b41b..198c7bf36a3 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,67 @@ 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.each(['https://openrouter.ai/api/v1', 'https://api.x.ai/v1', 'https://api.openai.com/v1'])( + 'declares effort-style reasoning for the known host %s', + (baseURL) => { + expect(load({ baseURL })?.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://api.x.ai/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://api.x.ai/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..d2bd332db74 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -1,9 +1,63 @@ -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 whose Chat Completions API takes a flat `reasoning_effort` (OpenRouter + * takes it as `reasoning.effort`, which the OpenRouter path builds itself). + * Matched on the base URL host only: a name or icon says nothing about what the + * server behind it accepts. + */ +const effortReasoningHosts: ReadonlySet = new Set([ + ProviderId.openrouter, + ProviderId.openai, + ProviderId.xai, +]); + +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. + */ +function withHostReasoning( + customParams: TEndpoint['customParams'], + baseURL: string, + provider?: string, +): TEndpoint['customParams'] { + const host = providerFromBaseURL(baseURL); + if (provider != null || host == null || !effortReasoningHosts.has(host)) { + 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) + ) { + return customParams; + } + return { + ...params, + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + } as TEndpoint['customParams']; +} + /** * Load config endpoints from the cached configuration object * @param customEndpointsConfig - The configuration object @@ -55,7 +109,7 @@ export function loadCustomEndpointsConfig( (customParams?.defaultParamsEndpoint == null || customParams.defaultParamsEndpoint === EModelEndpoint.custom) ? { ...customParams, defaultParamsEndpoint: provider } - : customParams; + : withHostReasoning(customParams, resolvedBaseURL, provider); 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 Date: Tue, 6 Oct 2026 00:32:13 +0200 Subject: [PATCH 2/3] fix: Gate Inferred Reasoning on Provider Support and dropParams --- .../api/src/endpoints/custom/config.spec.ts | 30 +++++++++++++--- packages/api/src/endpoints/custom/config.ts | 36 ++++++++++++------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts index 198c7bf36a3..ca3ad3fc367 100644 --- a/packages/api/src/endpoints/custom/config.spec.ts +++ b/packages/api/src/endpoints/custom/config.spec.ts @@ -49,13 +49,33 @@ describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { { ...baseEndpoint, name: 'Gateway', ...endpoint }, ] as unknown as TCustomEndpoints)?.Gateway?.customParams; - it.each(['https://openrouter.ai/api/v1', 'https://api.x.ai/v1', 'https://api.openai.com/v1'])( - 'declares effort-style reasoning for the known host %s', + 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 })?.reasoningFormat).toBe(ReasoningParameterFormat.reasoningEffort); + 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('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, @@ -80,7 +100,7 @@ describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { it('defers to explicit reasoning parameter definitions', () => { const customParams = load({ - baseURL: 'https://api.x.ai/v1', + baseURL: 'https://openrouter.ai/api/v1', customParams: { paramDefinitions: [{ key: 'thinkingLevel' }] }, }); expect(customParams?.reasoningFormat).toBeUndefined(); @@ -90,7 +110,7 @@ describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { it('defers to an explicit non-default params endpoint', () => { expect( load({ - baseURL: 'https://api.x.ai/v1', + baseURL: 'https://openrouter.ai/api/v1', customParams: { defaultParamsEndpoint: EModelEndpoint.anthropic }, })?.reasoningFormat, ).toBeUndefined(); diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index d2bd332db74..1379c9dc97a 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -12,16 +12,20 @@ import { resolveEndpointProviderId, providerFromBaseURL } from './providers'; import { isUserProvided } from '~/utils'; /** - * Hosts whose Chat Completions API takes a flat `reasoning_effort` (OpenRouter - * takes it as `reasoning.effort`, which the OpenRouter path builds itself). - * Matched on the base URL host only: a name or icon says nothing about what the - * server behind it accepts. + * 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, - ProviderId.openai, - ProviderId.xai, -]); +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; @@ -29,15 +33,22 @@ 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. + * (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)) { + if ( + provider != null || + host == null || + !effortReasoningHosts.has(host) || + dropParams?.some((param) => reasoningDropParams.has(param)) === true + ) { return customParams; } const params = (customParams ?? {}) as Partial; @@ -91,6 +102,7 @@ export function loadCustomEndpointsConfig( modelDisplayLabel, customParams, provider, + dropParams, } = endpoint; const name = normalizeEndpointName(configName); @@ -109,7 +121,7 @@ export function loadCustomEndpointsConfig( (customParams?.defaultParamsEndpoint == null || customParams.defaultParamsEndpoint === EModelEndpoint.custom) ? { ...customParams, defaultParamsEndpoint: provider } - : withHostReasoning(customParams, resolvedBaseURL, provider); + : withHostReasoning(customParams, resolvedBaseURL, provider, dropParams); customEndpointsConfig[name] = { type: EModelEndpoint.custom, From 427c6749f459a8dc2757c78bcb929b2f6bcb738c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:37:26 +0200 Subject: [PATCH 3/3] fix: Infer Reasoning for the OpenRouter Params Endpoint Set by the Loader --- packages/api/src/endpoints/custom/config.spec.ts | 16 ++++++++++++++++ packages/api/src/endpoints/custom/config.ts | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts index ca3ad3fc367..8fad8b31ed2 100644 --- a/packages/api/src/endpoints/custom/config.spec.ts +++ b/packages/api/src/endpoints/custom/config.spec.ts @@ -69,6 +69,22 @@ describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { }, ); + 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'] }) diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index 1379c9dc97a..19cc4a2e7a9 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -59,7 +59,9 @@ function withHostReasoning( if ( params.reasoningFormat != null || declaresReasoning === true || - (paramsEndpoint != null && paramsEndpoint !== EModelEndpoint.custom) + (paramsEndpoint != null && + paramsEndpoint !== EModelEndpoint.custom && + paramsEndpoint !== ProviderId.openrouter) ) { return customParams; }