Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion packages/api/src/endpoints/custom/config.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -43,6 +43,103 @@ describe('loadCustomEndpointsConfig – native provider param set', () => {
});
});

describe('loadCustomEndpointsConfig: host-implied reasoning support', () => {
const load = (endpoint: Record<string, unknown>) =>
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([
Expand Down
74 changes: 71 additions & 3 deletions packages/api/src/endpoints/custom/config.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderId> = new Set([ProviderId.openrouter]);

/** Backend params whose removal also removes the effort this inference would advertise. */
const reasoningDropParams: ReadonlySet<string> = new Set(['reasoning_effort', 'reasoning']);

type CustomParams = NonNullable<TEndpoint['customParams']>;

/**
* 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<CustomParams>;
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,
Comment thread
berry-13 marked this conversation as resolved.
Comment thread
berry-13 marked this conversation as resolved.
Comment thread
berry-13 marked this conversation as resolved.
} as TEndpoint['customParams'];
}

/**
* Load config endpoints from the cached configuration object
* @param customEndpointsConfig - The configuration object
Expand Down Expand Up @@ -37,6 +104,7 @@ export function loadCustomEndpointsConfig(
modelDisplayLabel,
customParams,
provider,
dropParams,
} = endpoint;
const name = normalizeEndpointName(configName);

Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/endpoints/custom/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const providerHosts: ReadonlyArray<readonly [string, ProviderId]> = [
* 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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/data-provider/src/parameterSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,7 @@ export const agentParamSettings: Record<string, SettingsConfiguration | undefine
return acc;
}, {});

const reasoningSettingKeys = [
export const reasoningSettingKeys = [
'reasoning_effort',
'effort',
'thinkingLevel',
Expand Down
Loading