🥷 feat: Add showInMenu Option to Model Specs (#14034)

Add an optional `showInMenu` flag to model specs. When set to false, the
spec is dropped from the model selector menu and from the client startup
config (GET /api/config), but remains resolvable server-side by name — a
request that sends `spec: "<name>"` still works, since server-side
resolution uses the full, unfiltered list.

Unlike `showIconInMenu` (which only hides the icon), this hides the whole
entry. The flag is optional and defaults to listed, so existing specs are
unaffected.

Adds an `excludeHiddenModelSpecs()` helper (applied before
`sanitizeModelSpecs`) plus unit tests.
This commit is contained in:
Alexey Korepanov 2026-07-01 01:32:59 +02:00 committed by GitHub
parent 927a5957cb
commit ac759ef2f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 79 additions and 1 deletions

View file

@ -6,6 +6,7 @@ const {
resolveBuildInfo,
resolveTitleTiming,
sanitizeModelSpecs,
excludeHiddenModelSpecs,
isFileSnapshotEnabled,
} = require('@librechat/api');
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
@ -265,7 +266,7 @@ router.get('/', async function (req, res) {
endpoint: EModelEndpoint.agents,
}),
turnstile: appConfig?.turnstileConfig,
modelSpecs: sanitizeModelSpecs(appConfig?.modelSpecs),
modelSpecs: sanitizeModelSpecs(excludeHiddenModelSpecs(appConfig?.modelSpecs)),
balance: balanceConfig,
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,

View file

@ -0,0 +1,49 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import { excludeHiddenModelSpecs } from './index';
const makeSpec = (name: string, showInMenu?: boolean): TModelSpec => ({
name,
label: name,
...(showInMenu === undefined ? {} : { showInMenu }),
preset: {
endpoint: EModelEndpoint.bedrock,
model: 'claude-sonnet-4-6',
},
});
describe('excludeHiddenModelSpecs', () => {
it('drops specs marked showInMenu: false and keeps the rest', () => {
const modelSpecs = {
enforce: false,
prioritize: true,
list: [
makeSpec('listed-default'),
makeSpec('listed-explicit', true),
makeSpec('hidden', false),
],
};
const result = excludeHiddenModelSpecs(modelSpecs);
expect(result.list.map((s) => s.name)).toEqual(['listed-default', 'listed-explicit']);
});
it('treats an omitted showInMenu as listed (backwards compatible)', () => {
const modelSpecs = { list: [makeSpec('no-flag')] };
expect(excludeHiddenModelSpecs(modelSpecs).list).toHaveLength(1);
});
it('does not mutate the input', () => {
const modelSpecs = { list: [makeSpec('keep'), makeSpec('hidden', false)] };
excludeHiddenModelSpecs(modelSpecs);
expect(modelSpecs.list).toHaveLength(2);
});
it('returns the config unchanged when there is no list', () => {
expect(excludeHiddenModelSpecs(undefined)).toBeUndefined();
expect(excludeHiddenModelSpecs(null)).toBeNull();
const noList = { enforce: false, prioritize: true };
expect(excludeHiddenModelSpecs(noList)).toBe(noList);
});
});

View file

@ -186,6 +186,25 @@ export function resolveModelSpecPromptPrefixVariables<T extends { promptPrefix?:
};
}
/**
* Drops specs marked `showInMenu: false` so they are not advertised to clients
* (the model selector menu and the startup config). Such specs remain resolvable
* server-side by name, so a caller that sets `spec: "<name>"` can still use them.
* Returns the config unchanged when there is no list. Apply before `sanitizeModelSpecs`.
*/
export function excludeHiddenModelSpecs<T extends Partial<TSpecsConfig> | null | undefined>(
modelSpecs: T,
): T {
if (!modelSpecs?.list || !Array.isArray(modelSpecs.list)) {
return modelSpecs;
}
return {
...modelSpecs,
list: modelSpecs.list.filter((modelSpec) => modelSpec?.showInMenu !== false),
} as T;
}
export function sanitizeModelSpecs<T extends Partial<TSpecsConfig> | null | undefined>(
modelSpecs: T,
): T {

View file

@ -37,6 +37,14 @@ export type TModelSpec = {
showOnLanding?: boolean;
/** Conversation starter prompts shown on the chat landing while this spec is active. */
conversation_starters?: string[];
/**
* When false, the spec is omitted from the model selector menu and from the
* client startup config, but remains usable when invoked explicitly by name
* via the `spec` field (server-side resolution uses the full, unfiltered list).
* Unlike `showIconInMenu` (which only hides the icon), this hides the whole entry.
* Defaults to true (listed).
*/
showInMenu?: boolean;
iconURL?: string | EModelEndpoint; // Allow using project-included icons
authType?: AuthType;
/** Hide the chat input tool badge row while this model spec is active. */
@ -71,6 +79,7 @@ export const tModelSpecSchema = z.object({
showIconInHeader: z.boolean().optional(),
showOnLanding: z.boolean().optional(),
conversation_starters: z.array(z.string()).optional(),
showInMenu: z.boolean().optional(),
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
authType: authTypeSchema.optional(),
hideBadgeRow: z.boolean().optional(),