From e9a85d5c65dafc7298a4b5dd49835aca19203dca Mon Sep 17 00:00:00 2001 From: Peter Nancarrow <37298202+pnancarrow@users.noreply.github.com> Date: Sat, 11 Oct 2025 06:55:06 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20feat:=20Add=20Optional?= =?UTF-8?q?=20Group=20Field=20to=20ModelSpecs=20Configuration=20(#9996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../services/Config/loadConfigModels.js | 4 +- .../services/Config/loadConfigModels.spec.js | 4 +- .../Chat/Menus/Endpoints/ModelSelector.tsx | 16 ++++- .../Endpoints/components/CustomGroup.tsx | 66 +++++++++++++++++++ .../Endpoints/components/EndpointItem.tsx | 30 +++++++-- .../components/EndpointModelItem.tsx | 46 +++++++------ .../Chat/Menus/Endpoints/components/index.ts | 1 + librechat.example.yaml | 66 +++++++++++++++++-- packages/data-provider/src/config.ts | 13 +++- packages/data-provider/src/models.ts | 8 +++ 10 files changed, 215 insertions(+), 39 deletions(-) create mode 100644 client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 9ef8994241..840d957fa1 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -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, + ); } } diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index b8d577667a..1e0e8780a7 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -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( diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index 01d96432a7..d9464182b9 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -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 ?? [])} )} diff --git a/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx b/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx new file mode 100644 index 0000000000..80d049cce7 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx @@ -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 ( + +
+ {groupName} +
+ + } + > + {specs.map((spec: TModelSpec) => ( + + ))} +
+ ); +} + +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, + ); + + // Render each custom group + return Object.entries(customGroups).map(([groupName, specs]) => ( + + )); +} diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index 6541383f39..52c3fc8367 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -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) {
- ) : filteredModels ? ( - renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels) ) : ( - endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel) + <> + {/* Render modelSpecs for this endpoint */} + {endpointSpecs.map((spec: TModelSpec) => ( + + ))} + {/* Render endpoint models */} + {filteredModels + ? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels) + : endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)} + )} ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 6a9b6fd336..eeefdba598 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -36,38 +36,42 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod 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" > -
+
{avatarUrl ? ( -
+
{modelName
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && endpoint.icon ? ( -
+
{endpoint.icon}
) : null} - {modelName} + {modelName} + {isGlobal && ( + + )}
- {isGlobal && } {isSelected && ( - - - +
+ + + +
)} ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/index.ts b/client/src/components/Chat/Menus/Endpoints/components/index.ts index d39ad4276f..bc08e6a8a1 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/index.ts +++ b/client/src/components/Chat/Menus/Endpoints/components/index.ts @@ -2,3 +2,4 @@ export * from './ModelSpecItem'; export * from './EndpointModelItem'; export * from './EndpointItem'; export * from './SearchResults'; +export * from './CustomGroup'; diff --git a/librechat.example.yaml b/librechat.example.yaml index 6f034910dc..04e088aa38 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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: diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 2b78453be4..c3f872eaec 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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>; sharedLinksEnabled: boolean; publicSharedLinksEnabled: boolean; analyticsGtmId?: string; diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index c925781bff..78ba1237fc 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -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(),