diff --git a/packages/api/src/endpoints/config/models.spec.ts b/packages/api/src/endpoints/config/models.spec.ts new file mode 100644 index 000000000000..81d254d3c37a --- /dev/null +++ b/packages/api/src/endpoints/config/models.spec.ts @@ -0,0 +1,70 @@ +import type { AppConfig } from '@librechat/data-schemas'; +import { EModelEndpoint } from 'librechat-data-provider'; + +import { createLoadConfigModels } from './models'; +import type { ServerRequest } from '~/types'; + +describe('createLoadConfigModels', () => { + const originalEnv = process.env; + const getUserKeyValues = jest.fn(); + const fetchModels = jest.fn(); + + beforeEach(() => { + process.env = { + ...originalEnv, + MULTIPLE_MODELS: 'gpt-4o-mini, gpt-4o', + SINGLE_MODEL: 'gpt-4.1', + }; + + getUserKeyValues.mockReset(); + fetchModels.mockReset().mockResolvedValue([]); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function buildAppConfig(includeFetch = false): AppConfig { + return { + endpoints: { + [EModelEndpoint.custom]: [ + { + name: 'custom', + apiKey: 'test-api-key', + baseURL: 'https://example.com', + models: { + default: ['${MULTIPLE_MODELS}', { name: '${SINGLE_MODEL}' }, 'claude-3-5-sonnet'], + ...(includeFetch ? { fetch: true } : {}), + }, + }, + ], + }, + } as AppConfig; + } + + function buildLoader(includeFetch = false) { + return createLoadConfigModels({ + getAppConfig: async () => buildAppConfig(includeFetch), + getUserKeyValues, + fetchModels, + }); + } + + it('expands comma-separated env vars in default model lists', async () => { + const loadConfigModels = buildLoader(false); + + const result = await loadConfigModels({} as ServerRequest); + + expect(result.custom).toEqual(['gpt-4o-mini', 'gpt-4o', 'gpt-4.1', 'claude-3-5-sonnet']); + expect(fetchModels).not.toHaveBeenCalled(); + }); + + it('uses the same expansion when falling back after a fetch miss', async () => { + const loadConfigModels = buildLoader(true); + + const result = await loadConfigModels({} as ServerRequest); + + expect(fetchModels).toHaveBeenCalledTimes(1); + expect(result.custom).toEqual(['gpt-4o-mini', 'gpt-4o', 'gpt-4.1', 'claude-3-5-sonnet']); + }); +}); diff --git a/packages/api/src/endpoints/config/models.ts b/packages/api/src/endpoints/config/models.ts index 22c5207b1d67..030d329a2cc3 100644 --- a/packages/api/src/endpoints/config/models.ts +++ b/packages/api/src/endpoints/config/models.ts @@ -3,6 +3,7 @@ import { ErrorTypes, EModelEndpoint, extractEnvVariable, + extractVariableName, normalizeEndpointName, } from 'librechat-data-provider'; import type { TModelsConfig, TEndpoint } from 'librechat-data-provider'; @@ -21,6 +22,23 @@ interface ResolvedEndpoint { baseURLIsUserProvided: boolean; } +function resolveDefaultModelNames(defaultModels: TEndpoint['models']['default']): string[] { + return defaultModels.flatMap((model) => { + const rawModelName = typeof model === 'string' ? model : model.name; + const envVarName = extractVariableName(rawModelName); + const resolvedModelName = extractEnvVariable(rawModelName); + + if (envVarName && resolvedModelName.includes(',')) { + return resolvedModelName + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + + return [resolvedModelName]; + }); +} + export interface LoadConfigModelsDeps { getAppConfig: (params: { role?: string; @@ -196,9 +214,7 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) { } if (Array.isArray(models?.default)) { - modelsConfig[name] = models.default.map((model) => - typeof model === 'string' ? model : model.name, - ); + modelsConfig[name] = resolveDefaultModelNames(models.default); } } @@ -216,9 +232,7 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) { for (const name of associatedNames) { const endpoint = endpointsMap[name]; - const defaults = (endpoint.models?.default ?? []).map((m) => - typeof m === 'string' ? m : m.name, - ); + const defaults = resolveDefaultModelNames(endpoint.models?.default ?? []); modelsConfig[name] = !modelData?.length ? defaults : modelData; } }