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,38 +36,42 @@ 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 && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
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