🗂️ 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)) {
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
// Depending on your implementation's behavior regarding "default" models without fetching,
// you may need to adjust the following assertions:
expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default);
expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default);
expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default);
expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default);
// Verifying fetchModels was not called for groq and ollama
expect(fetchModels).not.toHaveBeenCalledWith(

View file

@ -2,7 +2,12 @@ import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
import {
renderModelSpecs,
renderEndpoints,
renderSearchResults,
renderCustomGroups,
} from './components';
import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager';
@ -86,8 +91,15 @@ function ModelSelectorContent() {
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 ?? [])}
{/* Render custom groups (specs with group field not matching any endpoint) */}
{renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])}
</>
)}
</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 { TooltipAnchor, Spinner } from '@librechat/client';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
import { filterModels } from '../utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
const {
agentsMap,
assistantsMap,
modelSpecs,
selectedValues,
handleOpenKeyDialog,
handleSelectEndpoint,
@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
setEndpointSearchValue,
endpointRequiresUserKey,
} = 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 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">
<Spinner />
</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>
);

View file

@ -36,23 +36,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
<MenuItem
key={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 ? (
<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" />
</div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
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}
</div>
) : 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>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
{isSelected && (
<div className="flex-shrink-0 self-center">
<svg
width="16"
height="16"
@ -68,6 +71,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
fill="currentColor"
/>
</svg>
</div>
)}
</MenuItem>
);

View file

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

View file

@ -229,13 +229,11 @@ endpoints:
baseURL: 'https://api.groq.com/openai/v1/'
models:
default:
[
'llama3-70b-8192',
'llama3-8b-8192',
'llama2-70b-4096',
'mixtral-8x7b-32768',
'gemma-7b-it',
]
- 'llama3-70b-8192'
- 'llama3-8b-8192'
- 'llama2-70b-4096'
- 'mixtral-8x7b-32768'
- 'gemma-7b-it'
fetch: false
titleConvo: true
titleModel: 'mixtral-8x7b-32768'
@ -320,6 +318,60 @@ endpoints:
forcePrompt: false
modelDisplayLabel: 'Portkey'
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:
# endpoints:
# 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(
z.object({
/* assistants specific */
@ -239,7 +247,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge(
apiKey: z.string().optional(),
models: z
.object({
default: z.array(z.string()).min(1),
default: z.array(modelItemSchema).min(1),
fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(),
})
@ -299,7 +307,7 @@ export const endpointSchema = baseEndpointSchema.merge(
apiKey: z.string(),
baseURL: z.string(),
models: z.object({
default: z.array(z.string()).min(1),
default: z.array(modelItemSchema).min(1),
fetch: z.boolean().optional(),
userIdQuery: z.boolean().optional(),
}),
@ -636,6 +644,7 @@ export type TStartupConfig = {
helpAndFaqURL: string;
customFooter?: string;
modelSpecs?: TSpecsConfig;
modelDescriptions?: Record<string, Record<string, string>>;
sharedLinksEnabled: boolean;
publicSharedLinksEnabled: boolean;
analyticsGtmId?: string;

View file

@ -15,6 +15,13 @@ export type TModelSpec = {
order?: number;
default?: boolean;
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;
showIconInHeader?: boolean;
iconURL?: string | EModelEndpoint; // Allow using project-included icons
@ -28,6 +35,7 @@ export const tModelSpecSchema = z.object({
order: z.number().optional(),
default: z.boolean().optional(),
description: z.string().optional(),
group: z.string().optional(),
showIconInMenu: z.boolean().optional(),
showIconInHeader: z.boolean().optional(),
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),