🗂️ feat: Add Optional Group Field to ModelSpecs Configuration (#9996)

* feat: Add group field to modelSpecs for flexible grouping

* resolve lint issues

* fix test

* docs: enhance modelSpecs group field documentation for clarity

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Peter Nancarrow 2025-10-11 06:55:06 -05:00 committed by GitHub
parent f61afc1124
commit e9a85d5c65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 215 additions and 39 deletions

View file

@ -85,7 +85,9 @@ async function loadConfigModels(req) {
} }
if (Array.isArray(models.default)) { if (Array.isArray(models.default)) {
modelsConfig[name] = models.default; modelsConfig[name] = models.default.map((model) =>
typeof model === 'string' ? model : model.name,
);
} }
} }

View file

@ -254,8 +254,8 @@ describe('loadConfigModels', () => {
// For groq and ollama, since the apiKey is "user_provided", models should not be fetched // For groq and ollama, since the apiKey is "user_provided", models should not be fetched
// Depending on your implementation's behavior regarding "default" models without fetching, // Depending on your implementation's behavior regarding "default" models without fetching,
// you may need to adjust the following assertions: // you may need to adjust the following assertions:
expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default); expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default);
expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default); expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default);
// Verifying fetchModels was not called for groq and ollama // Verifying fetchModels was not called for groq and ollama
expect(fetchModels).not.toHaveBeenCalledWith( expect(fetchModels).not.toHaveBeenCalledWith(

View file

@ -2,7 +2,12 @@ import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common'; import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext'; import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext'; import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components'; import {
renderModelSpecs,
renderEndpoints,
renderSearchResults,
renderCustomGroups,
} from './components';
import { getSelectedIcon, getDisplayValue } from './utils'; import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu'; import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager'; import DialogManager from './DialogManager';
@ -86,8 +91,15 @@ function ModelSelectorContent() {
renderSearchResults(searchResults, localize, searchValue) renderSearchResults(searchResults, localize, searchValue)
) : ( ) : (
<> <>
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')} {/* Render ungrouped modelSpecs (no group field) */}
{renderModelSpecs(
modelSpecs?.filter((spec) => !spec.group) || [],
selectedValues.modelSpec || '',
)}
{/* Render endpoints (will include grouped specs matching endpoint names) */}
{renderEndpoints(mappedEndpoints ?? [])} {renderEndpoints(mappedEndpoints ?? [])}
{/* Render custom groups (specs with group field not matching any endpoint) */}
{renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])}
</> </>
)} )}
</Menu> </Menu>

View file

@ -0,0 +1,66 @@
import React from 'react';
import type { TModelSpec } from 'librechat-data-provider';
import { CustomMenu as Menu } from '../CustomMenu';
import { ModelSpecItem } from './ModelSpecItem';
import { useModelSelectorContext } from '../ModelSelectorContext';
interface CustomGroupProps {
groupName: string;
specs: TModelSpec[];
}
export function CustomGroup({ groupName, specs }: CustomGroupProps) {
const { selectedValues } = useModelSelectorContext();
const { modelSpec: selectedSpec } = selectedValues;
if (!specs || specs.length === 0) {
return null;
}
return (
<Menu
id={`custom-group-${groupName}-menu`}
key={`custom-group-${groupName}`}
className="transition-opacity duration-200 ease-in-out"
label={
<div className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm">
<div className="flex items-center gap-2">
<span className="truncate text-left">{groupName}</span>
</div>
</div>
}
>
{specs.map((spec: TModelSpec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
</Menu>
);
}
export function renderCustomGroups(
modelSpecs: TModelSpec[],
mappedEndpoints: Array<{ value: string }>,
) {
// Get all endpoint values to exclude them from custom groups
const endpointValues = new Set(mappedEndpoints.map((ep) => ep.value));
// Group specs by their group field (excluding endpoint-matched groups and ungrouped)
const customGroups = modelSpecs.reduce(
(acc, spec) => {
if (!spec.group || endpointValues.has(spec.group)) {
return acc;
}
if (!acc[spec.group]) {
acc[spec.group] = [];
}
acc[spec.group].push(spec);
return acc;
},
{} as Record<string, TModelSpec[]>,
);
// Render each custom group
return Object.entries(customGroups).map(([groupName, specs]) => (
<CustomGroup key={groupName} groupName={groupName} specs={specs} />
));
}

View file

@ -2,10 +2,12 @@ import { useMemo } from 'react';
import { SettingsIcon } from 'lucide-react'; import { SettingsIcon } from 'lucide-react';
import { TooltipAnchor, Spinner } from '@librechat/client'; import { TooltipAnchor, Spinner } from '@librechat/client';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common'; import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu'; import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { useModelSelectorContext } from '../ModelSelectorContext'; import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem'; import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
import { filterModels } from '../utils'; import { filterModels } from '../utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
const { const {
agentsMap, agentsMap,
assistantsMap, assistantsMap,
modelSpecs,
selectedValues, selectedValues,
handleOpenKeyDialog, handleOpenKeyDialog,
handleSelectEndpoint, handleSelectEndpoint,
@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
setEndpointSearchValue, setEndpointSearchValue,
endpointRequiresUserKey, endpointRequiresUserKey,
} = useModelSelectorContext(); } = useModelSelectorContext();
const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues; const {
model: selectedModel,
endpoint: selectedEndpoint,
modelSpec: selectedSpec,
} = selectedValues;
// Filter modelSpecs for this endpoint (by group matching endpoint value)
const endpointSpecs = useMemo(() => {
if (!modelSpecs || !modelSpecs.length) {
return [];
}
return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value);
}, [modelSpecs, endpoint.value]);
const searchValue = endpointSearchValues[endpoint.value] || ''; const searchValue = endpointSearchValues[endpoint.value] || '';
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]); const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
@ -138,10 +153,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Spinner /> <Spinner />
</div> </div>
) : filteredModels ? (
renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
) : ( ) : (
endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel) <>
{/* Render modelSpecs for this endpoint */}
{endpointSpecs.map((spec: TModelSpec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
{/* Render endpoint models */}
{filteredModels
? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
: endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)}
</>
)} )}
</Menu> </Menu>
); );

View file

@ -36,23 +36,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
<MenuItem <MenuItem
key={modelId} key={modelId}
onClick={() => handleSelectModel(endpoint, modelId ?? '')} onClick={() => handleSelectModel(endpoint, modelId ?? '')}
className="flex h-8 w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 text-sm" className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
> >
<div className="flex items-center gap-2"> <div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
{avatarUrl ? ( {avatarUrl ? (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full"> <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" /> <img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
</div> </div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && ) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
endpoint.icon ? ( endpoint.icon ? (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full"> <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon} {endpoint.icon}
</div> </div>
) : null} ) : null}
<span>{modelName}</span> <span className="truncate text-left">{modelName}</span>
{isGlobal && (
<EarthIcon className="ml-auto size-4 flex-shrink-0 self-center text-green-400" />
)}
</div> </div>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
{isSelected && ( {isSelected && (
<div className="flex-shrink-0 self-center">
<svg <svg
width="16" width="16"
height="16" height="16"
@ -68,6 +71,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
</div>
)} )}
</MenuItem> </MenuItem>
); );

View file

@ -2,3 +2,4 @@ export * from './ModelSpecItem';
export * from './EndpointModelItem'; export * from './EndpointModelItem';
export * from './EndpointItem'; export * from './EndpointItem';
export * from './SearchResults'; export * from './SearchResults';
export * from './CustomGroup';

View file

@ -229,13 +229,11 @@ endpoints:
baseURL: 'https://api.groq.com/openai/v1/' baseURL: 'https://api.groq.com/openai/v1/'
models: models:
default: default:
[ - 'llama3-70b-8192'
'llama3-70b-8192', - 'llama3-8b-8192'
'llama3-8b-8192', - 'llama2-70b-4096'
'llama2-70b-4096', - 'mixtral-8x7b-32768'
'mixtral-8x7b-32768', - 'gemma-7b-it'
'gemma-7b-it',
]
fetch: false fetch: false
titleConvo: true titleConvo: true
titleModel: 'mixtral-8x7b-32768' titleModel: 'mixtral-8x7b-32768'
@ -320,6 +318,60 @@ endpoints:
forcePrompt: false forcePrompt: false
modelDisplayLabel: 'Portkey' modelDisplayLabel: 'Portkey'
iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf
# Example modelSpecs configuration showing grouping options
# The 'group' field organizes model specs in the UI selector:
# - If 'group' matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint
# - If 'group' is a custom name (doesn't match any endpoint), it creates a separate collapsible section
# - If 'group' is omitted, the spec appears as a standalone item at the top level
# modelSpecs:
# list:
# # Example 1: Nested under an endpoint (grouped with openAI endpoint)
# - name: "gpt-4o"
# label: "GPT-4 Optimized"
# description: "Most capable GPT-4 model with multimodal support"
# group: "openAI" # String value matching the endpoint name
# preset:
# endpoint: "openAI"
# model: "gpt-4o"
#
# # Example 2: Nested under a custom endpoint (grouped with groq endpoint)
# - name: "llama3-70b-8192"
# label: "Llama 3 70B"
# description: "Fastest inference available - great for quick responses"
# group: "groq" # String value matching your custom endpoint name from endpoints.custom
# preset:
# endpoint: "groq"
# model: "llama3-70b-8192"
#
# # Example 3: Custom group (creates a separate collapsible section)
# - name: "coding-assistant"
# label: "Coding Assistant"
# description: "Specialized for coding tasks"
# group: "my-assistants" # Custom string - doesn't match any endpoint, so creates its own group
# preset:
# endpoint: "openAI"
# model: "gpt-4o"
# instructions: "You are an expert coding assistant..."
# temperature: 0.3
#
# - name: "writing-assistant"
# label: "Writing Assistant"
# description: "Specialized for creative writing"
# group: "my-assistants" # Same custom group name - both specs appear in same section
# preset:
# endpoint: "anthropic"
# model: "claude-sonnet-4"
# instructions: "You are a creative writing expert..."
#
# # Example 4: Standalone (no group - appears at top level)
# - name: "general-assistant"
# label: "General Assistant"
# description: "General purpose assistant"
# # No 'group' field - appears as standalone item at top level (not nested)
# preset:
# endpoint: "openAI"
# model: "gpt-4o-mini"
# fileConfig: # fileConfig:
# endpoints: # endpoints:
# assistants: # assistants:

View file

@ -214,6 +214,14 @@ export const bedrockEndpointSchema = baseEndpointSchema.merge(
}), }),
); );
const modelItemSchema = z.union([
z.string(),
z.object({
name: z.string(),
description: z.string().optional(),
}),
]);
export const assistantEndpointSchema = baseEndpointSchema.merge( export const assistantEndpointSchema = baseEndpointSchema.merge(
z.object({ z.object({
/* assistants specific */ /* assistants specific */
@ -239,7 +247,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge(
apiKey: z.string().optional(), apiKey: z.string().optional(),
models: z models: z
.object({ .object({
default: z.array(z.string()).min(1), default: z.array(modelItemSchema).min(1),
fetch: z.boolean().optional(), fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(), userIdQuery: z.boolean().optional(),
}) })
@ -299,7 +307,7 @@ export const endpointSchema = baseEndpointSchema.merge(
apiKey: z.string(), apiKey: z.string(),
baseURL: z.string(), baseURL: z.string(),
models: z.object({ models: z.object({
default: z.array(z.string()).min(1), default: z.array(modelItemSchema).min(1),
fetch: z.boolean().optional(), fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(), userIdQuery: z.boolean().optional(),
}), }),
@ -636,6 +644,7 @@ export type TStartupConfig = {
helpAndFaqURL: string; helpAndFaqURL: string;
customFooter?: string; customFooter?: string;
modelSpecs?: TSpecsConfig; modelSpecs?: TSpecsConfig;
modelDescriptions?: Record<string, Record<string, string>>;
sharedLinksEnabled: boolean; sharedLinksEnabled: boolean;
publicSharedLinksEnabled: boolean; publicSharedLinksEnabled: boolean;
analyticsGtmId?: string; analyticsGtmId?: string;

View file

@ -15,6 +15,13 @@ export type TModelSpec = {
order?: number; order?: number;
default?: boolean; default?: boolean;
description?: string; description?: string;
/**
* Optional group name for organizing specs in the UI selector.
* - If it matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint
* - If it's a custom name (doesn't match any endpoint), it creates a separate collapsible group
* - If omitted, the spec appears as a standalone item at the top level
*/
group?: string;
showIconInMenu?: boolean; showIconInMenu?: boolean;
showIconInHeader?: boolean; showIconInHeader?: boolean;
iconURL?: string | EModelEndpoint; // Allow using project-included icons iconURL?: string | EModelEndpoint; // Allow using project-included icons
@ -28,6 +35,7 @@ export const tModelSpecSchema = z.object({
order: z.number().optional(), order: z.number().optional(),
default: z.boolean().optional(), default: z.boolean().optional(),
description: z.string().optional(), description: z.string().optional(),
group: z.string().optional(),
showIconInMenu: z.boolean().optional(), showIconInMenu: z.boolean().optional(),
showIconInHeader: z.boolean().optional(), showIconInHeader: z.boolean().optional(),
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(), iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),