diff --git a/api/server/routes/config.js b/api/server/routes/config.js index f6eb66374e..1f4f1a3575 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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, diff --git a/packages/api/src/modelSpecs/excludeHiddenModelSpecs.test.ts b/packages/api/src/modelSpecs/excludeHiddenModelSpecs.test.ts new file mode 100644 index 0000000000..591a5ca1b1 --- /dev/null +++ b/packages/api/src/modelSpecs/excludeHiddenModelSpecs.test.ts @@ -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); + }); +}); diff --git a/packages/api/src/modelSpecs/index.ts b/packages/api/src/modelSpecs/index.ts index 30c895d89c..6fb5f9006e 100644 --- a/packages/api/src/modelSpecs/index.ts +++ b/packages/api/src/modelSpecs/index.ts @@ -186,6 +186,25 @@ export function resolveModelSpecPromptPrefixVariables"` can still use them. + * Returns the config unchanged when there is no list. Apply before `sanitizeModelSpecs`. + */ +export function excludeHiddenModelSpecs | 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 | null | undefined>( modelSpecs: T, ): T { diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index d123f9131e..b6f44dfef3 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -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(),