diff --git a/api/server/controllers/ReasoningCapabilitiesController.js b/api/server/controllers/ReasoningCapabilitiesController.js new file mode 100644 index 00000000000..def7ed9de1e --- /dev/null +++ b/api/server/controllers/ReasoningCapabilitiesController.js @@ -0,0 +1,3 @@ +const { getReasoningCapabilities, createReasoningCapabilitiesHandler } = require('@librechat/api'); + +module.exports = createReasoningCapabilitiesHandler({ getReasoningCapabilities }); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 4ff306ccab6..42d114c34a6 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -6,6 +6,7 @@ const { inspectContent, extractChatContent, contentFilterBlockResponse, + getReasoningCapabilities, applyRequestReasoningOverride, } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); @@ -193,6 +194,8 @@ async function buildEndpointOption(req, res, next) { defaultParamsEndpoint, appliedModelSpecPrivateFields, enforcedModelSpecFields, + loadReasoningCapabilities: (endpointName) => + getReasoningCapabilities(appConfig, endpointName), }); if (!reasoningApplied) { return handleError(res, { text: 'Invalid reasoning override' }); diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index 2ca373b8ecd..948d4a41af5 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -3,10 +3,17 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const configMiddleware = require('~/server/middleware/config/app'); const endpointController = require('~/server/controllers/EndpointController'); const tokenConfigController = require('~/server/controllers/TokenConfigController'); +const reasoningCapabilitiesController = require('~/server/controllers/ReasoningCapabilitiesController'); const router = express.Router(); /** Auth required for role/tenant-scoped endpoint config resolution. */ router.get('/', requireJwtAuth, configMiddleware, endpointController); router.get('/token-config', requireJwtAuth, configMiddleware, tokenConfigController); +router.get( + '/reasoning-capabilities', + requireJwtAuth, + configMiddleware, + reasoningCapabilitiesController, +); module.exports = router; diff --git a/client/src/components/Chat/Input/Reasoning.tsx b/client/src/components/Chat/Input/Reasoning.tsx index 25ec7fa9375..21fba225ec5 100644 --- a/client/src/components/Chat/Input/Reasoning.tsx +++ b/client/src/components/Chat/Input/Reasoning.tsx @@ -24,6 +24,7 @@ import type { import type { TranslationKeys } from '~/hooks'; import { getReasoningStateKey, pendingReasoningOverrideFamily } from './Composer/state'; import { useGetAgentByIdQuery, useGetEndpointsQuery } from '~/data-provider'; +import { useModelReasoning } from '~/hooks/Endpoint/useModelReasoning'; import { formatTokens, resolveAgentTarget } from '~/utils'; import { useAgentsMapContext } from '~/Providers'; import { useLocalize } from '~/hooks'; @@ -336,6 +337,11 @@ export function useComposerReasoning({ const provider = isAgent ? (agentTarget?.provider ?? '') : (conversation?.endpoint ?? ''); const model = isAgent ? (agentTarget?.model ?? '') : (conversation?.model ?? ''); const endpointType = getEndpointField(endpointsConfig, provider, 'type'); + const { modelReasoning, pending: capabilitiesPending } = useModelReasoning( + endpointsConfig, + provider, + model, + ); const setting = useMemo(() => { const customParams = endpointsConfig[provider]?.customParams ?? {}; return resolveReasoningSettingForTarget({ @@ -346,18 +352,28 @@ export function useComposerReasoning({ reasoningFormat: customParams.reasoningFormat, paramDefinitions: customParams.paramDefinitions, blockedReasoningKeys, + modelReasoning, }); - }, [blockedReasoningKeys, endpointType, endpointsConfig, isAgent, model, provider]); - const settingFingerprint = - setting == null - ? '' - : `${setting.key}:${setting.type}:${setting.options?.join(',') ?? ''}:${setting.range?.min ?? ''}:${setting.range?.positiveMin ?? ''}:${setting.range?.max ?? ''}:${setting.range?.step ?? ''}`; + }, [ + blockedReasoningKeys, + endpointType, + endpointsConfig, + isAgent, + model, + modelReasoning, + provider, + ]); const targetResolved = endpoint !== '' && (!isAgent || agentTarget != null) && (setting != null || endpointsQuery.data != null); + /* The target is the endpoint or agent and the model. The efforts a catalog advertises for it + are not part of the identity: a refresh that changes them is not the user switching targets, + and a value the refreshed setting no longer allows is cleared as `mismatchedSetting` below. It + is recorded even while the capabilities load, so a switch made then is not mistaken for the + first target once they arrive. */ const targetFingerprint = targetResolved - ? `${isAgent ? conversation?.agent_id : provider}:${model}:${settingFingerprint}` + ? `${isAgent ? conversation?.agent_id : provider}:${model}` : null; const previousTarget = useRef({ key: reasoningStateKey, fingerprint: targetFingerprint }); const explicitlyUnavailable = @@ -386,13 +402,19 @@ export function useComposerReasoning({ key may survive while its enum value or number no longer does. */ const mismatchedSetting = setting != null && value != null && !isReasoningOverrideSupported(value, setting); + /* While the capabilities are unknown the setting is hidden, which says nothing about the + staged choice: keep it until the catalog confirms or refutes it. A switch of target is + different, because the choice belonged to the old one, so it clears at once. */ if ( - (explicitlyUnavailable || unsupportedResolved || targetChanged || mismatchedSetting) && - value != null + value != null && + (targetChanged || + (!capabilitiesPending && + (explicitlyUnavailable || unsupportedResolved || mismatchedSetting))) ) { setValue(undefined); } }, [ + capabilitiesPending, explicitlyUnavailable, reasoningStateKey, setValue, diff --git a/client/src/components/Chat/Input/__tests__/Reasoning.spec.tsx b/client/src/components/Chat/Input/__tests__/Reasoning.spec.tsx index 3031f2f87fc..0b617f79999 100644 --- a/client/src/components/Chat/Input/__tests__/Reasoning.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/Reasoning.spec.tsx @@ -7,6 +7,7 @@ import type { TConversation, TReasoningOverride, TSubmission, + TReasoningCapabilityMap, } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import { getReasoningStateKey, pendingReasoningOverrideFamily } from '../Composer/state'; @@ -21,10 +22,19 @@ type MockEndpointConfig = { }; }; let mockEndpointsConfig: Record | undefined = {}; +let mockCapabilities: TReasoningCapabilityMap | undefined; +let mockCapabilitiesLoading = false; jest.mock('~/data-provider', () => ({ useGetAgentByIdQuery: () => ({ data: undefined }), useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useReasoningCapabilitiesQuery: (_endpoint: string, config?: { enabled?: boolean }) => ({ + data: + config?.enabled === false || mockCapabilities == null + ? undefined + : { capabilities: mockCapabilities, expiresInMs: 3_600_000 }, + isInitialLoading: config?.enabled !== false && mockCapabilitiesLoading, + }), })); jest.mock('~/Providers', () => ({ @@ -37,6 +47,8 @@ jest.mock('~/hooks', () => ({ beforeEach(() => { mockEndpointsConfig = {}; + mockCapabilities = undefined; + mockCapabilitiesLoading = false; }); const enumSetting: SettingDefinition = { @@ -800,3 +812,227 @@ describe('useComposerReasoning', () => { expect(rendered.result.current?.setting.key).toBe('reasoning_effort'); }); }); + +describe('useComposerReasoning: OpenRouter per-model efforts', () => { + const model = 'openai/gpt-6.1-sol'; + const conversation = { + title: null, + conversationId: 'openrouter-conversation', + endpoint: 'OpenRouter', + endpointType: 'custom', + model, + createdAt: '', + updatedAt: '', + } as unknown as TConversation; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const render = () => + renderHook(() => useComposerReasoning({ conversation, index: 0, enabled: true }), { wrapper }); + + beforeEach(() => { + mockEndpointsConfig = { + OpenRouter: { + type: 'custom', + customParams: { + defaultParamsEndpoint: 'openrouter', + reasoningFormat: 'reasoning_effort', + }, + } as MockEndpointConfig, + }; + }); + + it('offers no control while the capabilities are still loading', () => { + mockCapabilitiesLoading = true; + + expect(render().result.current).toBeNull(); + }); + + it('keeps an administrator-defined effort control while the capabilities load', () => { + mockCapabilitiesLoading = true; + mockEndpointsConfig = { + OpenRouter: { + type: 'custom', + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [enumSetting], + }, + }, + }; + + expect(render().result.current?.setting.key).toBe('reasoning_effort'); + }); + + it('offers no control when the capabilities could not be read', () => { + mockCapabilities = undefined; + mockCapabilitiesLoading = false; + + expect(render().result.current).toBeNull(); + }); + + it('offers only the efforts the selected model supports', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + + expect(render().result.current?.setting.options).toEqual([ + ReasoningEffort.unset, + ReasoningEffort.low, + ReasoningEffort.high, + ]); + }); + + it('hides the control for a model the provider lists without reasoning', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: [] } } }; + + expect(render().result.current).toBeNull(); + }); + + it('keeps the generic efforts for a model the catalog does not list', () => { + mockCapabilities = { OpenRouter: { 'meta/other': { efforts: ['low'] } } }; + + expect(render().result.current?.setting.options).toContain(ReasoningEffort.max); + }); +}); + +describe('useComposerReasoning: restored override while OpenRouter capabilities load', () => { + const model = 'openai/gpt-6.1-sol'; + const conversation = { + title: null, + conversationId: 'restored-conversation', + endpoint: 'OpenRouter', + endpointType: 'custom', + model, + createdAt: '', + updatedAt: '', + } as unknown as TConversation; + const staged = { key: 'reasoning_effort', value: ReasoningEffort.low } as TReasoningOverride; + const stagedValue = (store: ReturnType) => + store.get(pendingReasoningOverrideFamily('restored-conversation')); + const setup = () => { + const store = createStore(); + store.set(pendingReasoningOverrideFamily('restored-conversation'), staged); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const rendered = renderHook( + () => useComposerReasoning({ conversation, index: 0, enabled: true }), + { wrapper }, + ); + return { store, rendered }; + }; + + beforeEach(() => { + mockEndpointsConfig = { + OpenRouter: { + type: 'custom', + customParams: { + defaultParamsEndpoint: 'openrouter', + reasoningFormat: 'reasoning_effort', + }, + } as MockEndpointConfig, + }; + }); + + it('keeps a staged override while the capabilities are loading', async () => { + mockCapabilitiesLoading = true; + + const { store } = setup(); + await act(async () => {}); + + expect(stagedValue(store)).toEqual(staged); + }); + + it('keeps a staged override while the capabilities request has failed', async () => { + mockCapabilities = undefined; + mockCapabilitiesLoading = false; + + const { store } = setup(); + await act(async () => {}); + + expect(stagedValue(store)).toEqual(staged); + }); + + it('keeps it once the loaded capabilities confirm it', async () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + + const { store } = setup(); + await act(async () => {}); + + expect(stagedValue(store)).toEqual(staged); + }); + + it('drops a staged override at once when the target changes while the capabilities load', async () => { + mockCapabilitiesLoading = true; + const store = createStore(); + store.set(pendingReasoningOverrideFamily('restored-conversation'), staged); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const rendered = renderHook( + ({ activeModel }: { activeModel: string }) => + useComposerReasoning({ + conversation: { ...conversation, model: activeModel } as TConversation, + index: 0, + enabled: true, + }), + { wrapper, initialProps: { activeModel: model } }, + ); + await act(async () => {}); + expect(stagedValue(store)).toEqual(staged); + + rendered.rerender({ activeModel: 'google/gemini-3.5-flash' }); + await act(async () => {}); + expect(stagedValue(store)).toBeUndefined(); + + mockCapabilitiesLoading = false; + mockCapabilities = { + OpenRouter: { 'google/gemini-3.5-flash': { efforts: ['low', 'high'] } }, + }; + rendered.rerender({ activeModel: 'google/gemini-3.5-flash' }); + await act(async () => {}); + + expect(stagedValue(store)).toBeUndefined(); + }); + + it('keeps a supported override across the loading to loaded transition', async () => { + mockCapabilitiesLoading = true; + const { store, rendered } = setup(); + await act(async () => {}); + expect(stagedValue(store)).toEqual(staged); + + mockCapabilitiesLoading = false; + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + rendered.rerender(); + await act(async () => {}); + + expect(rendered.result.current?.setting.options).toEqual(['', 'low', 'high']); + expect(stagedValue(store)).toEqual(staged); + }); + + it('keeps a supported override when a refresh changes the other advertised efforts', async () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + const { store, rendered } = setup(); + await act(async () => {}); + expect(stagedValue(store)).toEqual(staged); + + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'medium', 'high'] } } }; + rendered.rerender(); + await act(async () => {}); + + expect(rendered.result.current?.setting.options).toEqual(['', 'low', 'medium', 'high']); + expect(stagedValue(store)).toEqual(staged); + }); + + it('clears it only after the loaded capabilities prove it unsupported', async () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['high'] } } }; + + const { store } = setup(); + + await waitFor(() => expect(stagedValue(store)).toBeUndefined()); + }); +}); diff --git a/client/src/components/Endpoints/Settings/OpenAI.tsx b/client/src/components/Endpoints/Settings/OpenAI.tsx index dff953d79d8..ad501771e7a 100644 --- a/client/src/components/Endpoints/Settings/OpenAI.tsx +++ b/client/src/components/Endpoints/Settings/OpenAI.tsx @@ -3,6 +3,7 @@ import { presetSettings, getSettingsKeys, applyModelAwareDefaults } from 'librec import type { SettingDefinition } from 'librechat-data-provider'; import type { TModelSelectProps } from '~/common'; import { componentMapping } from '~/components/SidePanel/Parameters/components'; +import { useModelReasoning } from '~/hooks/Endpoint/useModelReasoning'; import { useGetEndpointsQuery } from '~/data-provider'; export default function OpenAISettings({ @@ -12,6 +13,11 @@ export default function OpenAISettings({ readonly, }: TModelSelectProps) { const { data: endpointsConfig } = useGetEndpointsQuery(); + const { modelReasoning } = useModelReasoning( + endpointsConfig, + conversation?.endpoint ?? '', + conversation?.model ?? '', + ); const parameters = useMemo(() => { const [combinedKey, endpointKey] = getSettingsKeys( conversation?.endpointType ?? conversation?.endpoint ?? '', @@ -21,21 +27,27 @@ export default function OpenAISettings({ if (!settings) { return undefined; } + /** A custom endpoint's own parameter set (e.g. OpenRouter's) decides its model-aware defaults. */ + const paramsKey = + endpointsConfig?.[conversation?.endpoint ?? '']?.customParams?.defaultParamsEndpoint ?? + endpointKey; return { col1: applyModelAwareDefaults( settings.col1, - endpointKey, + paramsKey, conversation?.model ?? undefined, endpointsConfig?.[conversation?.endpoint ?? '']?.responsesApiRouting, + modelReasoning, ), col2: applyModelAwareDefaults( settings.col2, - endpointKey, + paramsKey, conversation?.model ?? undefined, endpointsConfig?.[conversation?.endpoint ?? '']?.responsesApiRouting, + modelReasoning, ), }; - }, [conversation, endpointsConfig]); + }, [conversation, endpointsConfig, modelReasoning]); if (!parameters) { return null; diff --git a/client/src/components/Endpoints/Settings/__tests__/OpenAI.spec.tsx b/client/src/components/Endpoints/Settings/__tests__/OpenAI.spec.tsx index 3c6382a3f93..130be453f56 100644 --- a/client/src/components/Endpoints/Settings/__tests__/OpenAI.spec.tsx +++ b/client/src/components/Endpoints/Settings/__tests__/OpenAI.spec.tsx @@ -137,3 +137,49 @@ describe.each([EModelEndpoint.openAI, EModelEndpoint.azureOpenAI])( }); }, ); + +describe('OpenRouter custom endpoint effort list', () => { + const model = 'openai/gpt-6.1-sol'; + const renderSettings = (capabilities?: Record) => { + const queryClient = new QueryClient(); + queryClient.setQueryData([QueryKeys.endpoints], { + OpenRouter: { + type: EModelEndpoint.custom, + customParams: { defaultParamsEndpoint: 'openrouter' }, + }, + }); + if (capabilities != null) { + queryClient.setQueryData([QueryKeys.reasoningCapabilities, 'OpenRouter'], { + capabilities, + expiresInMs: 3_600_000, + }); + } + const conversation = { + endpoint: 'OpenRouter', + endpointType: EModelEndpoint.custom, + model, + } as TModelSelectProps['conversation']; + render( + + + jest.fn()) as TSetOption} + models={[model]} + /> + + , + ); + return screen.queryByRole('slider', { name: 'Reasoning Effort' }); + }; + + it('offers only the efforts the selected model supports', () => { + const slider = renderSettings({ OpenRouter: { [model]: { efforts: ['low', 'high'] } } }); + + expect(slider).toHaveAttribute('aria-valuemax', '2'); + }); + + it('hides the effort control while the capabilities are still loading', () => { + expect(renderSettings()).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/Agents/ModelPanel.test.tsx b/client/src/components/SidePanel/Agents/ModelPanel.test.tsx index bb5c7a118d0..c5ea12be0d8 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.test.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.test.tsx @@ -68,6 +68,7 @@ jest.mock('~/components/SidePanel/Parameters/components', () => ({ jest.mock('~/data-provider', () => ({ useGetEndpointsQuery: () => ({ data: {} }), useGetStartupConfig: () => ({ data: mockStartupConfig() }), + useReasoningCapabilitiesQuery: () => ({ data: undefined }), })); jest.mock('~/Providers', () => ({ diff --git a/client/src/components/SidePanel/Agents/ModelPanel.tsx b/client/src/components/SidePanel/Agents/ModelPanel.tsx index dc1b783fb2d..64560d4aaba 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.tsx @@ -14,6 +14,7 @@ import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; import { pruneAgentModelParameters, resolveAgentParameterSettings } from './parameters'; import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import { useModelReasoning } from '~/hooks/Endpoint/useModelReasoning'; import { useLocalize, useHasAccess } from '~/hooks'; import { useLiveAnnouncer } from '~/Providers'; import { Panel } from '~/common'; @@ -64,6 +65,7 @@ export default function ModelPanel({ const { data: endpointsConfig = {} } = useGetEndpointsQuery(); const { data: startupConfig } = useGetStartupConfig(); + const { modelReasoning } = useModelReasoning(endpointsConfig, provider, model ?? ''); const bedrockRegions = useMemo(() => { return endpointsConfig?.[provider]?.availableRegions ?? []; @@ -82,8 +84,9 @@ export default function ModelPanel({ provider, startupConfig, webSearchAllowed, + modelReasoning, }), - [endpointsConfig, model, provider, startupConfig, webSearchAllowed], + [endpointsConfig, model, modelReasoning, provider, startupConfig, webSearchAllowed], ); /** The rendered set omits role-gated controls; `parameterSettings.parameters` * stays complete so the pruning effect below still recognises them. */ diff --git a/client/src/components/SidePanel/Agents/__tests__/parameters.spec.ts b/client/src/components/SidePanel/Agents/__tests__/parameters.spec.ts index 6752fe47f40..ddd3f31da53 100644 --- a/client/src/components/SidePanel/Agents/__tests__/parameters.spec.ts +++ b/client/src/components/SidePanel/Agents/__tests__/parameters.spec.ts @@ -1,5 +1,5 @@ import { agentParamSettings, EModelEndpoint } from 'librechat-data-provider'; -import type { AgentModelParameters } from 'librechat-data-provider'; +import type { AgentModelParameters, TModelReasoning } from 'librechat-data-provider'; import { pruneAgentModelParameters, resolveAgentParameterSettings } from '../parameters'; /** @@ -158,3 +158,33 @@ it('preserves custom Anthropic settings while applying overrides only to visible const stored = { ...defaults, temperature: 0.7, thinking: false, web_search: true }; expect(pruneAgentModelParameters(stored, settings)).toBe(stored); }); + +describe('resolveAgentParameterSettings: OpenRouter reasoning efforts', () => { + const resolve = (modelReasoning?: TModelReasoning | null) => + resolveAgentParameterSettings({ + provider: 'openrouter', + model: 'openai/gpt-6.1-sol', + webSearchAllowed: true, + modelReasoning, + }); + const keys = (list: { key: string }[]) => list.map(({ key }) => key); + + it('hides the effort control while the efforts are unknown to be loading', () => { + expect(keys(resolve(null).visibleParameters)).not.toContain('reasoning_effort'); + }); + + it('keeps the stored effort in the recognized schema while the control is hidden', () => { + const settings = resolve(null); + const stored = { reasoning_effort: 'max' } as unknown as AgentModelParameters; + + expect(keys(settings.parameters)).toContain('reasoning_effort'); + expect(pruneAgentModelParameters(stored, settings)).toBe(stored); + }); + + it('offers the effort control narrowed to what the model reports', () => { + const settings = resolve({ efforts: ['low', 'high'] }); + + const effort = settings.visibleParameters.find(({ key }) => key === 'reasoning_effort'); + expect(effort?.options).toEqual(['', 'low', 'high']); + }); +}); diff --git a/client/src/components/SidePanel/Agents/parameters.ts b/client/src/components/SidePanel/Agents/parameters.ts index a9a59ac39ff..30c9431044b 100644 --- a/client/src/components/SidePanel/Agents/parameters.ts +++ b/client/src/components/SidePanel/Agents/parameters.ts @@ -10,6 +10,7 @@ import { import type { AgentModelParameters, SettingDefinition, + TModelReasoning, TEndpointsConfig, TStartupConfig, } from 'librechat-data-provider'; @@ -40,6 +41,7 @@ export function resolveAgentParameterSettings({ provider, startupConfig, webSearchAllowed, + modelReasoning, }: AgentParameterConfig & { model: string; provider: string; @@ -47,6 +49,8 @@ export function resolveAgentParameterSettings({ * Anthropic and Google column sets, so without this the builder offers a * switch the server will refuse. Narrows `visibleParameters` only. */ webSearchAllowed: boolean; + /** Provider-reported efforts for `model` on an OpenRouter endpoint; `undefined` while unknown. */ + modelReasoning?: TModelReasoning | null; }): ResolvedAgentParameterSettings { const resolvedEndpointsConfig = endpointsConfig ?? {}; const endpointType = getEndpointField(resolvedEndpointsConfig, provider, 'type'); @@ -80,6 +84,7 @@ export function resolveAgentParameterSettings({ overriddenEndpointKey, model, resolvedEndpointsConfig[provider]?.responsesApiRouting, + modelReasoning, ) .map(applyOverride) .filter((param) => param.key !== 'web_search' || webSearchAllowed); diff --git a/client/src/components/SidePanel/Parameters/Panel.tsx b/client/src/components/SidePanel/Parameters/Panel.tsx index 4b3460c59ce..e71eb14576b 100644 --- a/client/src/components/SidePanel/Parameters/Panel.tsx +++ b/client/src/components/SidePanel/Parameters/Panel.tsx @@ -16,6 +16,7 @@ import { import type { TPreset } from 'librechat-data-provider'; import { groupParameters, countModified, hasControl, isWideParameter } from './groups'; import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import { useModelReasoning } from '~/hooks/Endpoint/useModelReasoning'; import { useChatContext, useLiveAnnouncer } from '~/Providers'; import { SaveAsPresetDialog } from '~/components/Endpoints'; import { useSetIndexOptions, useLocalize } from '~/hooks'; @@ -38,6 +39,7 @@ export default function Parameters() { const { data: endpointsConfig = {} } = useGetEndpointsQuery(); const provider = conversation?.endpoint ?? ''; const model = conversation?.model ?? ''; + const { modelReasoning } = useModelReasoning(endpointsConfig, provider, model); const bedrockRegions = useMemo(() => { return endpointsConfig?.[conversation?.endpoint ?? '']?.availableRegions ?? []; @@ -75,9 +77,10 @@ export default function Parameters() { overriddenEndpointKey, model, endpointsConfig?.[provider ?? '']?.responsesApiRouting, + modelReasoning, ).map((param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param); return { parameters, visibleParameters }; - }, [endpointType, endpointsConfig, model, provider, startupConfig]); + }, [endpointType, endpointsConfig, model, modelReasoning, provider, startupConfig]); useEffect(() => { if (!parameters) { diff --git a/client/src/data-provider/Endpoints/queries.ts b/client/src/data-provider/Endpoints/queries.ts index d947c1ead7a..e4164b57260 100644 --- a/client/src/data-provider/Endpoints/queries.ts +++ b/client/src/data-provider/Endpoints/queries.ts @@ -1,6 +1,6 @@ import { useRecoilValue } from 'recoil'; import { useQuery } from '@tanstack/react-query'; -import { QueryKeys, dataService } from 'librechat-data-provider'; +import { Time, QueryKeys, dataService } from 'librechat-data-provider'; import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; import { normalizeStartupConfigModelSpecs } from '~/utils'; @@ -42,6 +42,63 @@ export const useTokenConfigQuery = ( }); }; +/** Floor for the revalidation timer, so an entry that is about to expire cannot cause a request storm. */ +const MIN_REVALIDATE_MS = 1000; + +/** The server's catalog entry is gone, or the request failed, so the data must be read again. */ +const reasoningCapabilitiesExpired = (query: { + state: { status: string; data?: unknown; dataUpdatedAt: number }; +}): 'always' | false => { + const data = query.state.data as t.TReasoningCapabilitiesResponse | undefined; + if (query.state.status === 'error') { + return 'always'; + } + return data != null && Date.now() - query.state.dataUpdatedAt >= data.expiresInMs + ? 'always' + : false; +}; + +/** + * Per-model reasoning efforts of one OpenRouter endpoint. Scoped to the endpoint, so another + * endpoint's outage cannot hide this one's data. Pass `enabled: false` unless the endpoint is an + * OpenRouter one, so other users never pay for the request. + * + * The response says how long the server keeps the catalog it came from, and the client reads it + * again when that time passes, on a timer while the page is open and on focus or reconnect + * otherwise, so it never offers a list the server has already replaced. A failed request retries + * every 30 seconds without waiting for an event, because the editors offer nothing while it fails. + */ +export const useReasoningCapabilitiesQuery = ( + endpoint: string, + config?: UseQueryOptions, +): QueryObserverResult => { + const queriesEnabled = useRecoilValue(store.queriesEnabled); + return useQuery( + [QueryKeys.reasoningCapabilities, endpoint], + () => dataService.getReasoningCapabilities(endpoint), + { + staleTime: Infinity, + refetchOnWindowFocus: reasoningCapabilitiesExpired, + refetchOnReconnect: reasoningCapabilitiesExpired, + refetchOnMount: reasoningCapabilitiesExpired, + /** The interval restarts whenever a consumer mounts, so it counts down what is left of the + * entry's lifetime, not the lifetime it had when it was read. */ + refetchInterval: (data, query) => { + if (query.state.status === 'error') { + return Time.THIRTY_SECONDS; + } + if (data == null) { + return false; + } + const remaining = data.expiresInMs - (Date.now() - query.state.dataUpdatedAt); + return Math.max(remaining, MIN_REVALIDATE_MS); + }, + ...config, + enabled: (config?.enabled ?? true) === true && queriesEnabled, + }, + ); +}; + /** * Auth-aware query key so unauthenticated (login page) and authenticated * (chat page) configs are cached independently, preventing stale diff --git a/client/src/data-provider/__tests__/reasoningCapabilities.test.tsx b/client/src/data-provider/__tests__/reasoningCapabilities.test.tsx new file mode 100644 index 00000000000..9a07f6592d3 --- /dev/null +++ b/client/src/data-provider/__tests__/reasoningCapabilities.test.tsx @@ -0,0 +1,181 @@ +import { RecoilRoot } from 'recoil'; +import { dataService } from 'librechat-data-provider'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, + focusManager, + onlineManager, +} from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useReasoningCapabilitiesQuery } from '../Endpoints/queries'; +import store from '~/store'; + +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + dataService: { ...actual.dataService, getReasoningCapabilities: jest.fn() }, + }; +}); + +const mockGet = dataService.getReasoningCapabilities as jest.Mock; +const capabilities = { + capabilities: { OpenRouter: { 'a/b': { efforts: ['low'] } } }, + expiresInMs: 60_000, +}; + +const setup = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + set(store.queriesEnabled, true)}> + {children} + + ); + return { ...renderHook(() => useReasoningCapabilitiesQuery('OpenRouter'), { wrapper }), wrapper }; +}; + +const refocus = async () => { + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); +}; + +const reconnect = async () => { + await act(async () => { + onlineManager.setOnline(false); + onlineManager.setOnline(true); + }); +}; + +describe('useReasoningCapabilitiesQuery expiry and retry', () => { + beforeEach(() => { + mockGet.mockReset(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + focusManager.setFocused(undefined); + }); + + const advance = (ms: number) => + act(async () => { + jest.advanceTimersByTime(ms); + }); + + it('refetches when the server entry expires', async () => { + mockGet.mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + + await advance(61_000); + + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)); + }); + + it('does not refetch before the server entry expires', async () => { + mockGet.mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + + await advance(30_000); + await refocus(); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('revalidates on focus when the entry expired while the page was in the background', async () => { + mockGet.mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + await act(async () => { + focusManager.setFocused(false); + }); + + await advance(120_000); + expect(mockGet).toHaveBeenCalledTimes(1); + await act(async () => { + focusManager.setFocused(true); + }); + + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)); + }); + + it('schedules revalidation from the remaining lifetime when a consumer remounts', async () => { + mockGet.mockResolvedValue({ ...capabilities, expiresInMs: 3_600_000 }); + const first = setup(); + await waitFor(() => expect(first.result.current.data).toBeDefined()); + + /* An unobserved query is dropped after five minutes, so the remount has to come sooner. */ + await advance(1_800_000); + first.unmount(); + await advance(120_000); + const second = renderHook(() => useReasoningCapabilitiesQuery('OpenRouter'), { + wrapper: first.wrapper, + }); + await waitFor(() => expect(second.result.current.data).toBeDefined()); + expect(mockGet).toHaveBeenCalledTimes(1); + + /* 32 minutes into a 60 minute entry: it must refresh in about 28 minutes, not 60. */ + await advance(29 * 60_000); + + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)); + }); + + it('follows the expiry of the entry it just read, not a fixed window', async () => { + mockGet + .mockResolvedValueOnce({ ...capabilities, expiresInMs: 10_000 }) + .mockResolvedValue({ ...capabilities, expiresInMs: 3_600_000 }); + const { result } = setup(); + await waitFor(() => expect(result.current.data?.expiresInMs).toBe(10_000)); + + await advance(11_000); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)); + await advance(600_000); + + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('retries a failed request on its own while the page stays focused', async () => { + mockGet.mockRejectedValueOnce(new Error('503')).mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.isError).toBe(true)); + + await advance(30_000); + + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('refetches a failed request on focus', async () => { + mockGet.mockRejectedValueOnce(new Error('503')).mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.isError).toBe(true)); + + await refocus(); + + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + }); + + it('refetches a failed request on reconnect', async () => { + mockGet.mockRejectedValueOnce(new Error('offline')).mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.isError).toBe(true)); + + await reconnect(); + + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + }); + + it('does not refetch loaded data on reconnect before the server entry expires', async () => { + mockGet.mockResolvedValue(capabilities); + const { result } = setup(); + await waitFor(() => expect(result.current.data).toEqual(capabilities)); + + await reconnect(); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx index 3ee19114135..2178cab33ad 100644 --- a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx @@ -34,7 +34,20 @@ const mockGetSender = jest.fn(() => 'Assistant'); const mockGetExpiry = jest.fn(() => 'expiry-key'); const mockAgentQueryData: { current?: Agent } = {}; const mockEndpointsQueryData: { current?: Record } = {}; +const mockCapabilitiesData: { current?: Record } = {}; +const mockCapabilitiesState: { + current?: { status: string; fetchStatus?: string; dataUpdatedAt?: number }; +} = {}; +/** A query that holds data always has a timestamp: loaded data is fresh unless a test says otherwise. */ +const mockGetQueryState = jest.fn((queryKey: readonly unknown[]) => + queryKey[0] === QueryKeys.reasoningCapabilities + ? { dataUpdatedAt: Date.now(), ...mockCapabilitiesState.current } + : undefined, +); const mockGetQueryData = jest.fn((queryKey: readonly unknown[]) => { + if (queryKey[0] === QueryKeys.reasoningCapabilities) { + return mockCapabilitiesData.current; + } if (queryKey[0] === QueryKeys.agent) { return mockAgentQueryData.current; } @@ -63,7 +76,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ getQueryData: mockGetQueryData, - getQueryState: jest.fn(() => undefined), + getQueryState: (queryKey: readonly unknown[]) => mockGetQueryState(queryKey), }), })); @@ -210,6 +223,8 @@ function renderAsk( describe('useChatFunctions ask', () => { beforeEach(() => { mockEndpointsQueryData.current = undefined; + mockCapabilitiesData.current = undefined; + mockCapabilitiesState.current = undefined; localStorage.clear(); jest.clearAllMocks(); mockAgentQueryData.current = undefined; @@ -677,6 +692,216 @@ describe('useChatFunctions ask', () => { expect(submission.userMessage.reasoningOverride).toEqual(override); }); + describe('replaying a reasoning override on an OpenRouter endpoint', () => { + const model = 'openai/gpt-6.1-sol'; + const override = { key: 'reasoning_effort', value: 'max' } as TReasoningOverride; + const openRouter = (paramDefinitions?: unknown[]) => ({ + OpenRouter: { + type: 'custom', + customParams: { + defaultParamsEndpoint: 'openrouter', + reasoningFormat: 'reasoning_effort', + ...(paramDefinitions && { paramDefinitions }), + }, + }, + }); + const replay = (value: TReasoningOverride = override) => { + const { result, setSubmission } = renderAsk([], 'openrouter-conversation', { + endpoint: 'OpenRouter' as TConversation['endpoint'], + model, + }); + act(() => { + result.current.ask({ text: 'Think' }, { overrideReasoning: value }); + }); + return (setSubmission.mock.calls.at(-1)?.[0] as TSubmission).userMessage.reasoningOverride; + }; + + beforeEach(() => { + mockEndpointsQueryData.current = openRouter(); + }); + + it('omits the override while the capabilities have not loaded', () => { + mockCapabilitiesState.current = { status: 'loading' }; + + expect(replay()).toBeUndefined(); + }); + + it('omits the override when no capabilities request has run', () => { + expect(replay()).toBeUndefined(); + }); + + it('keeps an override the loaded capabilities support', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['max', 'high'] } } }, + expiresInMs: 60000, + }; + + expect(replay()).toEqual(override); + }); + + it('omits an override the loaded capabilities do not support', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['low'] } } }, + expiresInMs: 60000, + }; + + expect(replay()).toBeUndefined(); + }); + + it('omits the override when the capabilities request failed, as its efforts are unknown', () => { + mockCapabilitiesState.current = { status: 'error' }; + + expect(replay()).toBeUndefined(); + }); + + it('omits the override when a refresh failed although the previous data is still cached', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['max', 'high'] } } }, + expiresInMs: 60000, + }; + mockCapabilitiesState.current = { status: 'error' }; + + expect(replay()).toBeUndefined(); + }); + + it('omits the override while an expired entry is refreshed, although its data is cached', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['max', 'high'] } } }, + expiresInMs: 60000, + }; + mockCapabilitiesState.current = { status: 'success', fetchStatus: 'fetching' } as never; + + expect(replay()).toBeUndefined(); + }); + + it('omits the override when the cached entry is past its lifetime and idle', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['max', 'high'] } } }, + expiresInMs: 60000, + }; + mockCapabilitiesState.current = { + status: 'success', + fetchStatus: 'idle', + dataUpdatedAt: Date.now() - 61_000, + }; + + expect(replay()).toBeUndefined(); + }); + + it('keeps the override while the cached entry is still within its lifetime', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['max', 'high'] } } }, + expiresInMs: 60000, + }; + mockCapabilitiesState.current = { + status: 'success', + fetchStatus: 'idle', + dataUpdatedAt: Date.now() - 30_000, + }; + + expect(replay()).toEqual(override); + }); + + describe('a staged effort submitted while the capabilities are unknown', () => { + const submitStaged = () => { + const { result, setSubmission, reasoningStore } = renderAsk([], 'openrouter-conversation', { + endpoint: 'OpenRouter' as TConversation['endpoint'], + model, + reasoningOverride: override, + }); + act(() => { + result.current.ask({ text: 'Think' }); + }); + const sent = (setSubmission.mock.calls.at(-1)?.[0] as TSubmission).userMessage + .reasoningOverride; + return { + sent, + left: reasoningStore.get(pendingReasoningOverrideFamily('openrouter-conversation')), + }; + }; + + it('leaves it staged for the next message instead of losing it', () => { + mockCapabilitiesState.current = { status: 'error' }; + + const { sent, left } = submitStaged(); + + expect(sent).toBeUndefined(); + expect(left).toEqual(override); + }); + + it('leaves it staged while the capabilities are loading', () => { + mockCapabilitiesState.current = { status: 'loading' }; + + expect(submitStaged().left).toEqual(override); + }); + + it('consumes it once it is sent', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['max', 'high'] } } }, + expiresInMs: 60000, + }; + + const { sent, left } = submitStaged(); + + expect(sent).toEqual(override); + expect(left).toBeUndefined(); + }); + + it('consumes it when the loaded catalog refuses it', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: ['low'] } } }, + expiresInMs: 60000, + }; + + const { sent, left } = submitStaged(); + + expect(sent).toBeUndefined(); + expect(left).toBeUndefined(); + }); + }); + + describe('an Auto override, which sends no effort', () => { + const auto = { key: 'reasoning_effort', value: '' } as TReasoningOverride; + + it('is kept while the capabilities have not loaded', () => { + mockCapabilitiesState.current = { status: 'loading' }; + + expect(replay(auto)).toEqual(auto); + }); + + it('is kept when the capabilities request failed', () => { + mockCapabilitiesState.current = { status: 'error' }; + + expect(replay(auto)).toEqual(auto); + }); + + it('is kept for a model the loaded catalog lists without effort selection', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { [model]: { efforts: [] } } }, + expiresInMs: 60000, + }; + + expect(replay(auto)).toEqual(auto); + }); + }); + + it('keeps an override for a model the loaded catalog does not list', () => { + mockCapabilitiesData.current = { + capabilities: { OpenRouter: { 'meta/other': { efforts: ['low'] } } }, + expiresInMs: 60000, + }; + + expect(replay()).toEqual(override); + }); + + it('keeps an administrator-defined effort override without waiting for the catalog', () => { + mockEndpointsQueryData.current = openRouter([{ key: 'reasoning_effort' }]); + mockCapabilitiesState.current = { status: 'loading' }; + + expect(replay()).toEqual(override); + }); + }); + it('uses hydrated per-agent query data when the agent catalog is unavailable', () => { const agentId = 'agent-uncatalogued'; const override = { key: 'reasoning_effort', value: 'high' } as TReasoningOverride; diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 57d6c7891e0..368bca7cc8c 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -9,8 +9,10 @@ import { Constants, QueryKeys, ContentTypes, + ReasoningEffort, EModelEndpoint, getEndpointField, + resolveModelReasoning, isAgentsEndpoint, parseCompactConvo, replaceSpecialVars, @@ -28,6 +30,7 @@ import type { TEndpointOption, TEndpointsConfig, EndpointSchemaKey, + TReasoningCapabilitiesResponse, } from 'librechat-data-provider'; import type { SetterOrUpdater } from 'recoil'; import type { TAskFunction, ExtendedFile } from '~/common'; @@ -51,6 +54,7 @@ import { resolveSubmittedCodeApprovalMode, } from '~/hooks/Agents/codeDecision'; import useFocusRegeneratedResponse from '~/hooks/Chat/useFocusRegeneratedResponse'; +import { usesReasoningCapabilities } from '~/hooks/Endpoint/useModelReasoning'; import useGetConversation from '~/hooks/Conversations/useGetConversation'; import useCodeApprovalMode from '~/hooks/Agents/useCodeApprovalMode'; import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; @@ -516,9 +520,13 @@ export default function useChatFunctions({ } } let reasoningOverride = overrideReasoning ?? undefined; - if (overrideReasoning === undefined && !regenerateShaped && !isContinued && !isEdited) { - reasoningOverride = drainPendingReasoning(getReasoningStateKey(conversationId, index)); + const reasoningStateKey = getReasoningStateKey(conversationId, index); + const drainedReasoning = + overrideReasoning === undefined && !regenerateShaped && !isContinued && !isEdited; + if (drainedReasoning) { + reasoningOverride = drainPendingReasoning(reasoningStateKey); } + const stagedReasoning = reasoningOverride; if (reasoningOverride != null) { const isAgent = isAgentsEndpoint(endpoint); const agentId = isAgent ? conversation?.agent_id : undefined; @@ -532,6 +540,30 @@ export default function useChatFunctions({ const effectiveEndpointType = getEndpointField(endpointsConfig, effectiveEndpoint, 'type'); const customParams = effectiveEndpoint == null ? undefined : endpointsConfig?.[effectiveEndpoint]?.customParams; + const capabilitiesKey = [QueryKeys.reasoningCapabilities, effectiveEndpoint]; + /* A failed or running refresh leaves the previous data cached, and so does an entry that is + past its lifetime while its refetch has not started (a throttled background tab): the + efforts are unknown in all three, as they are in the editors. */ + const capabilitiesState = queryClient.getQueryState(capabilitiesKey); + const cachedCapabilities = + queryClient.getQueryData(capabilitiesKey); + const capabilitiesExpired = + cachedCapabilities != null && + Date.now() - (capabilitiesState?.dataUpdatedAt ?? 0) >= cachedCapabilities.expiresInMs; + const capabilitiesData = + capabilitiesState?.status === 'error' || + capabilitiesState?.fetchStatus === 'fetching' || + capabilitiesExpired + ? undefined + : cachedCapabilities?.capabilities; + /* Auto sends no effort, so no catalog can refuse it and a restored one is always kept. */ + const clearsEffort = + reasoningOverride.key === 'reasoning_effort' && + reasoningOverride.value === ReasoningEffort.unset && + usesReasoningCapabilities(endpointsConfig, effectiveEndpoint ?? ''); + /* A replayed override is checked against the loaded per-model efforts. While they are + unknown (never requested, running or failed) `resolveModelReasoning` offers nothing, so + the override is omitted rather than sent and refused by the server. */ const supportedSetting = effectiveEndpoint == null ? undefined @@ -542,9 +574,28 @@ export default function useChatFunctions({ defaultParamsEndpoint: customParams?.defaultParamsEndpoint, reasoningFormat: customParams?.reasoningFormat, paramDefinitions: customParams?.paramDefinitions, + modelReasoning: + effectiveModel == null || clearsEffort + ? undefined + : resolveModelReasoning({ + capabilities: capabilitiesData, + endpoint: effectiveEndpoint, + model: effectiveModel, + paramDefinitions: customParams?.paramDefinitions, + }), }); if (!isReasoningOverrideSupported(reasoningOverride, supportedSetting)) { reasoningOverride = undefined; + /* Refused only because the catalog is not known yet, not because it said no: the user's + one-shot choice is put back instead of being lost, and applies once it can be checked. */ + if ( + drainedReasoning && + stagedReasoning != null && + capabilitiesData == null && + usesReasoningCapabilities(endpointsConfig, effectiveEndpoint ?? '') + ) { + reasoningStore.set(pendingReasoningOverrideFamily(reasoningStateKey), stagedReasoning); + } } } const isEditOrContinue = isEdited || isContinued; diff --git a/client/src/hooks/Endpoint/__tests__/useModelReasoning.spec.tsx b/client/src/hooks/Endpoint/__tests__/useModelReasoning.spec.tsx new file mode 100644 index 00000000000..817866c6c5c --- /dev/null +++ b/client/src/hooks/Endpoint/__tests__/useModelReasoning.spec.tsx @@ -0,0 +1,228 @@ +import { renderHook } from '@testing-library/react'; +import type { TEndpointsConfig, TReasoningCapabilityMap } from 'librechat-data-provider'; +import { useModelReasoning } from '../useModelReasoning'; + +/** `undefined` is a query that is loading or failed: both leave the hook without data. */ +let mockCapabilities: TReasoningCapabilityMap | undefined; +let mockErrored = false; +let mockFetching = false; +const mockQuery = jest.fn(); +jest.mock('~/data-provider', () => ({ + useReasoningCapabilitiesQuery: (endpoint: string, config: { enabled?: boolean }) => { + mockQuery(endpoint, config); + const enabled = config?.enabled !== false; + return { + data: + enabled && mockCapabilities != null + ? { capabilities: mockCapabilities, expiresInMs: 3_600_000 } + : undefined, + isError: enabled && mockErrored, + isFetching: enabled && mockFetching, + }; + }, +})); + +const model = 'openai/gpt-6.1-sol'; +const openRouter = { + OpenRouter: { + order: 0, + type: 'custom', + customParams: { defaultParamsEndpoint: 'openrouter' }, + }, +} as TEndpointsConfig; +const withDefinition = { + OpenRouter: { + order: 0, + type: 'custom', + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'reasoning_effort', options: ['low', 'max'] }], + }, + }, +} as TEndpointsConfig; + +beforeEach(() => { + mockCapabilities = undefined; + mockErrored = false; + mockFetching = false; + mockQuery.mockClear(); +}); + +describe('useModelReasoning', () => { + it('hides the efforts until the capabilities have loaded', () => { + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeNull(); + }); + + it('hides the efforts when the request failed, as the capabilities are then unknown', () => { + mockCapabilities = undefined; + + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeNull(); + }); + + it('reports pending while the capabilities are unknown, whether loading or failed', () => { + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.pending).toBe(true); + }); + + it('hides the efforts when a refresh failed, although the previous data is still cached', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + mockErrored = true; + + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeNull(); + expect(result.current.pending).toBe(true); + }); + + it('hides the efforts while an expired entry is being refreshed in the background', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + mockFetching = true; + + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeNull(); + expect(result.current.pending).toBe(true); + }); + + it('shows the refreshed efforts once the background request finishes', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + mockFetching = true; + const { result, rerender } = renderHook(() => + useModelReasoning(openRouter, 'OpenRouter', model), + ); + + mockFetching = false; + rerender(); + + expect(result.current.modelReasoning).toEqual({ efforts: ['low', 'high'] }); + }); + + it('shows the efforts again once the refresh succeeds', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + mockErrored = true; + const { result, rerender } = renderHook(() => + useModelReasoning(openRouter, 'OpenRouter', model), + ); + expect(result.current.pending).toBe(true); + + mockErrored = false; + rerender(); + + expect(result.current.modelReasoning).toEqual({ efforts: ['low', 'high'] }); + expect(result.current.pending).toBe(false); + }); + + it('returns the efforts of the selected model once loaded', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low', 'high'] } } }; + + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toEqual({ efforts: ['low', 'high'] }); + expect(result.current.pending).toBe(false); + }); + + it('reports no reasoning for a model the loaded catalog lists without it', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: [] } } }; + + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeNull(); + }); + + it('keeps the generic efforts for a model the loaded catalog does not list', () => { + mockCapabilities = { OpenRouter: { 'meta/other': { efforts: ['low'] } } }; + + const { result } = renderHook(() => + useModelReasoning(openRouter, 'OpenRouter', '~openai/gpt-latest'), + ); + + expect(result.current.modelReasoning).toBeUndefined(); + expect(result.current.pending).toBe(false); + }); + + it('keeps the generic efforts for an endpoint the server did not resolve', () => { + mockCapabilities = {}; + + const { result } = renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeUndefined(); + }); + + it('ignores the provider efforts when the administrator defined reasoning_effort', () => { + mockCapabilities = { OpenRouter: { [model]: { efforts: ['low'] } } }; + + const { result } = renderHook(() => useModelReasoning(withDefinition, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeUndefined(); + }); + + it('keeps an administrator-defined reasoning_effort available while capabilities are unknown', () => { + const { result } = renderHook(() => useModelReasoning(withDefinition, 'OpenRouter', model)); + + expect(result.current.modelReasoning).toBeUndefined(); + expect(result.current.pending).toBe(false); + }); + + it('does not request capabilities when reasoning is disabled for the endpoint', () => { + const endpoints = { + OpenRouter: { + order: 0, + type: 'custom', + customParams: { defaultParamsEndpoint: 'openrouter', reasoningFormat: 'disabled' }, + }, + } as TEndpointsConfig; + + const { result } = renderHook(() => useModelReasoning(endpoints, 'OpenRouter', model)); + + expect(mockQuery).toHaveBeenCalledWith( + 'OpenRouter', + expect.objectContaining({ enabled: false }), + ); + expect(result.current.pending).toBe(false); + }); + + it('does not request capabilities when the administrator defined reasoning_effort', () => { + renderHook(() => useModelReasoning(withDefinition, 'OpenRouter', model)); + + expect(mockQuery).toHaveBeenCalledWith( + 'OpenRouter', + expect.objectContaining({ enabled: false }), + ); + }); + + it('is never hidden or pending for an endpoint that does not use capabilities', () => { + const endpoints = { openAI: { order: 0, type: 'openAI' } } as TEndpointsConfig; + + const { result } = renderHook(() => useModelReasoning(endpoints, 'openAI', model)); + + expect(result.current.modelReasoning).toBeUndefined(); + expect(result.current.pending).toBe(false); + }); + + it('does not request capabilities for an endpoint that is not OpenRouter', () => { + const endpoints = { + Local: { order: 0, type: 'custom', customParams: { defaultParamsEndpoint: 'custom' } }, + } as TEndpointsConfig; + + renderHook(() => useModelReasoning(endpoints, 'Local', model)); + + expect(mockQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ enabled: false }), + ); + }); + + it('requests capabilities for an OpenRouter endpoint', () => { + renderHook(() => useModelReasoning(openRouter, 'OpenRouter', model)); + + expect(mockQuery).toHaveBeenCalledWith( + 'OpenRouter', + expect.objectContaining({ enabled: true }), + ); + }); +}); diff --git a/client/src/hooks/Endpoint/index.ts b/client/src/hooks/Endpoint/index.ts index 851ff4d3fa5..009b072ce2a 100644 --- a/client/src/hooks/Endpoint/index.ts +++ b/client/src/hooks/Endpoint/index.ts @@ -2,3 +2,4 @@ export { default as useKeyDialog } from './useKeyDialog'; export { default as useEndpoints } from './useEndpoints'; export { default as useSelectorEffects } from './useSelectorEffects'; export { useProviderIcon, resolveProviderIcon } from './useProviderIcon'; +export { useModelReasoning, usesReasoningCapabilities } from './useModelReasoning'; diff --git a/client/src/hooks/Endpoint/useModelReasoning.ts b/client/src/hooks/Endpoint/useModelReasoning.ts new file mode 100644 index 00000000000..1bc06b656a1 --- /dev/null +++ b/client/src/hooks/Endpoint/useModelReasoning.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { + Providers, + ReasoningParameterFormat, + resolveModelReasoning, + hasExplicitReasoningEffort, +} from 'librechat-data-provider'; +import type { TEndpointsConfig, TModelReasoning } from 'librechat-data-provider'; +import { useReasoningCapabilitiesQuery } from '~/data-provider'; + +/** + * Whether the endpoint's efforts come from a per-model catalog: an OpenRouter parameter set + * whose administrator did not define `reasoning_effort` themselves. Anything else needs no + * request and no waiting. + */ +export function usesReasoningCapabilities( + endpointsConfig: TEndpointsConfig | undefined, + endpoint: string, +): boolean { + const customParams = endpointsConfig?.[endpoint]?.customParams; + return ( + customParams?.defaultParamsEndpoint === Providers.OPENROUTER && + customParams.reasoningFormat !== ReasoningParameterFormat.disabled && + !hasExplicitReasoningEffort(customParams.paramDefinitions) + ); +} + +/** + * The reasoning efforts the selected OpenRouter model accepts, for `applyModelAwareDefaults` + * and `resolveReasoningSettingForTarget` (see `resolveModelReasoning`). `pending` is true while + * the capabilities are unknown, because the request is in flight or failed: the efforts are hidden + * then, so a choice cannot be made from the generic list, but a value already stored or staged + * must be kept, not cleared, until the catalog can confirm or refute it. + */ +export function useModelReasoning( + endpointsConfig: TEndpointsConfig | undefined, + endpoint: string, + model: string, +): { modelReasoning: TModelReasoning | null | undefined; pending: boolean } { + const enabled = usesReasoningCapabilities(endpointsConfig, endpoint); + const { data, isError, isFetching } = useReasoningCapabilitiesQuery(endpoint, { enabled }); + /** React Query keeps the previous data while a refresh runs or fails, so the query state, not the + * data, says whether the efforts are known. The query only refetches once the server's entry + * has expired or a request failed, so the retained data is out of date for as long as it runs. */ + const capabilities = isError || isFetching ? undefined : data?.capabilities; + const paramDefinitions = endpointsConfig?.[endpoint]?.customParams?.paramDefinitions; + const pending = enabled && capabilities == null; + const modelReasoning = useMemo( + () => + enabled && model + ? resolveModelReasoning({ capabilities, endpoint, model, paramDefinitions }) + : undefined, + [capabilities, enabled, endpoint, model, paramDefinitions], + ); + return { modelReasoning, pending }; +} diff --git a/packages/api/src/endpoints/capabilities.spec.ts b/packages/api/src/endpoints/capabilities.spec.ts new file mode 100644 index 00000000000..dd479103ee4 --- /dev/null +++ b/packages/api/src/endpoints/capabilities.spec.ts @@ -0,0 +1,125 @@ +import type { Response } from 'express'; +import type { ServerRequest } from '~/types'; +import { createReasoningCapabilitiesHandler } from './capabilities'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const makeRes = () => { + const res = {} as Response & { status: jest.Mock; json: jest.Mock }; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +const makeReq = (query: Record = { endpoint: 'OpenRouter' }) => + ({ config: { endpoints: {} }, query }) as unknown as ServerRequest; + +describe('createReasoningCapabilitiesHandler', () => { + it('returns the capabilities when the catalog was read', async () => { + const capabilities = { OpenRouter: { 'a/b': { efforts: ['low'] } } }; + const handler = createReasoningCapabilitiesHandler({ + getReasoningCapabilities: async () => ({ capabilities, unavailable: [] }), + }); + const res = makeRes(); + + await handler(makeReq(), res); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ capabilities, expiresInMs: expect.any(Number) }); + }); + + describe('expiry the client revalidates against', () => { + const NOW = 1_000_000; + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + const respond = async (expiresAt: number | undefined) => { + const res = makeRes(); + await createReasoningCapabilitiesHandler({ + getReasoningCapabilities: async () => ({ capabilities: {}, unavailable: [], expiresAt }), + })(makeReq(), res); + return res.json.mock.calls[0][0].expiresInMs; + }; + + it('sends the time left in the server entry', async () => { + expect(await respond(NOW + 42000)).toBe(42000); + }); + + it('never sends a negative time', async () => { + expect(await respond(NOW - 5)).toBe(0); + }); + + it('sends five minutes when no entry applies, so the client still revalidates', async () => { + expect(await respond(undefined)).toBe(300000); + }); + }); + + it('scopes the lookup to the requested endpoint and the request config', async () => { + const getReasoningCapabilities = jest.fn(async () => ({ capabilities: {}, unavailable: [] })); + const req = makeReq({ endpoint: 'My Gateway' }); + + await createReasoningCapabilitiesHandler({ getReasoningCapabilities })(req, makeRes()); + + expect(getReasoningCapabilities).toHaveBeenCalledWith(req.config, 'My Gateway'); + }); + + it.each([{}, { endpoint: '' }, { endpoint: ['a', 'b'] }, { endpoint: 42 }])( + 'answers 400 when the endpoint query is %j', + async (query) => { + const getReasoningCapabilities = jest.fn(); + const res = makeRes(); + + await createReasoningCapabilitiesHandler({ getReasoningCapabilities })(makeReq(query), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'endpoint_required' }); + expect(getReasoningCapabilities).not.toHaveBeenCalled(); + }, + ); + + it('answers 503 with a stable code when the catalog is unavailable', async () => { + const handler = createReasoningCapabilitiesHandler({ + getReasoningCapabilities: async () => ({ capabilities: {}, unavailable: ['OpenRouter'] }), + }); + const res = makeRes(); + + await handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ error: 'reasoning_catalog_unavailable' }); + }); + + it('does not expose the unavailable endpoint names', async () => { + const handler = createReasoningCapabilitiesHandler({ + getReasoningCapabilities: async () => ({ + capabilities: {}, + unavailable: ['Internal Gateway'], + }), + }); + const res = makeRes(); + + await handler(makeReq(), res); + + expect(JSON.stringify(res.json.mock.calls)).not.toContain('Internal Gateway'); + }); + + it('answers 500 without the error text when resolution throws', async () => { + const handler = createReasoningCapabilitiesHandler({ + getReasoningCapabilities: async () => { + throw new Error('secret detail'); + }, + }); + const res = makeRes(); + + await handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(JSON.stringify(res.json.mock.calls)).not.toContain('secret detail'); + }); +}); diff --git a/packages/api/src/endpoints/capabilities.ts b/packages/api/src/endpoints/capabilities.ts new file mode 100644 index 00000000000..9c0605624f0 --- /dev/null +++ b/packages/api/src/endpoints/capabilities.ts @@ -0,0 +1,48 @@ +import { logger } from '@librechat/data-schemas'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { ReasoningCapabilityResult, ServerRequest } from '~/types'; + +/** Sent when no catalog applies to the endpoint, so the client still revalidates. */ +const FALLBACK_REVALIDATE_MS = 300000; + +export interface ReasoningCapabilitiesHandlerDeps { + getReasoningCapabilities: ( + appConfig?: AppConfig, + endpoint?: string, + ) => Promise; +} + +/** + * `GET /api/endpoints/reasoning-capabilities?endpoint=`: the efforts each model of one + * OpenRouter endpoint accepts. Scoped to the endpoint so another endpoint's outage never hides + * its data. A catalog that could not be read answers 503 with a stable code, never a 200 with the + * endpoint missing, so the client retries instead of caching an outage as data. Only the + * code is returned: not the endpoint names, URLs or error text. The response carries `expiresInMs`, + * how long the server will keep the catalog, so the client revalidates when the server does. + */ +export function createReasoningCapabilitiesHandler(deps: ReasoningCapabilitiesHandlerDeps) { + return async (req: ServerRequest, res: Response): Promise => { + const endpoint = req.query?.endpoint; + if (typeof endpoint !== 'string' || endpoint === '') { + return res.status(400).json({ error: 'endpoint_required' }); + } + try { + const { capabilities, unavailable, expiresAt } = await deps.getReasoningCapabilities( + req.config, + endpoint, + ); + if (unavailable.length > 0) { + return res.status(503).json({ error: 'reasoning_catalog_unavailable' }); + } + const expiresInMs = + expiresAt == null ? FALLBACK_REVALIDATE_MS : Math.max(0, expiresAt - Date.now()); + return res.json({ capabilities, expiresInMs }); + } catch (error) { + logger.error('[reasoningCapabilitiesHandler] Failed to resolve reasoning capabilities', { + error: error instanceof Error ? error.name : 'unknown', + }); + return res.status(500).json({ error: 'reasoning_capabilities_failed' }); + } + }; +} diff --git a/packages/api/src/endpoints/config/providers.reasoning.spec.ts b/packages/api/src/endpoints/config/providers.reasoning.spec.ts new file mode 100644 index 00000000000..e6d4ad9d0b3 --- /dev/null +++ b/packages/api/src/endpoints/config/providers.reasoning.spec.ts @@ -0,0 +1,55 @@ +import { Providers } from '@librechat/agents'; +import type { ProviderInitializeParams } from '~/types'; + +const mockInitializeCustom = jest.fn(async (_params: ProviderInitializeParams) => ({ + llmConfig: {}, +})); +jest.mock('../custom/initialize', () => ({ + initializeCustom: (params: ProviderInitializeParams) => mockInitializeCustom(params), +})); + +import { providerConfigMap, getProviderConfig } from './providers'; + +const params = { endpoint: 'OpenRouter', db: {} } as unknown as ProviderInitializeParams; + +describe('custom endpoint initializers are wired with the reasoning dependencies', () => { + beforeEach(() => { + mockInitializeCustom.mockClear(); + }); + + it.each([Providers.OPENROUTER, Providers.XAI, Providers.DEEPSEEK, Providers.MOONSHOT])( + 'supplies the production dependencies to the %s initializer', + async (provider) => { + await providerConfigMap[provider](params); + + const passed = mockInitializeCustom.mock.calls[0][0]; + expect(passed.reasoningCapabilityDeps).toEqual( + expect.objectContaining({ fetchPage: expect.any(Function), cache: expect.anything() }), + ); + }, + ); + + it('supplies them to a user-defined custom endpoint', async () => { + const { getOptions } = getProviderConfig({ + provider: 'MyGateway', + appConfig: { + endpoints: { custom: [{ name: 'MyGateway', baseURL: 'https://x/v1', apiKey: 'k' }] }, + } as never, + }); + + await getOptions(params); + + expect(mockInitializeCustom.mock.calls[0][0].reasoningCapabilityDeps).toBeDefined(); + }); + + it('keeps dependencies a caller supplies', async () => { + const supplied = { fetchPage: jest.fn(), cache: { get: jest.fn(), set: jest.fn() } }; + + await providerConfigMap[Providers.OPENROUTER]({ + ...params, + reasoningCapabilityDeps: supplied, + } as ProviderInitializeParams); + + expect(mockInitializeCustom.mock.calls[0][0].reasoningCapabilityDeps).toBe(supplied); + }); +}); diff --git a/packages/api/src/endpoints/config/providers.ts b/packages/api/src/endpoints/config/providers.ts index 642007c6684..9ab6239f002 100644 --- a/packages/api/src/endpoints/config/providers.ts +++ b/packages/api/src/endpoints/config/providers.ts @@ -6,6 +6,7 @@ import type { InitializeResultBase, ProviderInitializeParams } from '~/types'; import { resolveCustomEndpointSecrets } from '~/admin/secrets'; import { initializeAnthropic } from '../anthropic/initialize'; import { initializeBedrock } from '../bedrock/initialize'; +import { getReasoningCapabilityDeps } from '../reasoning'; import { initializeCustom } from '../custom/initialize'; import { initializeGoogle } from '../google/initialize'; import { initializeOpenAI } from '../openai/initialize'; @@ -27,6 +28,16 @@ export function isKnownCustomProvider(provider?: string): boolean { ); } +/** + * Production wiring for custom endpoints: the initializer takes its reasoning catalog client + * and cache from the caller, and this registry is where the real ones are supplied. + */ +const initializeCustomWithReasoning: InitializeFn = (params) => + initializeCustom({ + ...params, + reasoningCapabilityDeps: params.reasoningCapabilityDeps ?? getReasoningCapabilityDeps(), + }); + /** * Provider configuration map mapping providers to their initialization functions. * @@ -38,10 +49,10 @@ export function isKnownCustomProvider(provider?: string): boolean { * summarization falls back to the raw provider, dropping client overrides. */ export const providerConfigMap: Record = { - [Providers.XAI]: initializeCustom, - [Providers.DEEPSEEK]: initializeCustom, - [Providers.MOONSHOT]: initializeCustom, - [Providers.OPENROUTER]: initializeCustom, + [Providers.XAI]: initializeCustomWithReasoning, + [Providers.DEEPSEEK]: initializeCustomWithReasoning, + [Providers.MOONSHOT]: initializeCustomWithReasoning, + [Providers.OPENROUTER]: initializeCustomWithReasoning, [Providers.VERTEXAI]: initializeGoogle, [EModelEndpoint.openAI]: initializeOpenAI, [EModelEndpoint.google]: initializeGoogle, @@ -153,7 +164,7 @@ export function getProviderConfig({ if (!customEndpointConfig) { throw new Error(`Provider ${provider} not supported`); } - getOptions = initializeCustom; + getOptions = initializeCustomWithReasoning; overrideProvider = Providers.OPENAI; } diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts index 8fad8b31ed2..3759b86eae1 100644 --- a/packages/api/src/endpoints/custom/config.spec.ts +++ b/packages/api/src/endpoints/custom/config.spec.ts @@ -1,6 +1,6 @@ import { AuthType, EModelEndpoint, ReasoningParameterFormat } from 'librechat-data-provider'; import type { TCustomEndpoints } from 'librechat-data-provider'; -import { loadCustomEndpointsConfig } from './config'; +import { loadCustomEndpointsConfig, usesOpenRouterCatalog } from './config'; const baseEndpoint = { apiKey: 'sk-test', @@ -44,10 +44,12 @@ describe('loadCustomEndpointsConfig – native provider param set', () => { }); describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { - const load = (endpoint: Record) => - loadCustomEndpointsConfig([ + const load = (endpoint: Record) => { + const name = typeof endpoint.name === 'string' ? endpoint.name : 'Gateway'; + return loadCustomEndpointsConfig([ { ...baseEndpoint, name: 'Gateway', ...endpoint }, - ] as unknown as TCustomEndpoints)?.Gateway?.customParams; + ] as unknown as TCustomEndpoints)?.[name]?.customParams; + }; it('declares effort-style reasoning for OpenRouter', () => { expect(load({ baseURL: 'https://openrouter.ai/api/v1' })?.reasoningFormat).toBe( @@ -69,6 +71,95 @@ describe('loadCustomEndpointsConfig: host-implied reasoning support', () => { }, ); + describe('OpenRouter identified from the resolved host', () => { + const envKey = 'OPENROUTER_BASE_URL_PROBE'; + afterEach(() => { + delete process.env[envKey]; + }); + + it('marks an endpoint whose URL comes from an environment variable and whose name does not say OpenRouter', () => { + process.env[envKey] = 'https://openrouter.ai/api/v1'; + + const params = load({ name: 'Gateway', baseURL: `\${${envKey}}` }); + + expect(params?.defaultParamsEndpoint).toBe('openrouter'); + expect(params?.reasoningFormat).toBe(ReasoningParameterFormat.reasoningEffort); + }); + + it('marks a literal OpenRouter URL under any endpoint name', () => { + expect( + load({ name: 'Models', baseURL: 'https://openrouter.ai/api/v1' })?.defaultParamsEndpoint, + ).toBe('openrouter'); + }); + + it('keeps an administrator-chosen params endpoint', () => { + expect( + load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { defaultParamsEndpoint: EModelEndpoint.openAI }, + })?.defaultParamsEndpoint, + ).toBe(EModelEndpoint.openAI); + }); + + it('replaces the generic default the schema fills in', () => { + expect( + load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { defaultParamsEndpoint: EModelEndpoint.custom }, + })?.defaultParamsEndpoint, + ).toBe('openrouter'); + }); + + it.each([ReasoningParameterFormat.reasoningEffort, ReasoningParameterFormat.reasoningObject])( + 'marks an OpenRouter endpoint that sets reasoningFormat %s and keeps the format', + (format) => { + const params = load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { reasoningFormat: format }, + }); + + expect(params?.defaultParamsEndpoint).toBe('openrouter'); + expect(params?.reasoningFormat).toBe(format); + }, + ); + + it('leaves an endpoint that disables reasoning unmarked', () => { + const params = load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { reasoningFormat: ReasoningParameterFormat.disabled }, + }); + + expect(params?.defaultParamsEndpoint).toBeUndefined(); + expect(params?.reasoningFormat).toBe(ReasoningParameterFormat.disabled); + }); + + it('does not mark a host that is not OpenRouter', () => { + process.env[envKey] = 'https://api.mistral.ai/v1'; + + expect(load({ name: 'Gateway', baseURL: `\${${envKey}}` })).toBeUndefined(); + }); + + it('does not mark an endpoint whose URL the user provides', () => { + expect(load({ baseURL: 'user_provided' })).toBeUndefined(); + }); + + it('does not mark an endpoint that drops the effort', () => { + expect( + load({ baseURL: 'https://openrouter.ai/api/v1', dropParams: ['reasoning_effort'] }), + ).toBeUndefined(); + }); + + it('keeps an administrator-defined reasoning_effort authoritative', () => { + const params = load({ + baseURL: 'https://openrouter.ai/api/v1', + customParams: { paramDefinitions: [{ key: 'reasoning_effort' }] }, + }); + + expect(params?.defaultParamsEndpoint).toBe('openrouter'); + expect(params?.reasoningFormat).toBeUndefined(); + }); + }); + it('advertises reasoning for the OpenRouter params endpoint the config loader injects', () => { expect( load({ @@ -180,3 +271,44 @@ describe('loadCustomEndpointsConfig – user credential prompts', () => { ); }); }); + +describe('usesOpenRouterCatalog', () => { + const OR = 'https://openrouter.ai/api/v1'; + const uses = (endpoint: Record, baseURL: string = OR) => + usesOpenRouterCatalog({ ...baseEndpoint, name: 'Gateway', ...endpoint } as never, baseURL); + + it('is true for an OpenRouter host, whatever the endpoint is named', () => { + expect(uses({})).toBe(true); + }); + + it('is true for a proxy the administrator marks as OpenRouter, on any host', () => { + expect( + uses( + { customParams: { defaultParamsEndpoint: 'openrouter' } }, + 'https://proxy.example.com/v1', + ), + ).toBe(true); + }); + + it('is false for a host that is not OpenRouter and carries no marker', () => { + expect(uses({}, 'https://api.mistral.ai/v1')).toBe(false); + }); + + it('is false when the administrator chose another params endpoint', () => { + expect(uses({ customParams: { defaultParamsEndpoint: EModelEndpoint.openAI } })).toBe(false); + }); + + it('is false when the administrator defined reasoning_effort', () => { + expect(uses({ customParams: { paramDefinitions: [{ key: 'reasoning_effort' }] } })).toBe(false); + }); + + it('is false when reasoning is disabled', () => { + expect(uses({ customParams: { reasoningFormat: ReasoningParameterFormat.disabled } })).toBe( + false, + ); + }); + + it('is false for a native provider endpoint', () => { + expect(uses({ provider: EModelEndpoint.anthropic })).toBe(false); + }); +}); diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index 19cc4a2e7a9..1f492cfeac8 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -4,6 +4,7 @@ import { extractEnvVariable, normalizeEndpointName, reasoningSettingKeys, + hasExplicitReasoningEffort, ReasoningParameterFormat, } from 'librechat-data-provider'; import type { TCustomEndpoints, TEndpoint } from 'librechat-data-provider'; @@ -12,42 +13,71 @@ 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. + * Backend params whose removal also removes the effort an OpenRouter endpoint would advertise. */ -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. + * Whether an endpoint is OpenRouter's, from its resolved base URL host or from the administrator + * marking its parameter set as OpenRouter's (a proxy in front of OpenRouter has another host). A + * name or icon says nothing about what the server behind it accepts, so neither counts. Anything + * the administrator stated wins: a native `provider`, another params endpoint, or disabled + * reasoning. Only OpenRouter takes an effort for every model it serves: OpenAI and xAI 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 a Responses route a custom endpoint does not get), and this is decided per endpoint, before + * a model is chosen. + */ +export function isOpenRouterEndpoint( + endpoint: Pick, + resolvedBaseURL: string, +): boolean { + const params = endpoint.customParams as Partial | undefined; + if (endpoint.provider != null || params?.reasoningFormat === ReasoningParameterFormat.disabled) { + return false; + } + const paramsEndpoint = params?.defaultParamsEndpoint; + if (paramsEndpoint === ProviderId.openrouter) { + return true; + } + const generic = paramsEndpoint == null || paramsEndpoint === EModelEndpoint.custom; + return generic && providerFromBaseURL(resolvedBaseURL) === ProviderId.openrouter; +} + +/** + * Whether the endpoint's reasoning efforts come from OpenRouter's per-model catalog: the one rule + * the config marking, the catalog loader and the stored-effort check share, so the client's + * decision to fetch and the server's decision to read cannot disagree. An administrator-defined + * `reasoning_effort` is authoritative, so the catalog could change no result, and an endpoint that + * drops the effort never sends it. + */ +export function usesOpenRouterCatalog( + endpoint: Pick, + resolvedBaseURL: string, +): boolean { + return ( + isOpenRouterEndpoint(endpoint, resolvedBaseURL) && + !hasExplicitReasoningEffort(endpoint.customParams?.paramDefinitions) && + endpoint.dropParams?.some((param) => reasoningDropParams.has(param)) !== true + ); +} + +/** + * Declares reasoning support for an OpenRouter endpoint so the effort control appears without + * per-endpoint config, and marks the endpoint's parameter set as OpenRouter's so the client knows to + * read the catalog. The marking comes from the resolved host: the config loader only sees the + * unresolved name and URL. A `reasoningFormat` the administrator set is kept, and reasoning + * parameter definitions suppress only the format. An endpoint that drops the effort is left alone. */ -function withHostReasoning( - customParams: TEndpoint['customParams'], - baseURL: string, - provider?: string, - dropParams?: string[], +function withOpenRouterReasoning( + endpoint: Pick, + resolvedBaseURL: string, ): TEndpoint['customParams'] { - const host = providerFromBaseURL(baseURL); + const customParams = endpoint.customParams; if ( - provider != null || - host == null || - !effortReasoningHosts.has(host) || - dropParams?.some((param) => reasoningDropParams.has(param)) === true + !isOpenRouterEndpoint(endpoint, resolvedBaseURL) || + endpoint.dropParams?.some((param) => reasoningDropParams.has(param)) === true ) { return customParams; } @@ -55,19 +85,13 @@ function withHostReasoning( 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, + defaultParamsEndpoint: ProviderId.openrouter, + ...(params.reasoningFormat == null && + declaresReasoning !== true && { + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + }), } as TEndpoint['customParams']; } @@ -123,7 +147,7 @@ export function loadCustomEndpointsConfig( (customParams?.defaultParamsEndpoint == null || customParams.defaultParamsEndpoint === EModelEndpoint.custom) ? { ...customParams, defaultParamsEndpoint: provider } - : withHostReasoning(customParams, resolvedBaseURL, provider, dropParams); + : withOpenRouterReasoning({ provider, customParams, dropParams }, resolvedBaseURL); customEndpointsConfig[name] = { type: EModelEndpoint.custom, diff --git a/packages/api/src/endpoints/custom/initialize.spec.ts b/packages/api/src/endpoints/custom/initialize.spec.ts index c76c84af319..c671ddf0bfd 100644 --- a/packages/api/src/endpoints/custom/initialize.spec.ts +++ b/packages/api/src/endpoints/custom/initialize.spec.ts @@ -34,6 +34,8 @@ jest.mock('~/cache', () => ({ jest.mock('~/utils', () => ({ isUserProvided: (val: string) => val === 'user_provided', checkUserKeyExpiry: jest.fn(), + resolveHeaders: ({ headers }: { headers?: Record }) => ({ ...headers }), + applyAxiosProxyConfig: jest.fn(), })); const mockGetCustomEndpointConfig = jest.fn(); @@ -725,3 +727,126 @@ describe('initializeCustom – native Anthropic provider', () => { expect(options.provider).toBeUndefined(); }); }); + +describe('initializeCustom: stored reasoning effort', () => { + const OPENROUTER = 'https://openrouter.ai/api/v1'; + const sol = 'openai/gpt-6.1-sol'; + const catalog = { + data: [ + { id: sol, reasoning: { supported_efforts: ['low', 'high'], mandatory: false } }, + { id: 'google/gemini-3.5-flash', reasoning: { supported_efforts: ['max'] } }, + ], + }; + const makeDeps = (fetchPage: jest.Mock = jest.fn(async () => catalog)) => ({ + deps: { + fetchPage, + cache: { get: jest.fn(async () => undefined), set: jest.fn(async () => true) }, + }, + fetchPage, + }); + const run = async ( + modelParameters: Record, + options: { + deps?: ReturnType['deps']; + addParams?: Record; + } = {}, + ) => { + const params = createParams({ baseURL: OPENROUTER }); + mockGetCustomEndpointConfig.mockReturnValue({ + name: 'OpenRouter', + apiKey: 'sk-test-key', + baseURL: OPENROUTER, + models: {}, + ...(options.addParams && { addParams: options.addParams }), + }); + params.model_parameters = modelParameters; + params.reasoningCapabilityDeps = options.deps; + await initializeCustom(params); + return mockGetOpenAIConfig.mock.calls[0][1].modelOptions as Record; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('drops a stored effort the selected model does not accept', async () => { + const { deps } = makeDeps(); + + const sent = await run({ model: sol, reasoning_effort: 'max' }, { deps }); + + expect(sent).not.toHaveProperty('reasoning_effort'); + expect(sent.model).toBe(sol); + }); + + it('keeps a stored effort the selected model accepts', async () => { + const { deps } = makeDeps(); + + const sent = await run({ model: sol, reasoning_effort: 'low' }, { deps }); + + expect(sent.reasoning_effort).toBe('low'); + }); + + it('does not check an endpoint that pins its model through addParams', async () => { + const { deps, fetchPage } = makeDeps(); + + const sent = await run( + { model: sol, reasoning_effort: 'max' }, + { deps, addParams: { model: 'google/gemini-3.5-flash' } }, + ); + + expect(sent.reasoning_effort).toBe('max'); + expect(fetchPage).not.toHaveBeenCalled(); + }); + + it('leaves the stored effort alone when addParams sets one', async () => { + const { deps, fetchPage } = makeDeps(); + + const sent = await run( + { model: sol, reasoning_effort: 'max' }, + { deps, addParams: { reasoning_effort: 'low' } }, + ); + + expect(sent.reasoning_effort).toBe('max'); + expect(fetchPage).not.toHaveBeenCalled(); + }); + + it('sends the stored effort as saved when no capability dependencies are supplied', async () => { + const sent = await run({ model: sol, reasoning_effort: 'max' }); + + expect(sent.reasoning_effort).toBe('max'); + }); + + it('starts the catalog lookup while the model list is still being fetched', async () => { + const { fetchModels } = jest.requireMock('~/endpoints/models'); + const events: string[] = []; + fetchModels.mockReset().mockImplementation(async () => { + events.push('models-start'); + await new Promise((resolve) => setTimeout(resolve, 20)); + events.push('models-end'); + return []; + }); + const fetchPage = jest.fn(async () => { + events.push('catalog-start'); + return catalog; + }); + const { deps } = makeDeps(fetchPage); + const params = createParams({ baseURL: OPENROUTER }); + params.endpoint = 'openrouter'; + mockGetCustomEndpointConfig.mockReturnValue({ + name: 'OpenRouter', + apiKey: 'sk-test-key', + baseURL: OPENROUTER, + models: { fetch: true }, + }); + params.model_parameters = { model: sol, reasoning_effort: 'max' }; + params.reasoningCapabilityDeps = deps; + + await initializeCustom(params); + + expect(events.indexOf('catalog-start')).toBeGreaterThan(-1); + expect(events.indexOf('catalog-start')).toBeLessThan(events.indexOf('models-end')); + expect(mockGetOpenAIConfig.mock.calls[0][1].modelOptions).not.toHaveProperty( + 'reasoning_effort', + ); + }); +}); diff --git a/packages/api/src/endpoints/custom/initialize.ts b/packages/api/src/endpoints/custom/initialize.ts index e254c9543a8..476dd212f42 100644 --- a/packages/api/src/endpoints/custom/initialize.ts +++ b/packages/api/src/endpoints/custom/initialize.ts @@ -18,6 +18,7 @@ import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm import { resolveModelTransportTimeouts } from '~/agents/config'; import { extractDefaultParams } from '~/endpoints/openai/llm'; import { isUserProvided, checkUserKeyExpiry } from '~/utils'; +import { withSupportedEffort } from '~/endpoints/reasoning'; import { getOpenAIConfig } from '~/endpoints/openai/config'; import { getScopedTokenConfigKey } from '~/endpoints/keys'; import { getCustomEndpointConfig } from '~/app/config'; @@ -253,6 +254,20 @@ export async function initializeCustom( const userId = user?.id ?? ''; const tenantId = user?.tenantId; + /** The stored effort is checked against the provider's catalog, an independent read: it starts + * here and is awaited where the model options are built, so it overlaps token discovery below + * instead of following it. The no-op catch keeps an early failure from going unhandled. */ + const requestedOptions = { ...(model_parameters ?? {}), user: userId }; + const effortCheck = + params.reasoningCapabilityDeps == null + ? undefined + : withSupportedEffort( + requestedOptions, + endpointConfig as TEndpoint, + params.reasoningCapabilityDeps, + ); + effortCheck?.catch(() => undefined); + const cache = tokenConfigCache(); const hasTokenConfig = endpointConfig.tokenConfig != null; const tokenKey = getTokenConfigKey(endpointConfig, endpoint, userId, tenantId); @@ -317,7 +332,7 @@ export async function initializeCustom( ...customOptions, }; - const modelOptions = { ...(model_parameters ?? {}), user: userId }; + const modelOptions = effortCheck == null ? requestedOptions : await effortCheck; let options: InitializeResultBase; if (endpointConfig.provider === EModelEndpoint.anthropic) { diff --git a/packages/api/src/endpoints/index.ts b/packages/api/src/endpoints/index.ts index 9e6e9dbac07..85dd341b436 100644 --- a/packages/api/src/endpoints/index.ts +++ b/packages/api/src/endpoints/index.ts @@ -6,4 +6,6 @@ export * from './google'; export * from './models'; export * from './openai'; export * from './pricing'; +export * from './capabilities'; +export * from './reasoning'; export * from './tokenConfig'; diff --git a/packages/api/src/endpoints/reasoning.spec.ts b/packages/api/src/endpoints/reasoning.spec.ts new file mode 100644 index 00000000000..ce3361e41ae --- /dev/null +++ b/packages/api/src/endpoints/reasoning.spec.ts @@ -0,0 +1,1516 @@ +import { getModelReasoning } from 'librechat-data-provider'; +import type { TEndpoint } from 'librechat-data-provider'; +import type { ReasoningCapabilityDeps } from '~/types'; +import { loadReasoningCapabilities, withSupportedEffort, catalogRequestHeaders } from './reasoning'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const OPENROUTER = 'https://openrouter.ai/api/v1'; + +const catalog = { + data: [ + { + id: 'openai/gpt-6.1-sol', + reasoning: { supported_efforts: ['high', 'low', 'none'], mandatory: false }, + }, + { id: 'google/gemini-3.5-flash', reasoning: { supported_efforts: ['low'], mandatory: true } }, + { id: 'meta/no-reasoning' }, + { id: 'openrouter/auto' }, + { id: 'openrouter/free' }, + { id: 'meta/empty-efforts', reasoning: { supported_efforts: [] } }, + { id: 'meta/malformed', reasoning: 'yes' }, + { id: 'meta/no-efforts', reasoning: { mandatory: true } }, + { id: 'meta/unrestricted', reasoning: { supported_efforts: null, mandatory: false } }, + { id: 'meta/unrestricted-mandatory', reasoning: { supported_efforts: null, mandatory: true } }, + ], +}; + +const endpoint = (overrides: Record = {}): TEndpoint => + ({ + name: 'OpenRouter', + baseURL: OPENROUTER, + apiKey: 'sk-test', + models: { default: ['seed/model'], fetch: true }, + ...overrides, + }) as TEndpoint; + +function makeDeps(fetchPage?: ReasoningCapabilityDeps['fetchPage']) { + const store = new Map(); + const ttls = new Map(); + const fetchSpy = jest.fn(fetchPage ?? (async () => catalog)); + const deps: ReasoningCapabilityDeps = { + fetchPage: fetchSpy, + cache: { + get: async (key) => store.get(key), + set: async (key, value, ttl) => { + store.set(key, value); + ttls.set(key, ttl); + return true; + }, + }, + }; + return { deps, fetchSpy, store, ttls }; +} + +describe('loadReasoningCapabilities', () => { + it('maps the efforts and mandatory flag of each model that reports reasoning', async () => { + const { deps } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + expect(map.OpenRouter['openai/gpt-6.1-sol']).toEqual({ + efforts: ['high', 'low', 'none'], + mandatory: false, + }); + expect(map.OpenRouter['google/gemini-3.5-flash']).toEqual({ + efforts: ['low'], + mandatory: true, + }); + }); + + it('treats a null supported_efforts as every known effort being accepted', async () => { + const { deps } = makeDeps(); + + const { capabilities } = await loadReasoningCapabilities([endpoint()], deps); + + expect(capabilities.OpenRouter['meta/unrestricted'].efforts).toEqual( + expect.arrayContaining(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), + ); + expect(capabilities.OpenRouter['meta/unrestricted'].efforts).not.toContain(''); + expect(capabilities.OpenRouter['meta/unrestricted-mandatory'].mandatory).toBe(true); + }); + + it('records a model that exposes no effort selection as listed without reasoning', async () => { + const { deps } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + for (const id of ['meta/no-reasoning', 'meta/empty-efforts', 'meta/no-efforts']) { + expect(map.OpenRouter[id]).toEqual({ efforts: [] }); + } + }); + + it('leaves a model with a malformed reasoning object unknown', async () => { + const { deps } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + expect(map.OpenRouter).not.toHaveProperty(['meta/malformed']); + }); + + it('leaves the dynamic router models unknown, as their reasoning depends on the route', async () => { + const { deps } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + expect(map.OpenRouter).not.toHaveProperty(['openrouter/auto']); + expect(map.OpenRouter).not.toHaveProperty(['openrouter/free']); + }); + + it('keys the result by the normalized endpoint name', async () => { + const { deps } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities( + [endpoint({ name: 'Open Router' })], + deps, + ); + + expect(Object.keys(map)).toEqual(['Open Router']); + }); + + it('does not fetch for hosts other than OpenRouter', async () => { + const { deps, fetchSpy } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities( + [ + endpoint({ name: 'Local', baseURL: 'http://localhost:8080/v1' }), + endpoint({ name: 'Proxy', baseURL: 'https://gateway.example.com/openrouter.ai/v1' }), + ], + deps, + ); + + expect(map).toEqual({}); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch for a user-provided base URL', async () => { + const { deps, fetchSpy } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities( + [endpoint({ baseURL: 'user_provided' })], + deps, + ); + + expect(map).toEqual({}); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches once for endpoints that share a base URL', async () => { + const { deps, fetchSpy } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities( + [endpoint({ name: 'OpenRouter' }), endpoint({ name: 'OpenRouter Staging' })], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(map)).toEqual(['OpenRouter', 'OpenRouter Staging']); + }); + + it('serves a later call from the cache', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities([endpoint()], deps); + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(map.OpenRouter['google/gemini-3.5-flash']).toEqual({ + efforts: ['low'], + mandatory: true, + }); + }); + + it('leaves an endpoint out when the catalog cannot be fetched', async () => { + const { deps } = makeDeps(async () => { + throw new Error('upstream down'); + }); + + await expect(loadReasoningCapabilities([endpoint()], deps)).resolves.toMatchObject({ + capabilities: {}, + }); + }); + + it('does not cache a failed fetch', async () => { + const { deps, fetchSpy, store } = makeDeps(async () => { + throw new Error('upstream down'); + }); + + const noWindow = endpoint({ customParams: { reasoningCatalogFailureTtlMs: 0 } }); + + await loadReasoningCapabilities([noWindow], deps); + await loadReasoningCapabilities([noWindow], deps); + + expect(store.size).toBe(0); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('leaves an endpoint out when the catalog is not a model list', async () => { + const { deps } = makeDeps(async () => ({ error: 'nope' })); + + await expect(loadReasoningCapabilities([endpoint()], deps)).resolves.toMatchObject({ + capabilities: {}, + }); + }); + + it('returns an empty map without custom endpoints', async () => { + const { deps } = makeDeps(); + + await expect(loadReasoningCapabilities(undefined, deps)).resolves.toMatchObject({ + capabilities: {}, + }); + }); +}); + +describe('loadReasoningCapabilities: pagination', () => { + const page = (id: string, next: string | null) => ({ + data: [{ id, reasoning: { supported_efforts: ['low'] } }], + links: { next }, + }); + + it('follows the next link until the catalog is exhausted', async () => { + const { deps, fetchSpy } = makeDeps(async ({ url }) => + url.includes('offset=1') + ? page('b/second', null) + : page('a/first', '/api/v1/models?offset=1'), + ); + + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + expect(Object.keys(map.OpenRouter)).toEqual(['a/first', 'b/second']); + expect(fetchSpy.mock.calls.map(([params]) => params.url)).toEqual([ + `${OPENROUTER}/models`, + 'https://openrouter.ai/api/v1/models?offset=1', + ]); + }); + + it('leaves the endpoint out, uncached, when a later page fails', async () => { + const { deps, store } = makeDeps(async ({ url }) => { + if (url.includes('offset=1')) { + throw new Error('page 2 down'); + } + return page('a/first', '/api/v1/models?offset=1'); + }); + + await expect(loadReasoningCapabilities([endpoint()], deps)).resolves.toMatchObject({ + capabilities: {}, + }); + /* Only the failure marker is stored: no partial catalog is ever cached. */ + expect([...store.keys()].every((key) => key.endsWith(':failed'))).toBe(true); + }); + + it('refuses a next link that leaves the provider host', async () => { + const { deps, fetchSpy } = makeDeps(async () => + page('a/first', 'https://attacker.example.com/api/v1/models?offset=1'), + ); + + await expect(loadReasoningCapabilities([endpoint()], deps)).resolves.toMatchObject({ + capabilities: {}, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('refuses a next link that loops back to a page already read', async () => { + const { deps, fetchSpy } = makeDeps(async () => page('a/first', '/api/v1/models')); + + await expect(loadReasoningCapabilities([endpoint()], deps)).resolves.toMatchObject({ + capabilities: {}, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('gives up on a catalog with more pages than the limit', async () => { + let offset = 0; + const { deps, fetchSpy } = makeDeps(async () => { + offset += 1; + return page(`m/${offset}`, `/api/v1/models?offset=${offset}`); + }); + + await expect(loadReasoningCapabilities([endpoint()], deps)).resolves.toMatchObject({ + capabilities: {}, + }); + expect(fetchSpy).toHaveBeenCalledTimes(20); + }); +}); + +describe('loadReasoningCapabilities: timeout', () => { + it('uses 5000 ms when the endpoint does not set one', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities([endpoint()], deps); + + expect(fetchSpy).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 5000 })); + }); + + it('uses the timeout the endpoint configures', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogTimeoutMs: 12000 } })], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 12000 })); + }); +}); + +describe('withSupportedEffort', () => { + const sol = 'openai/gpt-6.1-sol'; + const options = (reasoning_effort?: string, model = sol) => ({ + model, + ...(reasoning_effort != null && { reasoning_effort }), + }); + + it('keeps an effort the model supports', async () => { + const { deps } = makeDeps(); + const stored = options('low'); + + await expect(withSupportedEffort(stored, endpoint(), deps)).resolves.toBe(stored); + }); + + it('drops an effort the model does not support', async () => { + const { deps } = makeDeps(); + + const result = await withSupportedEffort(options('max'), endpoint(), deps); + + expect(result).toEqual({ model: sol }); + }); + + it('drops none for a model whose reasoning is mandatory', async () => { + const { deps } = makeDeps(); + + const result = await withSupportedEffort( + options('none', 'google/gemini-3.5-flash'), + endpoint(), + deps, + ); + + expect(result).toEqual({ model: 'google/gemini-3.5-flash' }); + }); + + it('drops any effort for a model the catalog lists without reasoning', async () => { + const { deps } = makeDeps(); + + const result = await withSupportedEffort(options('low', 'meta/no-reasoning'), endpoint(), deps); + + expect(result).toEqual({ model: 'meta/no-reasoning' }); + }); + + it('keeps the effort for a model the catalog does not list, such as an alias', async () => { + const { deps } = makeDeps(); + const stored = options('max', '~openai/gpt-latest'); + + await expect(withSupportedEffort(stored, endpoint(), deps)).resolves.toBe(stored); + }); + + it('keeps the effort while the catalog cannot be read', async () => { + const { deps } = makeDeps(async () => { + throw new Error('upstream down'); + }); + const stored = options('max'); + + await expect(withSupportedEffort(stored, endpoint(), deps)).resolves.toBe(stored); + }); + + it('keeps an effort the administrator defined for the endpoint', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = options('max'); + + const result = await withSupportedEffort( + stored, + endpoint({ customParams: { paramDefinitions: [{ key: 'reasoning_effort' }] } }), + deps, + ); + + expect(result).toBe(stored); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch when no effort is stored', async () => { + const { deps, fetchSpy } = makeDeps(); + + await withSupportedEffort(options(), endpoint(), deps); + await withSupportedEffort(options(''), endpoint(), deps); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch for an endpoint that is not on OpenRouter', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = options('max'); + + const result = await withSupportedEffort( + stored, + endpoint({ baseURL: 'https://api.openai.com/v1' }), + deps, + ); + + expect(result).toBe(stored); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('loadReasoningCapabilities: availability', () => { + const down = async () => { + throw new Error('upstream down'); + }; + + it('reports an endpoint whose catalog cannot be read as unavailable', async () => { + const { deps } = makeDeps(down); + + const result = await loadReasoningCapabilities([endpoint()], deps); + + expect(result).toEqual({ capabilities: {}, unavailable: ['OpenRouter'] }); + }); + + it('reports every endpoint that shares a failed catalog', async () => { + const { deps } = makeDeps(down); + + const result = await loadReasoningCapabilities( + [endpoint({ name: 'OpenRouter' }), endpoint({ name: 'OpenRouter Staging' })], + deps, + ); + + expect(result.unavailable).toEqual(['OpenRouter', 'OpenRouter Staging']); + }); + + it('reports nothing unavailable when every catalog was read', async () => { + const { deps } = makeDeps(); + + const result = await loadReasoningCapabilities([endpoint()], deps); + + expect(result.unavailable).toEqual([]); + }); + + it('does not report an endpoint that was never eligible as unavailable', async () => { + const { deps } = makeDeps(down); + + const result = await loadReasoningCapabilities( + [endpoint({ baseURL: 'user_provided' }), endpoint({ name: 'Local', baseURL: 'http://x/v1' })], + deps, + ); + + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + }); + + it('keeps a readable catalog when another endpoint fails', async () => { + const { deps } = makeDeps(async ({ apiKey }) => { + if (apiKey === 'sk-bad') { + throw new Error('rejected'); + } + return catalog; + }); + + const result = await loadReasoningCapabilities( + [endpoint({ name: 'Good' }), endpoint({ name: 'Bad', apiKey: 'sk-bad' })], + deps, + ); + + expect(Object.keys(result.capabilities)).toEqual(['Good']); + expect(result.unavailable).toEqual(['Bad']); + }); +}); + +describe('loadReasoningCapabilities: explicit reasoning definitions', () => { + const explicit = { customParams: { paramDefinitions: [{ key: 'reasoning_effort' }] } }; + + it('skips an endpoint whose administrator defined reasoning_effort', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities([endpoint(explicit)], deps); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + }); + + it('does not wait on a catalog outage for such an endpoint', async () => { + const { deps, fetchSpy } = makeDeps(async () => { + throw new Error('upstream down'); + }); + + const result = await loadReasoningCapabilities([endpoint(explicit)], deps); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.unavailable).toEqual([]); + }); + + it('still reads an endpoint whose paramDefinitions do not define reasoning_effort', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ customParams: { paramDefinitions: [{ key: 'promptCache' }] } })], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('loadReasoningCapabilities: pinned model', () => { + it('skips an endpoint that pins its model through addParams', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities( + [endpoint({ addParams: { model: 'google/gemini-3.5-flash' } })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + }); + + it('still reads an endpoint whose addParams do not pin a model', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities([endpoint({ addParams: { temperature: 0.2 } })], deps); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('loadReasoningCapabilities: scoped to one endpoint', () => { + const down = async ({ apiKey }: { apiKey: string }) => { + if (apiKey === 'sk-bad') { + throw new Error('rejected'); + } + return catalog; + }; + + it('reads only the requested endpoint', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities( + [endpoint({ name: 'A' }), endpoint({ name: 'B', apiKey: 'sk-b' })], + deps, + 'B', + ); + + expect(Object.keys(result.capabilities)).toEqual(['B']); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('is not affected by another endpoint whose catalog is unavailable', async () => { + const { deps } = makeDeps(down); + + const result = await loadReasoningCapabilities( + [endpoint({ name: 'Bad', apiKey: 'sk-bad' }), endpoint({ name: 'Good' })], + deps, + 'Good', + ); + + expect(result.unavailable).toEqual([]); + expect(Object.keys(result.capabilities)).toEqual(['Good']); + }); + + it('reports the requested endpoint when its own catalog is unavailable', async () => { + const { deps } = makeDeps(down); + + const result = await loadReasoningCapabilities( + [endpoint({ name: 'Bad', apiKey: 'sk-bad' }), endpoint({ name: 'Good' })], + deps, + 'Bad', + ); + + expect(result.unavailable).toEqual(['Bad']); + }); + + it('returns nothing for a name that matches no endpoint', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities([endpoint()], deps, 'Missing'); + + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('loadReasoningCapabilities: direct endpoints', () => { + it('skips catalog discovery because the base URL is the exact inference URL', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities( + [endpoint({ baseURL: `${OPENROUTER}/chat/completions`, directEndpoint: true })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + }); +}); + +describe('loadReasoningCapabilities: page limit', () => { + const endless = () => { + let offset = 0; + return async () => { + offset += 1; + return { + data: [{ id: `m/${offset}`, reasoning: { supported_efforts: ['low'] } }], + links: { next: `/api/v1/models?offset=${offset}` }, + }; + }; + }; + + it('stops at the page limit the endpoint configures', async () => { + const { deps, fetchSpy } = makeDeps(endless()); + + await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogMaxPages: 3 } })], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('reads a catalog longer than the default when the endpoint raises the limit', async () => { + const pages = 25; + let offset = 0; + const { deps } = makeDeps(async () => { + offset += 1; + return { + data: [{ id: `m/${offset}`, reasoning: { supported_efforts: ['low'] } }], + links: { next: offset < pages ? `/api/v1/models?offset=${offset}` : null }, + }; + }); + + const { capabilities } = await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogMaxPages: 30 } })], + deps, + ); + + expect(Object.keys(capabilities.OpenRouter)).toHaveLength(pages); + }); +}); + +describe('catalog lookups are shared across callers', () => { + it('walks the catalog once for concurrent loads', async () => { + const { deps, fetchSpy } = makeDeps(async () => { + await new Promise((resolve) => setImmediate(resolve)); + return catalog; + }); + + await Promise.all([ + loadReasoningCapabilities([endpoint()], deps), + loadReasoningCapabilities([endpoint()], deps), + loadReasoningCapabilities([endpoint()], deps), + ]); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('shares the lookup between a load and a stored-effort check', async () => { + const { deps, fetchSpy } = makeDeps(async () => { + await new Promise((resolve) => setImmediate(resolve)); + return catalog; + }); + + await Promise.all([ + loadReasoningCapabilities([endpoint()], deps), + withSupportedEffort( + { model: 'openai/gpt-6.1-sol', reasoning_effort: 'low' }, + endpoint(), + deps, + ), + ]); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('retries after a lookup that failed', async () => { + let calls = 0; + const { deps, fetchSpy } = makeDeps(async () => { + calls += 1; + if (calls === 1) { + throw new Error('first attempt fails'); + } + return catalog; + }); + + const noWindow = endpoint({ customParams: { reasoningCatalogFailureTtlMs: 0 } }); + + await loadReasoningCapabilities([noWindow], deps); + const second = await loadReasoningCapabilities([noWindow], deps); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(second.unavailable).toEqual([]); + }); +}); + +describe('lookup identity includes the configured policy', () => { + const pages = (count: number) => { + let read = 0; + return async () => { + read += 1; + return { + data: [{ id: `m/${read}`, reasoning: { supported_efforts: ['low'] } }], + links: { next: read < count ? `/api/v1/models?offset=${read}` : null }, + }; + }; + }; + + it('does not let a one-page endpoint decide the result of a five-page endpoint', async () => { + const { deps } = makeDeps(pages(3)); + + const result = await loadReasoningCapabilities( + [ + endpoint({ name: 'Short', customParams: { reasoningCatalogMaxPages: 1 } }), + endpoint({ name: 'Long', customParams: { reasoningCatalogMaxPages: 5 } }), + ], + deps, + ); + + expect(result.unavailable).toEqual(['Short']); + expect(Object.keys(result.capabilities)).toEqual(['Long']); + }); + + it('still shares one walk between endpoints with the same policy', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [ + endpoint({ name: 'A', customParams: { reasoningCatalogMaxPages: 5 } }), + endpoint({ name: 'B', customParams: { reasoningCatalogMaxPages: 5 } }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('failure logging', () => { + it('logs the origin of a failed catalog and never the configured URL or credentials', async () => { + const { logger } = jest.requireMock('@librechat/data-schemas'); + const { deps } = makeDeps(async () => { + throw new Error('upstream down'); + }); + + await loadReasoningCapabilities( + [endpoint({ baseURL: 'https://user:s3cret@openrouter.ai/api/v1?key=abc123' })], + deps, + ); + + const logged = JSON.stringify(logger.warn.mock.calls); + expect(logged).toContain('https://openrouter.ai'); + expect(logged).not.toContain('s3cret'); + expect(logged).not.toContain('abc123'); + expect(logged).not.toContain('user:'); + }); +}); + +describe('withSupportedEffort: addParams', () => { + const sol = 'openai/gpt-6.1-sol'; + + it('does not check an endpoint that pins its model through addParams', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = { model: sol, reasoning_effort: 'max' }; + + const result = await withSupportedEffort( + stored, + endpoint({ addParams: { model: 'google/gemini-3.5-flash' } }), + deps, + ); + + expect(result).toBe(stored); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('leaves an effort alone when addParams replaces it', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = { model: sol, reasoning_effort: 'max' }; + + const result = await withSupportedEffort( + stored, + endpoint({ addParams: { reasoning_effort: 'low' } }), + deps, + ); + + expect(result).toBe(stored); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('failed catalog lookups are remembered briefly', () => { + const down = async () => { + throw new Error('upstream down'); + }; + + it('does not walk a failed catalog again within the failure window', async () => { + const { deps, fetchSpy } = makeDeps(down); + + await loadReasoningCapabilities([endpoint()], deps); + const second = await loadReasoningCapabilities([endpoint()], deps); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(second.unavailable).toEqual(['OpenRouter']); + }); + + it('shares the failure with the stored-effort check of the same request', async () => { + const { deps, fetchSpy } = makeDeps(down); + const stored = { model: 'openai/gpt-6.1-sol', reasoning_effort: 'max' }; + + await loadReasoningCapabilities([endpoint()], deps, 'OpenRouter'); + const result = await withSupportedEffort(stored, endpoint(), deps); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result).toBe(stored); + }); + + it('remembers the failure for 30 seconds by default', async () => { + const { deps, ttls } = makeDeps(down); + + await loadReasoningCapabilities([endpoint()], deps); + + expect([...ttls.values()]).toContain(30000); + }); + + it('remembers it for the window the endpoint configures', async () => { + const { deps, ttls } = makeDeps(down); + + await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogFailureTtlMs: 5000 } })], + deps, + ); + + expect([...ttls.values()]).toContain(5000); + }); + + it('remembers nothing when the window is 0', async () => { + const { deps, store } = makeDeps(down); + + await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogFailureTtlMs: 0 } })], + deps, + ); + + expect(store.size).toBe(0); + }); + + it('remembers an unreadable catalog the same way', async () => { + const { deps, fetchSpy } = makeDeps(async () => ({ error: 'nope' })); + + await loadReasoningCapabilities([endpoint()], deps); + await loadReasoningCapabilities([endpoint()], deps); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not remember a successful lookup as a failure', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities([endpoint()], deps); + const second = await loadReasoningCapabilities([endpoint()], deps); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(second.unavailable).toEqual([]); + }); +}); + +describe('loadReasoningCapabilities: which endpoints use the catalog', () => { + it('reads a proxy the administrator marks as OpenRouter, though its host is not OpenRouter', async () => { + const { deps, fetchSpy } = makeDeps(); + + const { capabilities } = await loadReasoningCapabilities( + [ + endpoint({ + baseURL: 'https://proxy.example.com/v1', + customParams: { defaultParamsEndpoint: 'openrouter' }, + }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0].url).toBe('https://proxy.example.com/v1/models'); + expect(Object.keys(capabilities)).toEqual(['OpenRouter']); + }); + + it('skips an OpenRouter host whose administrator chose another params endpoint', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ customParams: { defaultParamsEndpoint: 'openAI' } })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('skips an endpoint that disables reasoning', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningFormat: 'disabled' } })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('catalog URL', () => { + it('puts /models on the path of a base URL that carries a query string', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ baseURL: 'https://openrouter.ai/api/v1?signature=abc' })], + deps, + ); + + expect(fetchSpy.mock.calls[0][0].url).toBe('https://openrouter.ai/api/v1/models?signature=abc'); + }); + + it('handles a trailing slash', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities([endpoint({ baseURL: `${OPENROUTER}/` })], deps); + + expect(fetchSpy.mock.calls[0][0].url).toBe(`${OPENROUTER}/models`); + }); +}); + +describe('successful catalog lifetime', () => { + it('keeps a read catalog for an hour by default', async () => { + const { deps, ttls } = makeDeps(); + + await loadReasoningCapabilities([endpoint()], deps); + + expect([...ttls.values()]).toContain(3600000); + }); + + it('keeps it for the lifetime the endpoint configures', async () => { + const { deps, ttls } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogTtlMs: 120000 } })], + deps, + ); + + expect([...ttls.values()]).toContain(120000); + }); + + it('does not let one lifetime policy serve an endpoint with another', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [ + endpoint({ name: 'Short', customParams: { reasoningCatalogTtlMs: 120000 } }), + endpoint({ name: 'Long', customParams: { reasoningCatalogTtlMs: 7200000 } }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe('catalog request headers', () => { + /** A static model list: the headers do not shape what users are offered, so the catalog is read + * and only the per-user headers are withheld. With `models.fetch` they would be skipped instead. */ + const headersSent = async (headers: Record) => { + const { deps, fetchSpy } = makeDeps(); + await loadReasoningCapabilities( + [endpoint({ headers, models: { default: ['seed/model'] } })], + deps, + ); + return fetchSpy.mock.calls[0][0].headers; + }; + + afterEach(() => { + delete process.env.PROBE_PROXY_TOKEN; + }); + + it('forwards a static configured header', async () => { + expect(await headersSent({ 'X-Proxy-Tenant': 'acme' })).toEqual({ 'X-Proxy-Tenant': 'acme' }); + }); + + it('resolves an environment variable in a header value', async () => { + process.env.PROBE_PROXY_TOKEN = 'env-secret'; + + expect(await headersSent({ 'X-Proxy-Token': '${PROBE_PROXY_TOKEN}' })).toEqual({ + 'X-Proxy-Token': 'env-secret', + }); + }); + + it('drops a request-metadata header, which has no value outside a request', async () => { + const sent = await headersSent({ + 'x-librechat-body-parentmessageid': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}', + 'X-Static': 'kept', + }); + + expect(sent).toEqual({ 'X-Static': 'kept' }); + }); + + it.each([ + ['a user field', { 'X-User': '{{LIBRECHAT_USER_EMAIL}}' }], + ['an OpenID token', { Authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}' }], + ['text mixed with a user placeholder', { 'X-Mixed': 'prefix-{{LIBRECHAT_USER_ID}}-suffix' }], + ])( + 'skips an endpoint with %s in a header, even with a fixed model list', + async (_label, headers) => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities( + [endpoint({ headers, models: { default: ['seed/model'] } })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + }, + ); + + it('does not check a stored effort on a fixed-list endpoint that authenticates per user', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = { model: 'openai/gpt-6.1-sol', reasoning_effort: 'max' }; + + const result = await withSupportedEffort( + stored, + endpoint({ + headers: { Authorization: 'Bearer {{LIBRECHAT_USER_OPENID_ID_TOKEN}}' }, + models: { default: ['openai/gpt-6.1-sol'] }, + }), + deps, + ); + + expect(result).toBe(stored); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('drops a header whose environment variable is not set', async () => { + expect(await headersSent({ 'X-Proxy-Token': '${PROBE_PROXY_TOKEN}' })).toEqual({}); + }); + + it('keeps endpoints with different headers apart', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [ + endpoint({ name: 'A', headers: { 'X-Tenant': 'a' } }), + endpoint({ name: 'B', headers: { 'X-Tenant': 'b' } }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('shares a lookup between endpoints with the same headers', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [ + endpoint({ name: 'A', headers: { 'X-Tenant': 'a' } }), + endpoint({ name: 'B', headers: { 'X-Tenant': 'a' } }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('adds the API key as a Bearer token unless a configured Authorization header is present', () => { + expect(catalogRequestHeaders('sk-key', { 'X-A': '1' })).toEqual({ + 'X-A': '1', + Authorization: 'Bearer sk-key', + }); + expect(catalogRequestHeaders('sk-key', { authorization: 'Token proxy' })).toEqual({ + authorization: 'Token proxy', + }); + }); +}); + +describe('relative pagination links', () => { + const page = (id: string, next: string | null) => ({ + data: [{ id, reasoning: { supported_efforts: ['low'] } }], + links: { next }, + }); + const walk = async (next: string) => { + const { deps, fetchSpy } = makeDeps(async ({ url }) => + url.includes('offset=') ? page('b/second', null) : page('a/first', next), + ); + const result = await loadReasoningCapabilities([endpoint()], deps); + return { result, urls: fetchSpy.mock.calls.map(([params]) => params.url) }; + }; + + it('resolves a query-relative link against the current page', async () => { + const { result, urls } = await walk('?offset=1'); + + expect(urls[1]).toBe(`${OPENROUTER}/models?offset=1`); + expect(Object.keys(result.capabilities.OpenRouter)).toEqual(['a/first', 'b/second']); + }); + + it('resolves a path-relative link against the current page', async () => { + const { urls } = await walk('models?offset=1'); + + expect(urls[1]).toBe(`${OPENROUTER}/models?offset=1`); + }); + + it('still follows a root-relative link and an absolute same-host link', async () => { + expect((await walk('/api/v1/models?offset=1')).urls[1]).toBe(`${OPENROUTER}/models?offset=1`); + expect((await walk(`${OPENROUTER}/models?offset=1`)).urls[1]).toBe( + `${OPENROUTER}/models?offset=1`, + ); + }); +}); + +describe('models an endpoint exposes', () => { + const load = async (models: Record) => { + const { deps } = makeDeps(); + const { capabilities } = await loadReasoningCapabilities([endpoint({ models })], deps); + return Object.keys(capabilities.OpenRouter ?? {}).sort(); + }; + + it('returns every catalog model when the endpoint fetches its model list', async () => { + const ids = await load({ default: ['openai/gpt-6.1-sol'], fetch: true }); + + expect(ids).toEqual( + expect.arrayContaining([ + 'openai/gpt-6.1-sol', + 'google/gemini-3.5-flash', + 'meta/unrestricted', + ]), + ); + }); + + it('returns only the configured models when the endpoint does not fetch', async () => { + expect(await load({ default: ['openai/gpt-6.1-sol'] })).toEqual(['openai/gpt-6.1-sol']); + expect(await load({ default: ['openai/gpt-6.1-sol'], fetch: false })).toEqual([ + 'openai/gpt-6.1-sol', + ]); + }); + + it('accepts configured models given as objects with a name', async () => { + expect( + await load({ default: [{ name: 'google/gemini-3.5-flash', description: 'fast' }] }), + ).toEqual(['google/gemini-3.5-flash']); + }); + + it('matches a configured routing variant to its base model', async () => { + expect(await load({ default: ['openai/gpt-6.1-sol:nitro'] })).toEqual(['openai/gpt-6.1-sol']); + }); + + describe('routing variants', () => { + const siblings = { + data: ['a/m', 'a/m:nitro', 'a/m:free', 'a/other'].map((id) => ({ + id, + reasoning: { supported_efforts: ['low'] }, + })), + }; + const loadSiblings = async (models: Record) => { + const { deps } = makeDeps(async () => siblings); + const { capabilities } = await loadReasoningCapabilities([endpoint({ models })], deps); + return Object.keys(capabilities.OpenRouter ?? {}).sort(); + }; + + it('exposes a configured variant and its base model, not its sibling variants', async () => { + expect(await loadSiblings({ default: ['a/m:free'] })).toEqual(['a/m', 'a/m:free']); + }); + + it('exposes only the exact model when no variant is configured', async () => { + expect(await loadSiblings({ default: ['a/m'] })).toEqual(['a/m']); + }); + + it('exposes each configured variant and nothing else of the family', async () => { + expect(await loadSiblings({ default: ['a/m:free', 'a/m:nitro'] })).toEqual([ + 'a/m', + 'a/m:free', + 'a/m:nitro', + ]); + }); + }); + + it('returns nothing for a configured model the catalog does not list', async () => { + expect(await load({ default: ['meta/not-in-catalog'] })).toEqual([]); + }); + + it('returns nothing when the endpoint configures no models and does not fetch', async () => { + expect(await load({ default: [] })).toEqual([]); + }); + + it('still reads the full catalog once and filters per endpoint', async () => { + const { deps, fetchSpy } = makeDeps(); + + const { capabilities } = await loadReasoningCapabilities( + [ + endpoint({ name: 'Open', models: { default: ['openai/gpt-6.1-sol'] } }), + endpoint({ name: 'Wide', models: { default: ['seed/model'], fetch: true } }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(capabilities.Open)).toEqual(['openai/gpt-6.1-sol']); + expect(Object.keys(capabilities.Wide).length).toBeGreaterThan(1); + }); + + it('does not let the filter hide a model from the stored-effort check', async () => { + const { deps } = makeDeps(); + + const result = await withSupportedEffort( + { model: 'google/gemini-3.5-flash', reasoning_effort: 'max' }, + endpoint({ models: { default: ['openai/gpt-6.1-sol'] } }), + deps, + ); + + expect(result).toEqual({ model: 'google/gemini-3.5-flash' }); + }); +}); + +describe('when the catalog entry expires', () => { + it('reports the moment a freshly read catalog expires', async () => { + const { deps } = makeDeps(); + const before = Date.now(); + + const { expiresAt } = await loadReasoningCapabilities([endpoint()], deps); + + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600000); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 3600000); + }); + + it('keeps the original moment when the entry is served from the cache', async () => { + const { deps } = makeDeps(); + const first = await loadReasoningCapabilities([endpoint()], deps); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const second = await loadReasoningCapabilities([endpoint()], deps); + + expect(second.expiresAt).toBe(first.expiresAt); + }); + + it('follows the configured lifetime', async () => { + const { deps } = makeDeps(); + const before = Date.now(); + + const { expiresAt } = await loadReasoningCapabilities( + [endpoint({ customParams: { reasoningCatalogTtlMs: 120000 } })], + deps, + ); + + expect(expiresAt).toBeGreaterThanOrEqual(before + 120000); + expect(expiresAt).toBeLessThan(before + 3600000); + }); + + it('reports the earliest expiry across endpoints', async () => { + const { deps } = makeDeps(); + const before = Date.now(); + + const { expiresAt } = await loadReasoningCapabilities( + [ + endpoint({ name: 'Short', customParams: { reasoningCatalogTtlMs: 120000 } }), + endpoint({ name: 'Long', customParams: { reasoningCatalogTtlMs: 7200000 } }), + ], + deps, + ); + + expect(expiresAt).toBeLessThan(before + 3600000); + }); + + it('has no expiry when no endpoint was read', async () => { + const { deps } = makeDeps(async () => { + throw new Error('down'); + }); + + const { expiresAt } = await loadReasoningCapabilities([endpoint()], deps); + + expect(expiresAt).toBeUndefined(); + }); +}); + +describe('malformed pagination metadata', () => { + const withLinks = (links: unknown) => ({ + data: [{ id: 'a/first', reasoning: { supported_efforts: ['low'] } }], + links, + }); + + it.each([ + ['a numeric next', { next: 2 }], + ['an object next', { next: { page: 2 } }], + ['a boolean next', { next: true }], + ['links that are not an object', 'next'], + ])('reports the endpoint unavailable for %s', async (_label, links) => { + const { deps, store } = makeDeps(async () => withLinks(links)); + + const result = await loadReasoningCapabilities([endpoint()], deps); + + expect(result).toEqual({ capabilities: {}, unavailable: ['OpenRouter'] }); + expect([...store.keys()].every((key) => key.endsWith(':failed'))).toBe(true); + }); + + it.each([ + ['no links at all', undefined], + ['links without next', {}], + ['a null next', { next: null }], + ['an empty next', { next: '' }], + ])('accepts %s as the end of the catalog', async (_label, links) => { + const { deps } = makeDeps(async () => withLinks(links)); + + const result = await loadReasoningCapabilities([endpoint()], deps); + + expect(result.unavailable).toEqual([]); + expect(result.capabilities.OpenRouter).toHaveProperty(['a/first']); + }); +}); + +describe('catalogs whose model list is scoped to the requesting user', () => { + it('skips an endpoint that filters its fetched models by user id', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities( + [endpoint({ models: { fetch: true, userIdQuery: true, default: ['a/b'] } })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ capabilities: {}, unavailable: [] }); + }); + + it('skips an endpoint whose configured headers resolve per request', async () => { + const { deps, fetchSpy } = makeDeps(); + + const result = await loadReasoningCapabilities( + [ + endpoint({ + models: { fetch: true, default: ['a/b'] }, + headers: { Authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}' }, + }), + ], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.unavailable).toEqual([]); + }); + + it('reads an endpoint whose only per-request header is request metadata, as in the documented example', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [ + endpoint({ + models: { fetch: true, default: ['a/b'] }, + headers: { 'x-librechat-body-parentmessageid': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' }, + }), + ], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0].headers).toEqual({}); + }); + + it.each([ + '{{LIBRECHAT_USER_EMAIL}}', + 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}', + '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}', + ])('skips an endpoint with the identity-dependent header value %s', async (value) => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ models: { fetch: true, default: ['a/b'] }, headers: { 'X-Id': value } })], + deps, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('checks a stored effort on the documented example configuration', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = { model: 'openai/gpt-6.1-sol', reasoning_effort: 'max' }; + + const result = await withSupportedEffort( + stored, + endpoint({ + models: { fetch: true, default: ['a/b'] }, + headers: { 'x-librechat-body-parentmessageid': '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' }, + }), + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ model: 'openai/gpt-6.1-sol' }); + }); + + it('still reads an endpoint that fetches models for everyone alike', async () => { + const { deps, fetchSpy } = makeDeps(); + + await loadReasoningCapabilities( + [endpoint({ models: { fetch: true, default: ['a/b'] } })], + deps, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not check a stored effort on a user-scoped endpoint', async () => { + const { deps, fetchSpy } = makeDeps(); + const stored = { model: 'openai/gpt-6.1-sol', reasoning_effort: 'max' }; + + const result = await withSupportedEffort( + stored, + endpoint({ models: { fetch: true, userIdQuery: true, default: ['a/b'] } }), + deps, + ); + + expect(result).toBe(stored); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('model ids that name Object.prototype members', () => { + const protoCatalog = { + data: [ + { id: 'constructor', reasoning: { supported_efforts: ['low'] } }, + { id: '__proto__', reasoning: { supported_efforts: ['high'] } }, + { id: 'a/b', reasoning: { supported_efforts: ['low'] } }, + ], + }; + + it('records them as own entries, so none is lost to the prototype setter', async () => { + const { deps } = makeDeps(async () => protoCatalog); + + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + expect(Object.keys(map.OpenRouter).sort()).toEqual(['__proto__', 'a/b', 'constructor']); + expect(({} as Record).efforts).toBeUndefined(); + }); + + it('serves them through the JSON response and finds them on the client', async () => { + const { deps } = makeDeps(async () => protoCatalog); + const { capabilities: map } = await loadReasoningCapabilities([endpoint()], deps); + + const received = JSON.parse(JSON.stringify(map)); + + expect(getModelReasoning(received, 'OpenRouter', '__proto__')).toEqual({ + efforts: ['high'], + mandatory: false, + }); + expect(getModelReasoning(received, 'OpenRouter', 'constructor')).toEqual({ + efforts: ['low'], + mandatory: false, + }); + }); + + it('keeps an endpoint named __proto__ as an own entry', async () => { + const { deps } = makeDeps(); + + const { capabilities: map } = await loadReasoningCapabilities( + [endpoint({ name: '__proto__' })], + deps, + ); + + expect(Object.keys(map)).toEqual(['__proto__']); + }); + + it('does not throw when a stored effort sits on such an unlisted model', async () => { + const { deps } = makeDeps(); + const stored = { model: 'toString', reasoning_effort: 'max' }; + + await expect(withSupportedEffort(stored, endpoint(), deps)).resolves.toBe(stored); + }); +}); + +describe('configured query parameters across catalog pages', () => { + const base = 'https://openrouter.ai/api/v1?signature=abc'; + const page = (id: string, next: string | null) => ({ + data: [{ id, reasoning: { supported_efforts: ['low'] } }], + links: { next }, + }); + const urlsFor = async (next: string) => { + const { deps, fetchSpy } = makeDeps(async ({ url }) => + url.includes('offset=100') ? page('b/second', null) : page('a/first', next), + ); + const result = await loadReasoningCapabilities([endpoint({ baseURL: base })], deps); + return { result, urls: fetchSpy.mock.calls.map(([params]) => params.url) }; + }; + + it('keeps the configured query on a next link that has its own query', async () => { + const { result, urls } = await urlsFor('/api/v1/models?offset=100&limit=100'); + + expect(urls[1]).toBe('https://openrouter.ai/api/v1/models?offset=100&limit=100&signature=abc'); + expect(result.unavailable).toEqual([]); + }); + + it('keeps it on a next link that is only a query', async () => { + const { urls } = await urlsFor('?offset=100'); + + expect(new URL(urls[1]).searchParams.get('signature')).toBe('abc'); + }); + + it('lets a pagination parameter override a configured one of the same name', async () => { + const { deps, fetchSpy } = makeDeps(async ({ url }) => + url.includes('offset=100') + ? page('b/second', null) + : page('a/first', '/api/v1/models?offset=100&signature=server'), + ); + + await loadReasoningCapabilities([endpoint({ baseURL: base })], deps); + + expect(new URL(fetchSpy.mock.calls[1][0].url).searchParams.getAll('signature')).toEqual([ + 'server', + ]); + }); + + it('adds nothing when the base URL has no query', async () => { + const { deps, fetchSpy } = makeDeps(async ({ url }) => + url.includes('offset=100') + ? page('b/second', null) + : page('a/first', '/api/v1/models?offset=100'), + ); + + await loadReasoningCapabilities([endpoint()], deps); + + expect(fetchSpy.mock.calls[1][0].url).toBe('https://openrouter.ai/api/v1/models?offset=100'); + }); +}); diff --git a/packages/api/src/endpoints/reasoning.ts b/packages/api/src/endpoints/reasoning.ts new file mode 100644 index 00000000000..7e0727ec1f6 --- /dev/null +++ b/packages/api/src/endpoints/reasoning.ts @@ -0,0 +1,511 @@ +import { z } from 'zod'; +import axios from 'axios'; +import crypto from 'crypto'; +import { logger } from '@librechat/data-schemas'; +import { + Time, + CacheKeys, + ReasoningEffort, + EModelEndpoint, + normalizeEndpointName, + getModelReasoning, + effectiveModelReasoning, + isOpenRouterEffortSupported, +} from 'librechat-data-provider'; +import type { TEndpoint, TModelReasoning } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { + ReasoningCapabilityDeps, + ReasoningCapabilityCache, + ReasoningCapabilityResult, +} from '~/types'; +import { isUserProvided, applyAxiosProxyConfig, resolveHeaders } from '~/utils'; +import { usesOpenRouterCatalog } from '~/endpoints/custom/config'; +import { resolveConfigSecret } from '~/admin/secrets'; +import { standardCache } from '~/cache'; + +/** Applies when an endpoint does not set `customParams.reasoningCatalogTimeoutMs`. */ +const DEFAULT_CATALOG_TIMEOUT_MS = 5000; + +/** Applies when an endpoint does not set `customParams.reasoningCatalogFailureTtlMs`. */ +const DEFAULT_CATALOG_FAILURE_TTL_MS = 30000; + +/** Applies when an endpoint does not set `customParams.reasoningCatalogTtlMs`. */ +const DEFAULT_CATALOG_TTL_MS = Time.ONE_HOUR; + +/** Applies when an endpoint does not set `customParams.reasoningCatalogMaxPages`. */ +const DEFAULT_CATALOG_MAX_PAGES = 20; + +const reasoningSchema = z.object({ + supported_efforts: z.array(z.string()).nullable().optional(), + mandatory: z.boolean().optional(), +}); + +const pageSchema = z.object({ + data: z.array(z.object({ id: z.string(), reasoning: z.unknown().optional() })), + /** Absent, `{}`, `{ next: null }` and `{ next: '' }` all end the catalog; anything else that is + * not a string `next` is malformed and makes the whole catalog unreadable. */ + links: z.object({ next: z.string().nullable().optional() }).optional(), +}); + +/** Models whose reasoning depends on the route a request takes, so no fixed efforts exist. */ +const isDynamicRouterModel = (id: string): boolean => id.startsWith('openrouter/'); + +type CatalogModels = Record; + +/** A read catalog and the moment the cache drops it, kept with it so a cached read reports the same. */ +type CatalogEntry = { models: CatalogModels; expiresAt: number }; + +/** + * OpenRouter's `supported_efforts: null` means no allowlist applies and every gateway effort + * is accepted; an omitted field, or an omitted `reasoning` object, means the model exposes no + * effort selection at all, which is recorded as an empty list. A `reasoning` object that does not + * parse leaves the model unknown instead. + */ +const UNRESTRICTED_EFFORTS: string[] = Object.values(ReasoningEffort).filter( + (effort) => effort !== ReasoningEffort.unset, +); + +/** + * Identity of a catalog lookup: the endpoint, the headers it sends and the limits that decide its + * outcome. Two endpoints sharing a base URL and key but configuring different headers or limits + * must not share a result, or the first one's configuration would govern both. + */ +function cacheKey({ + baseURL, + apiKey, + timeoutMs, + maxPages, + failureTtlMs, + ttlMs, + headers, +}: ResolvedTarget): string { + const sortedHeaders = Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)); + const identity = JSON.stringify([ + baseURL, + apiKey, + timeoutMs, + maxPages, + failureTtlMs, + ttlMs, + sortedHeaders, + ]); + return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 32); +} + +/** The scheme and host of a configured URL: never its credentials, path or query. */ +function safeOrigin(baseURL: string): string { + try { + return new URL(baseURL).origin; + } catch { + return 'invalid-url'; + } +} + +/** A header value that is filled in per user or per request, which a shared catalog cannot honor. */ +const PER_REQUEST_PLACEHOLDER = /\{\{[^}]*\}\}/; + +/** Placeholders that carry the request's own ids (`{{LIBRECHAT_BODY_PARENTMESSAGEID}}`), not who is + * asking. They do not change which models a user is offered, so they do not scope the model list; + * the documented OpenRouter example sets one. Any other placeholder (user fields, OpenID tokens, an + * unknown name) is treated as identity-dependent. */ +const REQUEST_METADATA_PLACEHOLDER = /\{\{LIBRECHAT_BODY_[A-Z_]+\}\}/g; + +/** An environment variable reference left in a value, which means the variable is not set. */ +const UNRESOLVED_ENV_VARIABLE = /\$\{[^}]*\}/; + +/** + * The configured headers a catalog request may carry. The catalog is read once with the + * administrator's credentials and shared by every user, so a header that resolves per user or per + * request (`{{LIBRECHAT_USER_EMAIL}}`, an OpenID token) is never forwarded, and neither is one that + * resolves to nothing: an unset environment variable stays a literal `${NAME}` after resolution, + * which a proxy would receive as a credential. Static values and environment + * variables are forwarded, as inference and the model list forward them, because a proxy that + * requires one answers `/models` with an authorization failure otherwise. + */ +function staticCatalogHeaders(headers: Record | undefined): Record { + const candidates = Object.fromEntries( + Object.entries(headers ?? {}).filter(([, value]) => !PER_REQUEST_PLACEHOLDER.test(value)), + ); + const resolved = resolveHeaders({ headers: candidates, stripUnresolved: true }); + return Object.fromEntries( + Object.entries(resolved).filter( + ([, value]) => value !== '' && !UNRESOLVED_ENV_VARIABLE.test(value), + ), + ); +} + +/** + * The headers of one catalog request: the configured ones, plus the API key as a Bearer token + * unless a configured `Authorization` header already carries the credential, as the model list does. + */ +export function catalogRequestHeaders( + apiKey: string, + headers: Record, +): Record { + const hasAuthorization = Object.keys(headers).some( + (key) => key.toLowerCase() === 'authorization', + ); + return hasAuthorization ? { ...headers } : { ...headers, Authorization: `Bearer ${apiKey}` }; +} + +/** The `/models` URL of an API base, with `/models` on the path and any query kept in place. */ +function catalogURL(baseURL: string): string { + const url = new URL(baseURL); + url.pathname = `${url.pathname.replace(/\/+$/, '')}/models`; + return url.toString(); +} + +/** + * Reads every page of a model catalog, following `links.next` on the provider's own host. + * A page that cannot be read, a link that leaves the host or revisits a page, or a catalog + * longer than the endpoint's page limit makes the whole catalog unavailable: serving part of + * it would hide the controls of every model on the pages not read. + */ +async function readCatalog( + target: ResolvedTarget, + deps: ReasoningCapabilityDeps, +): Promise { + const { baseURL, apiKey, timeoutMs, maxPages, headers } = target; + /** Null-prototype, because model ids are unrestricted strings: assigning `__proto__` into an + * ordinary object would set its prototype instead of creating an entry. */ + const models: CatalogModels = Object.create(null); + const origin = new URL(baseURL).origin; + const configuredQuery = new URL(baseURL).searchParams; + const seen = new Set(); + let url: string | null = catalogURL(baseURL); + + while (url != null) { + if (seen.has(url) || seen.size >= maxPages) { + return undefined; + } + seen.add(url); + const parsed = pageSchema.safeParse(await deps.fetchPage({ url, apiKey, timeoutMs, headers })); + if (!parsed.success) { + return undefined; + } + for (const { id, reasoning } of parsed.data.data) { + if (isDynamicRouterModel(id)) { + continue; + } + if (reasoning == null) { + models[id] = { efforts: [] }; + continue; + } + const details = reasoningSchema.safeParse(reasoning); + if (!details.success) { + continue; + } + const reported = details.data.supported_efforts; + const efforts = reported === null ? UNRESTRICTED_EFFORTS : (reported ?? []); + models[id] = + efforts.length === 0 + ? { efforts: [] } + : { efforts, mandatory: details.data.mandatory === true }; + } + const next: string | null | undefined = parsed.data.links?.next; + if (next == null || next === '') { + return models; + } + /** Relative to the page just read, so `?offset=100` and `models?offset=100` keep its path. */ + const nextURL: URL = new URL(next, url); + if (nextURL.origin !== origin) { + return undefined; + } + /** A next link carries pagination, not the query the administrator configured on the base URL + * (a signature, a key): each page needs it, and a parameter the link sets itself wins. */ + for (const [name, value] of configuredQuery) { + if (!nextURL.searchParams.has(name)) { + nextURL.searchParams.append(name, value); + } + } + url = nextURL.toString(); + } + return models; +} + +type ResolvedTarget = { + name: string; + baseURL: string; + apiKey: string; + timeoutMs: number; + maxPages: number; + failureTtlMs: number; + ttlMs: number; + headers: Record; + /** The base names of the models the endpoint exposes, or `undefined` when it fetches every one. */ + exposedModels: Set | undefined; +}; + +/** The part of a model name before a routing variant such as `:nitro` or `:free`. */ +const baseModelName = (model: string): string => model.split(':')[0]; + +/** + * The models an endpoint exposes to its users. An endpoint that fetches its model list exposes + * every catalog model; one that does not exposes only its configured models, and the capabilities + * route must not disclose the rest of what a proxy's catalog lists to its users. + */ +function exposedModelNames(endpoint: TEndpoint): Set | undefined { + if (endpoint.models?.fetch === true) { + return undefined; + } + const names = new Set(); + for (const model of endpoint.models?.default ?? []) { + const configured = typeof model === 'string' ? model : model.name; + names.add(configured); + /** A variant (`vendor/model:free`) is catalogued under its base, so the base entry is exposed as + * a fallback for it. Sibling variants (`vendor/model:nitro`) are not: they are not configured. */ + names.add(baseModelName(configured)); + } + return names; +} + +/** The catalog's models narrowed to those the endpoint exposes. */ +function exposedCapabilities( + models: CatalogModels, + exposed: Set | undefined, +): CatalogModels { + if (exposed == null) { + return models; + } + return Object.fromEntries(Object.entries(models).filter(([id]) => exposed.has(id))); +} + +/** + * Whether this endpoint's requests depend on who is asking: its model list is fetched with + * `models.userIdQuery`, or a configured header resolves to the user's identity or credentials. The + * shared catalog is read with the administrator's key and no user, so for such an endpoint it could + * be refused, or describe a different user's models, whether or not the list is fetched. Headers + * that only carry request ids do not count. + */ +function hasUserScopedModelList(endpoint: TEndpoint): boolean { + if (endpoint.models?.userIdQuery === true) { + return true; + } + return Object.values(endpoint.headers ?? {}).some((value) => + PER_REQUEST_PLACEHOLDER.test(value.replace(REQUEST_METADATA_PLACEHOLDER, '')), + ); +} + +/** + * The endpoint's catalog target, or nothing when it does not use the OpenRouter catalog (see + * {@link usesOpenRouterCatalog}) or has no administrator credentials. A `directEndpoint` is skipped: + * its base URL is the exact inference URL, so `/models` cannot be derived from it. So is an endpoint + * that pins its model through `addParams.model`: every request goes to that one model, so the efforts + * of the model a user selected say nothing about what is sent, and narrowing by them would be wrong. + * And so is an endpoint whose model list is filtered per user, by `models.userIdQuery` or by headers + * that resolve per request: this catalog is read once with the administrator's key and shared, so it + * could expose models the requesting user is not offered. + */ +function resolveTarget(endpoint: TEndpoint): ResolvedTarget | undefined { + if ( + endpoint.directEndpoint === true || + typeof endpoint.addParams?.model === 'string' || + hasUserScopedModelList(endpoint) + ) { + return undefined; + } + const name = normalizeEndpointName(endpoint.name); + const baseURL = resolveConfigSecret(endpoint.baseURL) ?? ''; + const apiKey = resolveConfigSecret(endpoint.apiKey) ?? ''; + if ( + !name || + isUserProvided(baseURL) || + isUserProvided(apiKey) || + !usesOpenRouterCatalog(endpoint, baseURL) + ) { + return undefined; + } + return { + name, + baseURL, + apiKey, + timeoutMs: endpoint.customParams?.reasoningCatalogTimeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS, + maxPages: endpoint.customParams?.reasoningCatalogMaxPages ?? DEFAULT_CATALOG_MAX_PAGES, + failureTtlMs: + endpoint.customParams?.reasoningCatalogFailureTtlMs ?? DEFAULT_CATALOG_FAILURE_TTL_MS, + ttlMs: endpoint.customParams?.reasoningCatalogTtlMs ?? DEFAULT_CATALOG_TTL_MS, + headers: staticCatalogHeaders(endpoint.headers), + exposedModels: exposedModelNames(endpoint), + }; +} + +/** Where a recent failed lookup is remembered, beside the entry a successful one fills. */ +const failureKey = (key: string): string => `${key}:failed`; + +/** + * Lookups in progress, by base URL and key. Held at module scope so concurrent requests, + * from any caller and any user, share one catalog walk instead of each issuing their own + * when the cache is cold or has just expired. An entry is removed once it settles; a failure is + * then remembered for `reasoningCatalogFailureTtlMs` so it is retried after that, not per call. + */ +const inFlight = new Map>(); + +/** One cached, shared lookup per base URL and key; `undefined` when the catalog is unavailable. */ +function resolveCatalog( + target: ResolvedTarget, + deps: ReasoningCapabilityDeps, +): Promise { + const key = cacheKey(target); + const existing = inFlight.get(key); + if (existing != null) { + return existing; + } + const lookup = (async () => { + const cached = (await deps.cache.get(key)) as CatalogEntry | undefined; + if (cached != null && cached.expiresAt > Date.now()) { + return cached; + } + /** A recent failure is not retried: the middleware and the endpoint initializer of one + * request, and the requests that follow it, would otherwise each wait out the timeout. */ + if (target.failureTtlMs > 0 && (await deps.cache.get(failureKey(key))) != null) { + return undefined; + } + let models: CatalogModels | undefined; + try { + models = await readCatalog(target, deps); + } catch (error) { + logger.warn('[reasoning] Failed to load the OpenRouter model catalog', { + origin: safeOrigin(target.baseURL), + error: error instanceof Error ? error.name : 'unknown', + }); + } + if (models != null) { + const entry: CatalogEntry = { models, expiresAt: Date.now() + target.ttlMs }; + await deps.cache.set(key, entry, target.ttlMs); + return entry; + } + if (target.failureTtlMs > 0) { + await deps.cache.set(failureKey(key), true, target.failureTtlMs); + } + return undefined; + })().finally(() => inFlight.delete(key)); + inFlight.set(key, lookup); + return lookup; +} + +/** + * Resolves which reasoning efforts each OpenRouter model accepts, for the + * endpoints configured against OpenRouter. The catalog is fetched with the + * administrator's key, never a user's, and is the same for every caller, so it + * is cached by base URL and key and shared across users. A catalog that cannot + * be fetched or read is reported in `unavailable`, never as an endpoint without + * reasoning, and is not cached, so the next request retries. Each endpoint's result holds only the + * models it exposes. `only` limits the result to one endpoint, so one endpoint's outage does not hide + * another's data. `expiresAt` is when the earliest catalog read expires. + */ +export async function loadReasoningCapabilities( + customEndpoints: TEndpoint[] | undefined, + deps: ReasoningCapabilityDeps, + only?: string, +): Promise { + const wanted = only == null ? undefined : normalizeEndpointName(only); + const targets = (customEndpoints ?? []).flatMap((endpoint) => { + const target = resolveTarget(endpoint); + return target != null && (wanted == null || target.name === wanted) ? [target] : []; + }); + const catalogs = await Promise.all(targets.map((target) => resolveCatalog(target, deps))); + + const result: ReasoningCapabilityResult = { + capabilities: Object.create(null) as ReasoningCapabilityResult['capabilities'], + unavailable: [], + }; + targets.forEach(({ name, exposedModels }, index) => { + const entry = catalogs[index]; + if (entry == null) { + result.unavailable.push(name); + return; + } + result.capabilities[name] = exposedCapabilities(entry.models, exposedModels); + result.expiresAt = Math.min(result.expiresAt ?? entry.expiresAt, entry.expiresAt); + }); + return result; +} + +/** + * Drops a stored `reasoning_effort` the selected OpenRouter model does not accept, so a + * conversation or agent saved on another model cannot send a request the provider rejects. + * Auto sends no effort and is kept, as is everything while the catalog is unavailable, and + * an effort the administrator defined for the endpoint, including one set through + * `addParams`, which replaces the stored value outright. An endpoint that pins its model + * through `addParams.model` is not checked (see {@link resolveTarget}). Only a request that + * stores an effort on an eligible OpenRouter endpoint reads the catalog. + */ +export async function withSupportedEffort( + modelOptions: T, + endpoint: TEndpoint, + deps: ReasoningCapabilityDeps, +): Promise { + const { model, reasoning_effort: effort } = modelOptions as { + model?: unknown; + reasoning_effort?: unknown; + }; + if ( + typeof effort !== 'string' || + effort === '' || + typeof model !== 'string' || + endpoint.addParams?.reasoning_effort !== undefined + ) { + return modelOptions; + } + const target = resolveTarget(endpoint); + if (target == null) { + return modelOptions; + } + const entry = await resolveCatalog(target, deps); + const modelReasoning = effectiveModelReasoning( + getModelReasoning( + entry == null ? undefined : { [target.name]: entry.models }, + target.name, + model, + ), + endpoint.customParams?.paramDefinitions, + ); + if (isOpenRouterEffortSupported(effort, modelReasoning)) { + return modelOptions; + } + const rest = { ...modelOptions } as T & { reasoning_effort?: unknown }; + delete rest.reasoning_effort; + return rest; +} + +const reasoningCache = (): ReasoningCapabilityCache => + standardCache(CacheKeys.REASONING_CAPABILITIES, Time.ONE_HOUR); + +async function fetchPage({ + url, + apiKey, + timeoutMs, + headers, +}: { + url: string; + apiKey: string; + timeoutMs: number; + headers: Record; +}): Promise { + const options = applyAxiosProxyConfig( + { headers: catalogRequestHeaders(apiKey, headers), timeout: timeoutMs }, + url, + ); + return (await axios.get(url, options)).data; +} + +/** The dependencies the server uses; callers in `/api` and the endpoint initializers share them. */ +export function getReasoningCapabilityDeps(): ReasoningCapabilityDeps { + return { fetchPage, cache: reasoningCache() }; +} + +/** + * Per-model reasoning efforts for the OpenRouter endpoints in an app config, or for the one + * named by `endpoint`, wired to the real catalog fetch and cache. + */ +export function getReasoningCapabilities( + appConfig?: AppConfig, + endpoint?: string, +): Promise { + return loadReasoningCapabilities( + (appConfig?.endpoints?.[EModelEndpoint.custom] ?? []) as TEndpoint[], + getReasoningCapabilityDeps(), + endpoint, + ); +} diff --git a/packages/api/src/modelSpecs/reasoningOverride.spec.ts b/packages/api/src/modelSpecs/reasoningOverride.spec.ts index 78bb7197a71..afc2b2e56e6 100644 --- a/packages/api/src/modelSpecs/reasoningOverride.spec.ts +++ b/packages/api/src/modelSpecs/reasoningOverride.spec.ts @@ -5,6 +5,7 @@ import { ReasoningParameterFormat, ThinkingLevel, type TReasoningOverride, + type TReasoningCapabilityMap, } from 'librechat-data-provider'; import { parseReasoningOverrideRequest, @@ -525,3 +526,268 @@ describe('applyRequestReasoningOverride', () => { }); }); }); + +describe('resolveReasoningOverride: OpenRouter per-model efforts', () => { + const model = 'openai/gpt-6.1-sol'; + const openRouterInput = ( + value: ReasoningEffort, + capabilities: TReasoningCapabilityMap, + ): Partial => ({ + reasoningOverride: { key: 'reasoning_effort', value }, + endpoint: 'OpenRouter', + endpointType: EModelEndpoint.custom, + isAgent: false, + /** The base fixture's loaded agent would otherwise replace this target. */ + endpointOption: { model_parameters: { model }, agent: null }, + endpointsConfig: { + OpenRouter: { + order: 0, + customParams: { + defaultParamsEndpoint: 'openrouter', + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + }, + }, + }, + reasoningCapabilities: capabilities, + }); + + it('accepts an effort the model supports', async () => { + const result = await resolve( + openRouterInput(ReasoningEffort.low, { + OpenRouter: { [model]: { efforts: ['low', 'high'] } }, + }), + ); + + expect(result.ok).toBe(true); + }); + + it('rejects an effort the model does not support', async () => { + const result = await resolve( + openRouterInput(ReasoningEffort.max, { + OpenRouter: { [model]: { efforts: ['low', 'high'] } }, + }), + ); + + expect(result).toEqual({ ok: false, reason: 'invalid-reasoning-override' }); + }); + + it('rejects none for a model whose reasoning is mandatory', async () => { + const result = await resolve( + openRouterInput(ReasoningEffort.none, { + OpenRouter: { [model]: { efforts: ['none', 'low'], mandatory: true } }, + }), + ); + + expect(result).toEqual({ ok: false, reason: 'invalid-reasoning-override' }); + }); + + it('rejects an effort for a model the provider lists without reasoning', async () => { + const result = await resolve( + openRouterInput(ReasoningEffort.low, { OpenRouter: { [model]: { efforts: [] } } }), + ); + + expect(result).toEqual({ ok: false, reason: 'invalid-reasoning-override' }); + }); + + it('accepts Auto for a model the provider lists without reasoning, so a stale effort can be cleared', async () => { + const result = await resolve( + openRouterInput(ReasoningEffort.unset, { OpenRouter: { [model]: { efforts: [] } } }), + ); + + expect(result.ok).toBe(true); + }); + + it('accepts an effort for a model the catalog does not list, such as an alias', async () => { + const result = await resolve( + openRouterInput(ReasoningEffort.max, { OpenRouter: { 'meta/other': { efforts: ['low'] } } }), + ); + + expect(result.ok).toBe(true); + }); + + it('keeps the generic list while the endpoint capabilities are unknown', async () => { + const result = await resolve(openRouterInput(ReasoningEffort.max, {})); + + expect(result.ok).toBe(true); + }); +}); + +describe('applyRequestReasoningOverride: capability loading', () => { + const makeReq = (model = 'openai/gpt-6.1-sol') => ({ + body: { endpointOption: { model_parameters: { model }, agent: null } }, + }); + const openRouter = { + endpoint: 'OpenRouter', + endpointType: EModelEndpoint.custom, + isAgent: false, + endpointsConfig: { + OpenRouter: { + order: 0, + customParams: { + defaultParamsEndpoint: 'openrouter', + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + }, + }, + }, + }; + const effort = { key: 'reasoning_effort', value: ReasoningEffort.high }; + + it('does not load capabilities for a request without an override', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + loadReasoningCapabilities: load, + }); + + expect(load).not.toHaveBeenCalled(); + }); + + it('loads capabilities once for an OpenRouter effort override', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + reasoningOverride: effort, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(true); + expect(load).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledWith('OpenRouter'); + }); + + it('does not load capabilities when the administrator defined reasoning_effort', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + const explicit = { + ...openRouter, + endpointsConfig: { + OpenRouter: { + order: 0, + customParams: { + defaultParamsEndpoint: 'openrouter', + paramDefinitions: [{ key: 'reasoning_effort' }], + }, + }, + }, + }; + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...explicit, + reasoningOverride: effort, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(true); + expect(load).not.toHaveBeenCalled(); + }); + + it('does not load capabilities for an Auto override, which sends no effort', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + reasoningOverride: { key: 'reasoning_effort', value: ReasoningEffort.unset }, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(true); + expect(load).not.toHaveBeenCalled(); + }); + + it('accepts Auto while the catalog is unavailable', async () => { + const load = jest.fn(async () => { + throw new Error('upstream down'); + }); + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + reasoningOverride: { key: 'reasoning_effort', value: ReasoningEffort.unset }, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(true); + }); + + it('does not load capabilities when the target is not OpenRouter', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + const applied = await applyRequestReasoningOverride(makeReq('gpt-5.1'), { + endpoint: EModelEndpoint.openAI, + isAgent: false, + endpointsConfig: {}, + reasoningOverride: effort, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(true); + expect(load).not.toHaveBeenCalled(); + }); + + it('rejects a disabled endpoint without loading the catalog', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + const disabled = { + ...openRouter, + endpointsConfig: { + OpenRouter: { + order: 0, + customParams: { + defaultParamsEndpoint: 'openrouter', + reasoningFormat: ReasoningParameterFormat.disabled, + }, + }, + }, + }; + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...disabled, + reasoningOverride: effort, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(false); + expect(load).not.toHaveBeenCalled(); + }); + + it.each(['appliedModelSpecPrivateFields', 'enforcedModelSpecFields'])( + 'rejects an effort a model spec locks through %s without loading the catalog', + async (field) => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + [field]: new Set(['reasoning_effort']), + reasoningOverride: effort, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(false); + expect(load).not.toHaveBeenCalled(); + }, + ); + + it('does not load capabilities for an override that is not an effort', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + reasoningOverride: { key: 'thinkingBudget', value: 2048 }, + loadReasoningCapabilities: load, + }); + + expect(load).not.toHaveBeenCalled(); + }); + + it('does not load capabilities for a malformed override', async () => { + const load = jest.fn(async () => ({ capabilities: {}, unavailable: [] as string[] })); + + const applied = await applyRequestReasoningOverride(makeReq(), { + ...openRouter, + reasoningOverride: { key: 'nope', value: 1 }, + loadReasoningCapabilities: load, + }); + + expect(applied).toBe(false); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/modelSpecs/reasoningOverride.ts b/packages/api/src/modelSpecs/reasoningOverride.ts index 49a75606e02..4781a5a2f5c 100644 --- a/packages/api/src/modelSpecs/reasoningOverride.ts +++ b/packages/api/src/modelSpecs/reasoningOverride.ts @@ -1,12 +1,19 @@ import { + Providers, + getModelReasoning, + effectiveModelReasoning, + hasExplicitReasoningEffort, + ReasoningEffort, isReasoningOverrideSupported, reasoningOverrideSchema, ReasoningParameterFormat, resolveReasoningSettingForTarget, type TEndpointsConfig, type TReasoningOverride, + type TReasoningCapabilityMap, } from 'librechat-data-provider'; import type { AgentContinuationAdmissionSource } from '~/agents/triggers/host'; +import type { ReasoningCapabilityResult } from '~/types'; export type ReasoningOverrideRequest = | { ok: true; reasoningOverride?: TReasoningOverride } @@ -56,6 +63,8 @@ export type ReasoningOverrideInput = { parsedModel?: string | null; isAgent: boolean; endpointsConfig?: TEndpointsConfig; + /** Provider-reported efforts per OpenRouter model; absent while unknown. */ + reasoningCapabilities?: TReasoningCapabilityMap; defaultParamsEndpoint?: string | null; appliedModelSpecPrivateFields?: ReadonlySet; enforcedModelSpecFields?: ReadonlySet; @@ -87,6 +96,7 @@ export async function resolveReasoningOverride({ parsedModel, isAgent, endpointsConfig, + reasoningCapabilities, defaultParamsEndpoint, appliedModelSpecPrivateFields = new Set(), enforcedModelSpecFields = new Set(), @@ -111,6 +121,21 @@ export async function resolveReasoningOverride({ return { ok: false, reason: 'invalid-reasoning-override' }; } + const modelReasoning = + effectiveModel == null + ? undefined + : effectiveModelReasoning( + getModelReasoning(reasoningCapabilities, customEndpointKey, effectiveModel), + customParams?.paramDefinitions, + ); + /** Auto sends no effort. For a model the provider lists without effort selection it is the one + * valid override: it lets a caller clear an effort saved on another model. The disabled and + * locked refusals above have already run. */ + const clearsEffort = + modelReasoning === null && + reasoningOverride.key === 'reasoning_effort' && + reasoningOverride.value === ReasoningEffort.unset; + const supportedSetting = resolveReasoningSettingForTarget({ endpoint: effectiveEndpoint, model: effectiveModel, @@ -119,9 +144,10 @@ export async function resolveReasoningOverride({ paramDefinitions: customParams?.paramDefinitions, reasoningFormat: customParams?.reasoningFormat, blockedReasoningKeys: new Set([...appliedModelSpecPrivateFields, ...enforcedModelSpecFields]), + modelReasoning, }); - if (!isReasoningOverrideSupported(reasoningOverride, supportedSetting)) { + if (!clearsEffort && !isReasoningOverrideSupported(reasoningOverride, supportedSetting)) { return { ok: false, reason: 'invalid-reasoning-override' }; } @@ -153,10 +179,53 @@ export async function resolveReasoningOverride({ }; } +/** + * The provider's per-model efforts, loaded only when the override is a non-Auto effort on an + * OpenRouter endpoint: any other target is validated without the catalog, so an + * unavailable OpenRouter never delays an unrelated request. + */ +async function loadCapabilitiesFor( + override: TReasoningOverride, + endpointOption: EndpointOption, + input: Omit, + load?: (endpoint: string) => Promise, +): Promise { + /** Auto sends no effort, so no catalog entry can change whether it is accepted. */ + if ( + load == null || + override.key !== 'reasoning_effort' || + override.value === ReasoningEffort.unset + ) { + return undefined; + } + const loadedAgent = await endpointOption.agent; + const endpointKey = input.isAgent + ? (loadedAgent?.provider ?? endpointOption.endpointType ?? input.endpointType ?? input.endpoint) + : input.endpoint; + const customParams = input.endpointsConfig?.[endpointKey]?.customParams; + const paramsEndpoint = customParams?.defaultParamsEndpoint ?? input.defaultParamsEndpoint; + /** Requests `resolveReasoningOverride` refuses regardless of the catalog fail without it. */ + const locked = + input.appliedModelSpecPrivateFields?.has(override.key) === true || + input.enforcedModelSpecFields?.has(override.key) === true; + if ( + locked || + customParams?.reasoningFormat === ReasoningParameterFormat.disabled || + hasExplicitReasoningEffort(customParams?.paramDefinitions) + ) { + return undefined; + } + return paramsEndpoint === Providers.OPENROUTER + ? (await load(endpointKey)).capabilities + : undefined; +} + export type RequestReasoningOverrideInput = Omit< ReasoningOverrideInput, - 'reasoningOverride' | 'endpointOption' | 'reasoningOverrideBase' + 'reasoningOverride' | 'endpointOption' | 'reasoningOverrideBase' | 'reasoningCapabilities' > & { + /** Loads per-model efforts; called only for a request that carries an override. */ + loadReasoningCapabilities?: (endpoint: string) => Promise; /** The raw request field; validated here, so the caller passes it unparsed. */ reasoningOverride?: unknown; }; @@ -185,7 +254,7 @@ export async function applyRequestReasoningOverride( agentContinuationAdmission?: AgentContinuationAdmissionSource; }; }, - { reasoningOverride: raw, ...input }: RequestReasoningOverrideInput, + { reasoningOverride: raw, loadReasoningCapabilities, ...input }: RequestReasoningOverrideInput, ): Promise { const stripReplayedOverride = (): boolean => { delete req.body.reasoningOverride; @@ -231,6 +300,12 @@ export async function applyRequestReasoningOverride( } const resolution = await resolveReasoningOverride({ ...input, + reasoningCapabilities: await loadCapabilitiesFor( + request.reasoningOverride, + req.body.endpointOption, + input, + loadReasoningCapabilities, + ), reasoningOverride: request.reasoningOverride, endpointOption: req.body.endpointOption, reasoningOverrideBase: req.reasoningOverrideBase, diff --git a/packages/api/src/types/endpoints.ts b/packages/api/src/types/endpoints.ts index 88e54791c9a..d5a67e0b008 100644 --- a/packages/api/src/types/endpoints.ts +++ b/packages/api/src/types/endpoints.ts @@ -1,6 +1,7 @@ import type { ClientOptions, OpenAIClientOptions } from '@librechat/agents'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type { TConfig } from 'librechat-data-provider'; +import type { ReasoningCapabilityDeps } from './reasoning'; import type { RequestBody, ServerRequest } from './http'; import type { EndpointTokenConfig } from './tokens'; import type { AzureOptions } from './azure'; @@ -56,6 +57,11 @@ interface InitializeParamsBase { model_parameters?: Record; /** Database methods for user key operations */ db: EndpointDbMethods; + /** + * Catalog client and cache for checking a stored reasoning effort against the selected + * OpenRouter model. Omitted, no check is made and the stored effort is sent as saved. + */ + reasoningCapabilityDeps?: ReasoningCapabilityDeps; } /** Request-backed compatibility contract retained for existing endpoint callers. */ diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 62267a3b536..70793f4d6cc 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -11,6 +11,7 @@ export * from './http'; export * from './mistral'; export type * from './openai'; export * from './prompts'; +export * from './reasoning'; export * from './run'; export * from './tokens'; export * from './stream'; diff --git a/packages/api/src/types/reasoning.ts b/packages/api/src/types/reasoning.ts new file mode 100644 index 00000000000..af09843fc38 --- /dev/null +++ b/packages/api/src/types/reasoning.ts @@ -0,0 +1,33 @@ +import type { TReasoningCapabilityMap } from 'librechat-data-provider'; + +/** The slice of a keyed cache the reasoning capability lookup needs. */ +export interface ReasoningCapabilityCache { + get: (key: string) => Promise; + set: (key: string, value: unknown, ttl?: number) => Promise; +} + +/** What a caller supplies so the lookup has no hard-wired client or cache. */ +export interface ReasoningCapabilityDeps { + /** Returns one page of the provider's model catalog as parsed JSON. */ + fetchPage: (params: { + url: string; + apiKey: string; + timeoutMs: number; + /** Static configured headers; the caller adds the API key as a Bearer token. */ + headers: Record; + }) => Promise; + cache: ReasoningCapabilityCache; +} + +/** + * What was read and what could not be: an endpoint in `unavailable` is eligible but its + * catalog failed, which callers must not confuse with an endpoint whose models report no + * reasoning. Endpoints that were never eligible (not OpenRouter, user-provided, direct, + * pinned to one model) appear in neither. + */ +export interface ReasoningCapabilityResult { + capabilities: TReasoningCapabilityMap; + unavailable: string[]; + /** Epoch milliseconds at which the earliest catalog behind `capabilities` expires. */ + expiresAt?: number; +} diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index ea04d70335f..799feb2a75b 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -383,6 +383,84 @@ describe('endpointSchema addParams validation', () => { expect(result.success).toBe(false); }); + it('accepts a reasoning catalog timeout within bounds', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogTimeoutMs: 8000 }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customParams?.reasoningCatalogTimeoutMs).toBe(8000); + } + }); + + it.each([0, 100, 60000, 1.5])('rejects reasoning catalog timeout %s', (value) => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogTimeoutMs: value }, + }); + + expect(result.success).toBe(false); + }); + + it('accepts a reasoning catalog page limit within bounds', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogMaxPages: 50 }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customParams?.reasoningCatalogMaxPages).toBe(50); + } + }); + + it.each([0, 201, 1.5])('rejects reasoning catalog page limit %s', (value) => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogMaxPages: value }, + }); + + expect(result.success).toBe(false); + }); + + it.each([0, 30000, 600000])('accepts a reasoning catalog failure window of %s', (value) => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogFailureTtlMs: value }, + }); + + expect(result.success).toBe(true); + }); + + it.each([-1, 600001, 1.5])('rejects reasoning catalog failure window %s', (value) => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogFailureTtlMs: value }, + }); + + expect(result.success).toBe(false); + }); + + it.each([60000, 3600000, 86400000])('accepts a reasoning catalog lifetime of %s', (value) => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogTtlMs: value }, + }); + + expect(result.success).toBe(true); + }); + + it.each([0, 59999, 86400001, 1.5])('rejects reasoning catalog lifetime %s', (value) => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customParams: { reasoningCatalogTtlMs: value }, + }); + + expect(result.success).toBe(false); + }); + it('accepts custom reasoning response key config', () => { const result = endpointSchema.safeParse({ ...validEndpoint, diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 985fcf075eb..d98d43370dc 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -227,6 +227,9 @@ export const aiEndpoints = () => `${BASE_URL}/api/endpoints`; export const tokenConfig = () => `${BASE_URL}/api/endpoints/token-config`; +export const reasoningCapabilities = (endpoint: string) => + `${BASE_URL}/api/endpoints/reasoning-capabilities?endpoint=${encodeURIComponent(endpoint)}`; + export const models = () => `${BASE_URL}/api/models`; export const tokenizer = () => `${BASE_URL}/api/tokenizer`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index bd3befd1a18..63d859210bd 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2071,6 +2071,26 @@ export const endpointSchema = baseEndpointSchema.merge( /** Also reconstructs `reasoning_content` from persisted history across turns (implies `includeReasoningContent`). */ includeReasoningHistory: z.boolean().optional(), paramDefinitions: z.array(paramDefinitionSchema).optional(), + /** + * Milliseconds to wait for each request when reading an OpenRouter endpoint's + * per-model reasoning efforts. Omission keeps 5000. + */ + reasoningCatalogTimeoutMs: z.number().int().min(500).max(30000).optional(), + /** + * Most pages of an OpenRouter endpoint's model catalog to read before treating it as + * unavailable. Omission keeps 20. + */ + reasoningCatalogMaxPages: z.number().int().min(1).max(200).optional(), + /** + * Milliseconds a failed or unreadable OpenRouter catalog is remembered before it is + * requested again; 0 retries on every request. Omission keeps 30000. + */ + reasoningCatalogFailureTtlMs: z.number().int().min(0).max(600000).optional(), + /** + * Milliseconds a successfully read OpenRouter catalog is kept before it is read again. + * Omission keeps 3600000 (one hour). + */ + reasoningCatalogTtlMs: z.number().int().min(60000).max(86400000).optional(), }) .strict() .optional(), @@ -4607,6 +4627,10 @@ export enum CacheKeys { * Key for accessing the model token config cache. */ TOKEN_CONFIG = 'TOKEN_CONFIG', + /** + * Key for the per-model reasoning effort cache of OpenRouter endpoints. + */ + REASONING_CAPABILITIES = 'REASONING_CAPABILITIES', /** * Key for the app config namespace. */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 72687aef8dd..12ccf44b8df 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -418,6 +418,12 @@ export const getTokenConfig = (): Promise => { return request.get(endpoints.tokenConfig()); }; +export const getReasoningCapabilities = ( + endpoint: string, +): Promise => { + return request.get(endpoints.reasoningCapabilities(endpoint)); +}; + export const getModels = async (): Promise => { return request.get(endpoints.models()); }; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 93d3d439199..d950178f53e 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -24,6 +24,7 @@ export enum QueryKeys { balance = 'balance', endpoints = 'endpoints', tokenConfig = 'tokenConfig', + reasoningCapabilities = 'reasoningCapabilities', presets = 'presets', searchResults = 'searchResults', tokenCount = 'tokenCount', diff --git a/packages/data-provider/src/parameterSettings.spec.ts b/packages/data-provider/src/parameterSettings.spec.ts index 6fdfe847796..f928c3bd50f 100644 --- a/packages/data-provider/src/parameterSettings.spec.ts +++ b/packages/data-provider/src/parameterSettings.spec.ts @@ -1,3 +1,4 @@ +import type { TModelReasoning, TReasoningCapabilityMap } from './types'; import type { SettingDefinition } from './generate'; import { paramSettings, @@ -5,6 +6,11 @@ import { resolveReasoningSettingForTarget, isReasoningOverrideSupported, applyModelAwareDefaults, + getModelReasoning, + effectiveModelReasoning, + hasExplicitReasoningEffort, + resolveModelReasoning, + isOpenRouterEffortSupported, resolveDropParamsUIKeys, } from './parameterSettings'; import { BedrockProviders, EModelEndpoint, Providers } from './types'; @@ -498,3 +504,247 @@ describe('resolveDropParamsUIKeys', () => { expect(resolveDropParamsUIKeys([], EModelEndpoint.openAI)).toEqual(new Set()); }); }); + +describe('per-model OpenRouter reasoning efforts', () => { + const openRouterSettings = paramSettings[Providers.OPENROUTER] as SettingDefinition[]; + const model = 'openai/gpt-6.1-sol'; + const effortOptions = ( + modelReasoning?: TModelReasoning | null, + endpoint: string = Providers.OPENROUTER, + forModel: string = model, + ) => + applyModelAwareDefaults(openRouterSettings, endpoint, forModel, undefined, modelReasoning).find( + (setting) => setting.key === 'reasoning_effort', + )?.options; + const target = (modelReasoning?: TModelReasoning | null) => + resolveReasoningSettingForTarget({ + endpoint: EModelEndpoint.custom, + model, + defaultParamsEndpoint: Providers.OPENROUTER, + reasoningFormat: ReasoningParameterFormat.reasoningEffort, + modelReasoning, + }); + + it('keeps the generic list while capabilities are still unknown', () => { + expect(effortOptions(undefined)).toContain(ReasoningEffort.max); + expect(target(undefined)?.options).toContain(ReasoningEffort.none); + }); + + it('offers only the efforts the model supports, plus Auto', () => { + expect(effortOptions({ efforts: ['low', 'high'] })).toEqual([ + ReasoningEffort.unset, + ReasoningEffort.low, + ReasoningEffort.high, + ]); + }); + + it('drops none for a model whose reasoning is mandatory', () => { + expect(effortOptions({ efforts: ['none', 'low', 'high'], mandatory: true })).toEqual([ + ReasoningEffort.unset, + ReasoningEffort.low, + ReasoningEffort.high, + ]); + }); + + it('keeps none when reasoning can be turned off', () => { + expect(effortOptions({ efforts: ['none', 'low'] })).toContain(ReasoningEffort.none); + }); + + it('hides the setting for a model without reasoning metadata', () => { + expect(effortOptions(null)).toBeUndefined(); + }); + + it('hides the setting when no supported effort is a known level', () => { + expect(effortOptions({ efforts: ['ultra'] })).toBeUndefined(); + }); + + it('does not narrow an endpoint that is not OpenRouter', () => { + expect(effortOptions({ efforts: ['low'] }, EModelEndpoint.openAI)).toContain( + ReasoningEffort.max, + ); + }); + + it('keeps a model family list inside the efforts the provider reports', () => { + expect( + effortOptions({ efforts: ['low', 'max'] }, Providers.OPENROUTER, 'x-ai/grok-4.7'), + ).toEqual([ReasoningEffort.unset, ReasoningEffort.low]); + }); + + it('keeps an administrator-defined reasoning_effort when the model reports no reasoning', () => { + const setting = resolveReasoningSettingForTarget({ + endpoint: EModelEndpoint.custom, + model, + defaultParamsEndpoint: Providers.OPENROUTER, + paramDefinitions: [{ key: 'reasoning_effort', options: ['low', 'max'] }], + modelReasoning: null, + }); + + expect(setting?.options).toEqual(['low', 'max']); + }); + + it('does not narrow an administrator-defined reasoning_effort', () => { + const setting = resolveReasoningSettingForTarget({ + endpoint: EModelEndpoint.custom, + model, + defaultParamsEndpoint: Providers.OPENROUTER, + paramDefinitions: [{ key: 'reasoning_effort', options: ['low', 'max'] }], + modelReasoning: { efforts: ['high'] }, + }); + + expect(setting?.options).toEqual(['low', 'max']); + }); + + it('narrows the composer setting for an OpenRouter custom endpoint', () => { + expect(target({ efforts: ['low', 'high'] })?.options).toEqual([ + ReasoningEffort.unset, + ReasoningEffort.low, + ReasoningEffort.high, + ]); + }); + + it('hides the composer setting for a model without reasoning metadata', () => { + expect(target(null)).toBeUndefined(); + }); + + it('leaves an explicit reasoning definition untouched', () => { + expect( + resolveReasoningSettingForTarget({ + endpoint: EModelEndpoint.custom, + model, + defaultParamsEndpoint: Providers.OPENROUTER, + paramDefinitions: [{ key: 'reasoning_effort', options: ['low', 'max'] }], + modelReasoning: null, + })?.options, + ).toEqual(['low', 'max']); + }); +}); + +describe('getModelReasoning', () => { + const capabilities: TReasoningCapabilityMap = { + OpenRouter: { + 'openai/gpt-6.1-sol': { efforts: ['low', 'high'] }, + 'meta/plain': { efforts: [] }, + }, + }; + const lookup = (model: string) => getModelReasoning(capabilities, 'OpenRouter', model); + + it('is unknown when the endpoint was never resolved', () => { + expect(getModelReasoning(undefined, 'OpenRouter', 'openai/gpt-6.1-sol')).toBeUndefined(); + expect(getModelReasoning(capabilities, 'Other', 'openai/gpt-6.1-sol')).toBeUndefined(); + }); + + it('returns the efforts of a listed model', () => { + expect(lookup('openai/gpt-6.1-sol')).toEqual({ efforts: ['low', 'high'] }); + }); + + it('matches a model variant to its base model', () => { + expect(lookup('openai/gpt-6.1-sol:nitro')).toEqual({ efforts: ['low', 'high'] }); + }); + + it('reports no reasoning for a model the provider lists without effort selection', () => { + expect(lookup('meta/plain')).toBeNull(); + expect(lookup('meta/plain:free')).toBeNull(); + }); + + it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf'])( + 'keeps a model id that names an Object.prototype member unknown: %s', + (model) => { + expect(lookup(model)).toBeUndefined(); + expect(lookup(`${model}:free`)).toBeUndefined(); + }, + ); + + it('keeps an endpoint named after an Object.prototype member unknown', () => { + expect(getModelReasoning(capabilities, 'constructor', 'openai/gpt-6.1-sol')).toBeUndefined(); + expect(getModelReasoning(capabilities, '__proto__', 'openai/gpt-6.1-sol')).toBeUndefined(); + }); + + it('still finds a listed model that happens to be named like a prototype member', () => { + const named = { OpenRouter: { constructor: { efforts: ['low'] } } }; + + expect(getModelReasoning(named, 'OpenRouter', 'constructor')).toEqual({ efforts: ['low'] }); + }); + + it('keeps a model the provider does not list unknown, such as an alias', () => { + expect(lookup('meta/unlisted')).toBeUndefined(); + expect(lookup('~openai/gpt-latest')).toBeUndefined(); + }); +}); + +describe('isOpenRouterEffortSupported', () => { + it('accepts anything while the model capabilities are unknown', () => { + expect(isOpenRouterEffortSupported('max', undefined)).toBe(true); + }); + + it('always accepts Auto, which sends no effort', () => { + expect(isOpenRouterEffortSupported('', null)).toBe(true); + expect(isOpenRouterEffortSupported(ReasoningEffort.unset, { efforts: ['low'] })).toBe(true); + }); + + it('accepts a reported effort and rejects an unreported one', () => { + expect(isOpenRouterEffortSupported('low', { efforts: ['low', 'high'] })).toBe(true); + expect(isOpenRouterEffortSupported('max', { efforts: ['low', 'high'] })).toBe(false); + }); + + it('rejects none for a model whose reasoning is mandatory', () => { + expect(isOpenRouterEffortSupported('none', { efforts: ['none', 'low'], mandatory: true })).toBe( + false, + ); + }); + + it('rejects any effort for a model the provider reports no reasoning for', () => { + expect(isOpenRouterEffortSupported('low', null)).toBe(false); + }); +}); + +describe('effectiveModelReasoning', () => { + const reported = { efforts: ['low'] }; + + it('passes the reported efforts through without an explicit definition', () => { + expect(effectiveModelReasoning(reported, undefined)).toBe(reported); + expect(effectiveModelReasoning(null, [{ key: 'promptCache' }])).toBeNull(); + }); + + it('ignores the reported efforts when the administrator defines reasoning_effort', () => { + expect(effectiveModelReasoning(reported, [{ key: 'reasoning_effort' }])).toBeUndefined(); + expect(effectiveModelReasoning(null, [{ key: 'reasoning_effort' }])).toBeUndefined(); + }); +}); + +describe('hasExplicitReasoningEffort', () => { + it('is true only when reasoning_effort is defined', () => { + expect(hasExplicitReasoningEffort([{ key: 'reasoning_effort' }])).toBe(true); + expect(hasExplicitReasoningEffort([{ key: 'promptCache' }])).toBe(false); + expect(hasExplicitReasoningEffort(undefined)).toBe(false); + expect(hasExplicitReasoningEffort(null)).toBe(false); + }); +}); + +describe('resolveModelReasoning', () => { + const capabilities = { OpenRouter: { 'a/b': { efforts: ['low'] }, 'c/d': { efforts: [] } } }; + const resolve = (overrides: Partial[0]> = {}) => + resolveModelReasoning({ capabilities, endpoint: 'OpenRouter', model: 'a/b', ...overrides }); + + it('returns the reported efforts once the capabilities are known', () => { + expect(resolve()).toEqual({ efforts: ['low'] }); + }); + + it('reports no reasoning for a model the catalog lists without it', () => { + expect(resolve({ model: 'c/d' })).toBeNull(); + }); + + it('keeps the generic efforts for a model the catalog does not list', () => { + expect(resolve({ model: '~openai/gpt-latest' })).toBeUndefined(); + }); + + it('hides the efforts while the capabilities are unknown, loading or failed', () => { + expect(resolve({ capabilities: undefined })).toBeNull(); + }); + + it('never narrows or hides an administrator-defined reasoning_effort', () => { + const paramDefinitions = [{ key: 'reasoning_effort' }]; + + expect(resolve({ paramDefinitions })).toBeUndefined(); + expect(resolve({ capabilities: undefined, paramDefinitions })).toBeUndefined(); + }); +}); diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 1fd98abeb53..02f1c14e542 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -1,6 +1,6 @@ +import type { ResponsesApiRouting, TModelReasoning, TReasoningCapabilityMap } from './types'; import type { SettingDefinition, SettingsConfiguration } from './generate'; import type { TReasoningOverride } from './schemas'; -import type { ResponsesApiRouting } from './types'; import { Verbosity, ImageDetail, @@ -1451,6 +1451,7 @@ export function resolveReasoningSettingForTarget({ reasoningFormat, paramDefinitions, blockedReasoningKeys, + modelReasoning, }: { endpoint: string; model?: string | null; @@ -1459,6 +1460,8 @@ export function resolveReasoningSettingForTarget({ reasoningFormat?: ReasoningParameterFormat | null; paramDefinitions?: Partial[] | null; blockedReasoningKeys?: ReadonlySet; + /** Provider-reported efforts for `model`; see {@link applyModelAwareDefaults}. */ + modelReasoning?: TModelReasoning | null; }): SettingDefinition | undefined { if (!model || reasoningFormat === ReasoningParameterFormat.disabled) { return undefined; @@ -1498,12 +1501,16 @@ export function resolveReasoningSettingForTarget({ setting.key == null ? [] : ([[setting.key, setting]] as const), ), ); - const settings = applyModelAwareDefaults(baseSettings, effectiveDefaultParamsEndpoint, model).map( - (setting) => { - const override = customSettingsByKey.get(setting.key); - return override == null ? setting : { ...setting, ...override }; - }, - ); + const settings = applyModelAwareDefaults( + baseSettings, + effectiveDefaultParamsEndpoint, + model, + undefined, + effectiveModelReasoning(modelReasoning, paramDefinitions), + ).map((setting) => { + const override = customSettingsByKey.get(setting.key); + return override == null ? setting : { ...setting, ...override }; + }); const declaredReasoningSettings = (paramDefinitions ?? []) .filter((setting) => reasoningSettingKeys.includes(setting.key as ReasoningSettingKey)) .filter((setting) => !settings.some((baseSetting) => baseSetting.key === setting.key)) @@ -1557,17 +1564,154 @@ export function isReasoningOverrideSupported( return setting.options == null || setting.options.includes(reasoningOverride.value); } +const knownReasoningEfforts: ReadonlySet = new Set(Object.values(ReasoningEffort)); + +/** + * Whether a stored or submitted effort is acceptable for a model, by the same + * rules {@link narrowOpenRouterEfforts} uses to build the control: anything is + * accepted while the capabilities are unknown, Auto always is (it sends no + * effort), and otherwise the effort must be one the provider reports, with + * `none` refused for a model whose reasoning is mandatory. + */ +export function isOpenRouterEffortSupported( + effort: string, + modelReasoning: TModelReasoning | null | undefined, +): boolean { + if (modelReasoning === undefined || effort === ReasoningEffort.unset) { + return true; + } + if (modelReasoning === null) { + return false; + } + if (modelReasoning.mandatory === true && effort === ReasoningEffort.none) { + return false; + } + return modelReasoning.efforts.includes(effort); +} + +/** Whether an administrator defined `reasoning_effort` for the endpoint. */ +export function hasExplicitReasoningEffort( + paramDefinitions?: Partial[] | null, +): boolean { + return paramDefinitions?.some((setting) => setting.key === 'reasoning_effort') === true; +} + +/** + * An administrator-defined `reasoning_effort` is authoritative for its endpoint, + * so the provider's per-model efforts must not narrow or remove it. + */ +export function effectiveModelReasoning( + modelReasoning: TModelReasoning | null | undefined, + paramDefinitions?: Partial[] | null, +): TModelReasoning | null | undefined { + return hasExplicitReasoningEffort(paramDefinitions) ? undefined : modelReasoning; +} + +/** + * The efforts to offer for one OpenRouter model. Without the endpoint's capabilities, because + * the request is still loading or failed, nothing is offered (`null`): a choice made from the + * generic list could be refused by the server. Once they are known the model's own efforts + * apply, and a model the provider does not list keeps the generic list (`undefined`). An + * administrator-defined `reasoning_effort` is never narrowed or hidden. + */ +export function resolveModelReasoning({ + capabilities, + endpoint, + model, + paramDefinitions, +}: { + capabilities: TReasoningCapabilityMap | undefined; + endpoint: string; + model: string; + paramDefinitions?: Partial[] | null; +}): TModelReasoning | null | undefined { + const reported = capabilities == null ? null : getModelReasoning(capabilities, endpoint, model); + return effectiveModelReasoning(reported, paramDefinitions); +} + +/** + * Looks one model up in an endpoint's provider-reported reasoning efforts, for + * {@link applyModelAwareDefaults}. `undefined` means unknown: the endpoint was not resolved, + * or the provider does not list the model (an alias such as `~openai/gpt-latest`, or a model + * released since the catalog was read), so the generic efforts stay. `null` means the provider + * lists the model and exposes no effort selection for it. A routing variant (`model:nitro`) is + * matched to its base model. + */ +export function getModelReasoning( + capabilities: TReasoningCapabilityMap | undefined, + endpoint: string, + model: string, +): TModelReasoning | undefined | null { + /** Model ids and endpoint names are unrestricted strings, so only own properties count: an id + * such as `constructor` must not resolve to a member of `Object.prototype`. */ + const own = (record: Record | undefined, key: string): T | undefined => + record != null && Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined; + const models = own(capabilities, endpoint); + const listed = own(models, model) ?? own(models, model.split(':')[0]); + if (listed == null) { + return undefined; + } + return listed.efforts.length === 0 ? null : listed; +} + +/** + * Narrows the effort setting to what one OpenRouter model accepts. A model with + * no reasoning metadata, or none this client knows, has no effort control: the + * provider says to omit effort selection then, and sending one is rejected. + * `none` is dropped when the model's reasoning is mandatory. Auto stays because + * it sends no effort at all. + */ +function narrowOpenRouterEfforts( + settings: SettingsConfiguration, + modelReasoning: TModelReasoning | null, +): SettingsConfiguration { + const supported = (modelReasoning?.efforts ?? []).filter( + (effort) => + knownReasoningEfforts.has(effort) && + !(modelReasoning?.mandatory === true && effort === ReasoningEffort.none), + ); + return settings.flatMap((setting) => { + if (setting.key !== 'reasoning_effort') { + return [setting]; + } + if (supported.length === 0) { + return []; + } + const options = setting.options?.filter( + (option) => option === ReasoningEffort.unset || supported.includes(String(option)), + ); + return [{ ...setting, options }]; + }); +} + /** * Resolves model-aware defaults for a settings configuration before rendering. * Google's `maxOutputTokens` default depends on the selected Gemini model so that * current models (2.5 and 3+) surface their 64K output limit instead of the legacy 8K value. * Anthropic prompt-cache controls are only surfaced for models that support them. + * + * `modelReasoning` is the provider-reported effort set for the model on an OpenRouter + * endpoint: `undefined` while it is unknown (the generic list is kept), `null` when the + * provider reports no reasoning for the model, otherwise the efforts to offer. */ export function applyModelAwareDefaults( settings: SettingsConfiguration, endpoint: string, model?: string, responsesApiRouting?: ResponsesApiRouting, + modelReasoning?: TModelReasoning | null, +): SettingsConfiguration { + const resolved = applyModelFamilyDefaults(settings, endpoint, model, responsesApiRouting); + return model && endpoint === Providers.OPENROUTER && modelReasoning !== undefined + ? narrowOpenRouterEfforts(resolved, modelReasoning) + : resolved; +} + +function applyModelFamilyDefaults( + settings: SettingsConfiguration, + endpoint: string, + model?: string, + responsesApiRouting?: ResponsesApiRouting, ): SettingsConfiguration { if (!model) { return settings; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 0e6db56cf99..c967d54ac24 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -735,6 +735,10 @@ export type TConfig = { includeReasoningContent?: boolean; includeReasoningHistory?: boolean; paramDefinitions?: Partial[]; + reasoningCatalogTimeoutMs?: number; + reasoningCatalogMaxPages?: number; + reasoningCatalogFailureTtlMs?: number; + reasoningCatalogTtlMs?: number; }; }; @@ -753,6 +757,33 @@ export type TModelTokenomics = { cacheRead?: number; }; +/** + * The reasoning efforts one model accepts, as the provider reports them + * (OpenRouter's per-model `reasoning.supported_efforts` and `reasoning.mandatory`). + */ +export type TModelReasoning = { + efforts: string[]; + /** Reasoning cannot be turned off, so `none` is not a valid choice. */ + mandatory?: boolean; +}; + +/** + * endpoint → model → reasoning efforts, from GET /api/endpoints/reasoning-capabilities. A model + * with an empty `efforts` list is listed by the provider and exposes no effort selection; a + * model absent from the map is unknown. + */ +export type TReasoningCapabilityMap = Record>; + +/** + * What the capabilities route returns: the efforts, and how long the server will keep the catalog + * they came from. The client revalidates when that time has passed, so it never holds a list longer + * than the server does, however old the server's entry was when it was fetched. + */ +export type TReasoningCapabilitiesResponse = { + capabilities: TReasoningCapabilityMap; + expiresInMs: number; +}; + /** endpoint → model → resolved tokenomics, from GET /api/endpoints/token-config */ export type TTokenConfigMap = Record>;