Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0f5309a
feat: Filter OpenRouter Reasoning Efforts by the Selected Model
berry-13 Oct 6, 2026
5124638
fix: Address Review Findings on OpenRouter Per-Model Reasoning Efforts
berry-13 Oct 6, 2026
948af5f
fix: Report Catalog Failures, Share Lookups and Honor Direct Endpoint…
berry-13 Oct 6, 2026
6ff33e1
fix: Move Capabilities Handler Into packages/api and Harden Catalog L…
berry-13 Oct 6, 2026
32939ab
fix: Scope Reasoning Capabilities to One Endpoint and Gate Editors Un…
berry-13 Oct 6, 2026
35519ad
fix: Skip Catalog Reads for Explicit Definitions and Keep Staged Effo…
berry-13 Oct 6, 2026
ed858e7
fix: Retry the Reasoning Capabilities Query on Focus and Reconnect Af…
berry-13 Oct 6, 2026
f222cdd
fix: Remember Failed Catalog Lookups, Reject Locked Overrides Early a…
berry-13 Oct 6, 2026
1428449
fix: Hide Efforts Until the Catalog Is Known, Keep Unlisted Models Un…
berry-13 Oct 6, 2026
cc95939
fix: Mark OpenRouter Endpoints by Their Resolved Host so the Client F…
berry-13 Oct 6, 2026
055fc06
fix: Share One OpenRouter Catalog Rule, Build the Catalog URL Safely …
berry-13 Oct 6, 2026
74966b3
fix: Forward Static Catalog Headers, Resolve Relative Next Links and …
berry-13 Oct 6, 2026
41660e5
fix: Limit Capabilities to Exposed Models and Revalidate Against the …
berry-13 Oct 6, 2026
351e366
fix: Hide Efforts After a Failed Refresh, Reject Malformed Pagination…
berry-13 Oct 6, 2026
61c8087
fix: Use Own-Property Lookups, Skip the Catalog for Auto and Revalida…
berry-13 Oct 6, 2026
678711e
fix: Treat Request Metadata Headers as Shared, Store Catalogs as Own …
berry-13 Oct 6, 2026
41aa0d6
fix: Keep Configured Query Parameters Across Catalog Pages and Treat …
berry-13 Oct 6, 2026
8251539
fix: Expose Only Configured Variants, Skip Identity Headers on Fixed …
berry-13 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/server/controllers/ReasoningCapabilitiesController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { getReasoningCapabilities, createReasoningCapabilitiesHandler } = require('@librechat/api');

module.exports = createReasoningCapabilitiesHandler({ getReasoningCapabilities });
3 changes: 3 additions & 0 deletions api/server/middleware/buildEndpointOption.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
inspectContent,
extractChatContent,
contentFilterBlockResponse,
getReasoningCapabilities,
applyRequestReasoningOverride,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
Expand Down Expand Up @@ -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' });
Expand Down
7 changes: 7 additions & 0 deletions api/server/routes/endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
38 changes: 30 additions & 8 deletions client/src/components/Chat/Input/Reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -346,18 +352,28 @@ export function useComposerReasoning({
reasoningFormat: customParams.reasoningFormat,
paramDefinitions: customParams.paramDefinitions,
blockedReasoningKeys,
modelReasoning,
Comment thread
berry-13 marked this conversation as resolved.
Comment thread
berry-13 marked this conversation as resolved.
});
}, [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}`
Comment on lines 375 to +376

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode target identity without delimiter collisions

Endpoint names are unrestricted strings and OpenRouter model IDs commonly contain :, so two distinct targets can produce the same fingerprint—for example endpoint foo with model bar:baz and endpoint foo:bar with model baz. Switching between those targets then leaves targetChanged false; if both support the staged value, a one-shot reasoning override selected for the old target silently carries into the new one. Use a structured tuple or otherwise collision-free encoding for the endpoint/agent and model identity.

Useful? React with 👍 / 👎.

: null;
const previousTarget = useRef({ key: reasoningStateKey, fingerprint: targetFingerprint });
const explicitlyUnavailable =
Expand Down Expand Up @@ -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)))
Comment on lines +410 to +412

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear definitively unavailable overrides during refresh

When an OpenRouter capability request is pending or has failed, capabilitiesPending also prevents clearing for definitive conditions such as enabled === false or hasAddedConversation === true. A value staged before the parameters interface is disabled can therefore remain hidden in the atom throughout an outage and later resurface; an Auto value can also be drained and submitted because the send path always accepts that sentinel. Only catalog-dependent conditions should wait for capabilities—explicitly unavailable controls should clear their staged override immediately.

Useful? React with 👍 / 👎.

) {
setValue(undefined);
}
}, [
capabilitiesPending,
explicitlyUnavailable,
reasoningStateKey,
setValue,
Expand Down
236 changes: 236 additions & 0 deletions client/src/components/Chat/Input/__tests__/Reasoning.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,10 +22,19 @@ type MockEndpointConfig = {
};
};
let mockEndpointsConfig: Record<string, MockEndpointConfig> | 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', () => ({
Expand All @@ -37,6 +47,8 @@ jest.mock('~/hooks', () => ({

beforeEach(() => {
mockEndpointsConfig = {};
mockCapabilities = undefined;
mockCapabilitiesLoading = false;
});

const enumSetting: SettingDefinition = {
Expand Down Expand Up @@ -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 }) => (
<RecoilRoot>
<JotaiProvider store={createStore()}>{children}</JotaiProvider>
</RecoilRoot>
);
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<typeof createStore>) =>
store.get(pendingReasoningOverrideFamily('restored-conversation'));
const setup = () => {
const store = createStore();
store.set(pendingReasoningOverrideFamily('restored-conversation'), staged);
const wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<JotaiProvider store={store}>{children}</JotaiProvider>
</RecoilRoot>
);
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 }) => (
<RecoilRoot>
<JotaiProvider store={store}>{children}</JotaiProvider>
</RecoilRoot>
);
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());
});
});
Loading
Loading