mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🗂️ 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:
parent
f61afc1124
commit
e9a85d5c65
10 changed files with 215 additions and 39 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
));
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './ModelSpecItem';
|
|||
export * from './EndpointModelItem';
|
||||
export * from './EndpointItem';
|
||||
export * from './SearchResults';
|
||||
export * from './CustomGroup';
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue