diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index 13e70cc0a4..b667150e8b 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -204,13 +204,15 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector (spec: t.TModelSpec) => { let model = spec.preset.model ?? null; onSelectSpec?.(spec); - if (isAgentsEndpoint(spec.preset.endpoint)) { + /** Specs arrive with `preset.endpoint` materialized at config load. */ + const endpoint = spec.preset.endpoint ?? null; + if (isAgentsEndpoint(endpoint)) { model = spec.preset.agent_id ?? ''; - } else if (isAssistantsEndpoint(spec.preset.endpoint)) { + } else if (isAssistantsEndpoint(endpoint)) { model = spec.preset.assistant_id ?? ''; } setSelectedValues({ - endpoint: spec.preset.endpoint, + endpoint, model, modelSpec: spec.name, }); diff --git a/client/src/hooks/Config/useAppStartup.ts b/client/src/hooks/Config/useAppStartup.ts index fb8b271c86..6df61d2230 100644 --- a/client/src/hooks/Config/useAppStartup.ts +++ b/client/src/hooks/Config/useAppStartup.ts @@ -7,6 +7,7 @@ import { LocalStorageKeys, PermissionTypes, Permissions, + resolveModelSpecEndpoint, } from 'librechat-data-provider'; import type { TStartupConfig, TUser } from 'librechat-data-provider'; import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider'; @@ -77,6 +78,7 @@ export default function useAppStartup({ setDefaultPreset({ ...defaultSpec.preset, + endpoint: resolveModelSpecEndpoint(defaultSpec) ?? null, iconURL: defaultSpec.iconURL, spec: defaultSpec.name, }); diff --git a/client/src/hooks/Input/useMentions.ts b/client/src/hooks/Input/useMentions.ts index 420f823e23..e07e20e769 100644 --- a/client/src/hooks/Input/useMentions.ts +++ b/client/src/hooks/Input/useMentions.ts @@ -9,6 +9,7 @@ import { isAgentsEndpoint, getConfigDefaults, isAssistantsEndpoint, + resolveModelSpecEndpoint, } from 'librechat-data-provider'; import type { TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider'; import type { MentionOption } from '~/common'; @@ -202,6 +203,7 @@ export default function useMentions({ icon: EndpointIcon({ conversation: { ...modelSpec.preset, + endpoint: resolveModelSpecEndpoint(modelSpec) ?? null, iconURL: modelSpec.iconURL, }, endpointsConfig, diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 44fed60c5e..f0827df078 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -8,6 +8,7 @@ import { isAgentsEndpoint, isEphemeralAgentId, isAssistantsEndpoint, + resolveModelSpecEndpoint, } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { LocalizeFunction, IconsRecord } from '~/common'; @@ -514,6 +515,12 @@ export function getModelSpecPreset(modelSpec?: t.TModelSpec) { } return { ...modelSpec.preset, + /** + * Specs are materialized at config load, but a preset flowing into + * `TPreset` contexts must carry an endpoint decision either way — resolve + * here so startup and URL flows never receive an endpoint-less preset. + */ + endpoint: resolveModelSpecEndpoint(modelSpec) ?? null, spec: modelSpec.name, iconURL: getModelSpecIconURL(modelSpec), }; diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 7b70641ea2..5cab1fd2a0 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -114,6 +114,51 @@ describe('createAppConfigService', () => { expect(deps.getApplicableConfigs).toHaveBeenCalled(); }); + it('materializes inferred model-spec endpoints in the base config', async () => { + const deps = createDeps({ + loadBaseConfig: jest.fn().mockResolvedValue({ + modelSpecs: { + enforce: false, + prioritize: true, + list: [{ name: 'agent-spec', label: 'Agent Spec', preset: { agent_id: 'agent_abc' } }], + }, + }), + }); + const { getAppConfig } = createAppConfigService(deps); + + const config = await getAppConfig({ baseOnly: true }); + + expect(config.modelSpecs?.list?.[0]?.preset?.endpoint).toBe('agents'); + }); + + /** + * Admin-panel specs arrive through DB override documents the base config + * never saw, so materialization must also run on the merged result. + */ + it('materializes inferred model-spec endpoints contributed by DB overrides', async () => { + const deps = createDeps({ + getApplicableConfigs: jest.fn().mockResolvedValue([ + { + priority: 10, + isActive: true, + overrides: { + modelSpecs: { + list: [ + { name: 'agent-spec', label: 'Agent Spec', preset: { agent_id: 'agent_abc' } }, + ], + }, + }, + }, + ]), + }); + const { getAppConfig } = createAppConfigService(deps); + + const config = (await getAppConfig({ role: 'USER' })) as TestConfig; + + expect(config.modelSpecs?.list?.[0]?.preset?.endpoint).toBe('agents'); + expect(config.modelSpecs?.list?.[0]?.preset?.agent_id).toBe('agent_abc'); + }); + it('caches empty result — does not re-query DB on second call', async () => { const deps = createDeps({ getApplicableConfigs: jest.fn().mockResolvedValue([]) }); const { getAppConfig } = createAppConfigService(deps); diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index e9e2e7ad49..93cbfb957d 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -1,4 +1,4 @@ -import { PrincipalType } from 'librechat-data-provider'; +import { PrincipalType, materializeModelSpecEndpoints } from 'librechat-data-provider'; import { logger, getTenantId, @@ -10,6 +10,20 @@ import type { Types } from 'mongoose'; const BASE_CONFIG_KEY = '_BASE_'; +/** + * Materializes inferable model-spec fields (an omitted `preset.endpoint` for + * agent specs) so every consumer of the effective config reads complete specs. + * Runs at both assembly points — YAML base load and DB-override merge — because + * override documents contribute specs the base config never saw. + */ +function materializeConfigModelSpecs(config: AppConfig): AppConfig { + const modelSpecs = materializeModelSpecEndpoints(config.modelSpecs); + if (modelSpecs === config.modelSpecs) { + return config; + } + return { ...config, modelSpecs }; +} + export const DEFAULT_OVERRIDE_CACHE_TTL = 60_000; // ── Types ──────────────────────────────────────────────────────────── @@ -169,6 +183,8 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { throw new Error('Failed to initialize app configuration through AppService.'); } + baseConfig = materializeConfigModelSpecs(baseConfig); + if (baseConfig.availableTools) { await setCachedTools(baseConfig.availableTools); } @@ -241,7 +257,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { return baseConfig; } - const merged = mergeConfigOverrides(baseConfig, configs); + const merged = materializeConfigModelSpecs(mergeConfigOverrides(baseConfig, configs)); await cache.set(cacheKey, merged, overrideCacheTtl); return merged; } catch (error) { diff --git a/packages/api/src/modelSpecs/index.ts b/packages/api/src/modelSpecs/index.ts index 6fb5f9006e..e22c94f9f1 100644 --- a/packages/api/src/modelSpecs/index.ts +++ b/packages/api/src/modelSpecs/index.ts @@ -1,6 +1,7 @@ import { parseCompactConvo, replaceSpecialVars, + resolveModelSpecEndpoint, type EModelEndpoint, type TConversation, type TModelSpec, @@ -130,7 +131,7 @@ export function isModelSpecEndpointMatch( modelSpec: Pick | undefined, endpoint: string | null | undefined, ): boolean { - return Boolean(modelSpec && endpoint === modelSpec.preset?.endpoint); + return Boolean(modelSpec && endpoint === resolveModelSpecEndpoint(modelSpec)); } export function applyModelSpecPreset({ diff --git a/packages/api/src/modelSpecs/modelSpecs.test.ts b/packages/api/src/modelSpecs/modelSpecs.test.ts index 90287a4d4b..220c344aab 100644 --- a/packages/api/src/modelSpecs/modelSpecs.test.ts +++ b/packages/api/src/modelSpecs/modelSpecs.test.ts @@ -183,6 +183,47 @@ describe('modelSpecs helpers', () => { expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.google)).toBe(false); }); + /** + * A preset naming an `agent_id` can only be served by the agents endpoint, so + * omitting `endpoint` previously left the spec matching nothing at all. + */ + it('should infer the agents endpoint when a preset omits it but names an agent', () => { + const modelSpec: TModelSpec = { + name: 'agent-spec', + label: 'Agent Spec', + preset: { + agent_id: 'agent_abc', + }, + } as TModelSpec; + + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.agents)).toBe(true); + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.openAI)).toBe(false); + }); + + it('should keep an explicit endpoint over the inferred one', () => { + const modelSpec: TModelSpec = { + name: 'explicit-spec', + label: 'Explicit Spec', + preset: { + endpoint: EModelEndpoint.openAI, + agent_id: 'agent_abc', + }, + } as TModelSpec; + + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.openAI)).toBe(true); + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.agents)).toBe(false); + }); + + it('should not infer an endpoint for presets without an agent', () => { + const modelSpec: TModelSpec = { + name: 'bare-spec', + label: 'Bare Spec', + preset: {}, + } as TModelSpec; + + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.agents)).toBe(false); + }); + it('should resolve special variables in model spec prompt prefixes', () => { expect( resolveModelSpecPromptPrefixVariables({ promptPrefix: 'Help {{current_user}}.' }, { diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 4554423d39..58b32f7da2 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -20,7 +20,7 @@ import { ReasoningParameterFormat, ReasoningResponseKey, } from '../src/schemas'; -import { specsConfigSchema } from '../src/models'; +import { specsConfigSchema, materializeModelSpecEndpoints } from '../src/models'; import { FileSources } from '../src/types/files'; describe('paramDefinitionSchema', () => { @@ -1193,6 +1193,100 @@ describe('specsConfigSchema', () => { expect(result.success).toBe(false); }); + /** + * The endpoint is inferable from `agent_id`, so config validation must not + * reject the spec before `materializeModelSpecEndpoints` can fill it in. + */ + it('accepts an agent spec whose preset omits endpoint', () => { + const result = specsConfigSchema.safeParse({ + list: [ + { + name: 'agent-spec', + label: 'Agent Spec', + preset: { agent_id: 'agent_abc' }, + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.list[0].preset.agent_id).toBe('agent_abc'); + expect(result.data.list[0].preset.endpoint).toBeUndefined(); + } + }); + + /** Omission is only legal when inferable — a preset naming no agent still needs the key. */ + it('rejects an endpoint-less preset that names no agent', () => { + const result = specsConfigSchema.safeParse({ + list: [{ name: 'dead-spec', label: 'Dead Spec', preset: { model: 'gpt-4o' } }], + }); + expect(result.success).toBe(false); + }); + + /** `endpoint: null` validated before the key became optional; it must keep validating. */ + it('still accepts an explicit null endpoint without an agent', () => { + const result = specsConfigSchema.safeParse({ + list: [{ name: 'null-spec', label: 'Null Spec', preset: { endpoint: null } }], + }); + expect(result.success).toBe(true); + }); + + /** Form-backed writers persist untouched fields as `''`, which names no agent. */ + it('rejects an endpoint-less preset whose agent_id is an empty string', () => { + const result = specsConfigSchema.safeParse({ + list: [{ name: 'empty-agent', label: 'Empty Agent', preset: { agent_id: '' } }], + }); + expect(result.success).toBe(false); + }); + + /** + * An explicit `endpoint: null` is a statement, not an omission: such specs + * validated and stayed inert before inference existed, and must remain so. + */ + it('does not infer over an explicit null endpoint, even with an agent_id', () => { + const parsed = specsConfigSchema.parse({ + list: [ + { + name: 'null-agent-spec', + label: 'Null Agent Spec', + preset: { endpoint: null, agent_id: 'agent_abc' }, + }, + ], + }); + + const materialized = materializeModelSpecEndpoints(parsed); + + expect(materialized.list[0].preset.endpoint).toBeNull(); + expect(materialized).toBe(parsed); + }); + + it('materializes the inferred endpoint onto parsed agent specs', () => { + const parsed = specsConfigSchema.parse({ + list: [ + { name: 'agent-spec', label: 'Agent Spec', preset: { agent_id: 'agent_abc' } }, + { + name: 'explicit-spec', + label: 'Explicit Spec', + preset: { endpoint: EModelEndpoint.openAI, agent_id: 'agent_abc' }, + }, + { name: 'bare-spec', label: 'Bare Spec', preset: { endpoint: null } }, + ], + }); + + const materialized = materializeModelSpecEndpoints(parsed); + + expect(materialized.list[0].preset.endpoint).toBe(EModelEndpoint.agents); + expect(materialized.list[1].preset.endpoint).toBe(EModelEndpoint.openAI); + expect(materialized.list[1]).toBe(parsed.list[1]); + expect(materialized.list[2].preset.endpoint).toBeNull(); + }); + + it('returns the same object when every spec already has an endpoint', () => { + const parsed = specsConfigSchema.parse({ + list: [{ name: 'spec', label: 'Spec', preset: { endpoint: EModelEndpoint.openAI } }], + }); + expect(materializeModelSpecEndpoints(parsed)).toBe(parsed); + }); + it('rejects model spec subagent ids above the shared cap', () => { const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, i) => `agent_${i}`); const result = specsConfigSchema.safeParse({ diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index 1659b7bd0c..36cf7e4261 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -88,6 +88,80 @@ export const modelSpecSubagentsSchema = z.object({ agent_ids: z.array(z.string()).max(MAX_SUBAGENTS).optional(), }); +/** + * The endpoint a spec targets. Only the agents endpoint can serve a preset that + * names an `agent_id`, so an omitted `endpoint` is inferred rather than left + * undefined — otherwise the spec matches no endpoint at all, the client sends + * no endpoint when it is selected, and the request is rejected as a mismatch. + * + * Shared so the selector and the request pipeline resolve a spec identically. + */ +/** + * Structural view of the fields endpoint resolution reads, so partial spec + * shapes (e.g. `AppConfig['modelSpecs']`, which is deep-partial) qualify. + */ +type ModelSpecEndpointSource = { + preset?: Pick | null; +}; + +export function resolveModelSpecEndpoint( + modelSpec: ModelSpecEndpointSource | undefined, +): string | undefined { + const preset = modelSpec?.preset; + if (preset?.endpoint != null) { + return preset.endpoint; + } + /** + * An explicit `endpoint: null` is a statement, not an omission — such specs + * validated (and were skipped downstream) before inference existed, so + * inferring here would silently activate them. Only an absent key infers, + * and only from a non-empty `agent_id`: form-backed writers persist + * untouched fields as `''`, which names no agent. + */ + if (preset?.endpoint === null) { + return undefined; + } + return preset?.agent_id ? EModelEndpoint.agents : undefined; +} + +/** + * Writes each spec's resolved endpoint back onto its preset so every consumer — + * endpoint matching, the selector, access filters, startup presets, provider-key + * reachability — reads a complete spec instead of re-deriving it. Apply once + * where the effective config is assembled (YAML load and DB-override merge); + * downstream code then needs no awareness of inference. + * + * Returns the original object, and the original spec objects, when nothing + * needs filling in, so cached configs and memoized consumers see no new + * identities. + */ +export function materializeModelSpecEndpoints< + T extends { list?: ModelSpecEndpointSource[] } | null | undefined, +>(modelSpecs: T): T { + const list = modelSpecs?.list; + if (!list?.length) { + return modelSpecs; + } + + let changed = false; + const materialized = list.map((spec) => { + if (spec?.preset == null || spec.preset.endpoint != null) { + return spec; + } + const endpoint = resolveModelSpecEndpoint(spec); + if (endpoint == null) { + return spec; + } + changed = true; + return { ...spec, preset: { ...spec.preset, endpoint } }; + }); + + if (!changed) { + return modelSpecs; + } + return { ...modelSpecs, list: materialized } as T; +} + export const tModelSpecSchema = z.object({ name: z.string(), label: z.string(), diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index df6baaa41b..c007fb05d2 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1174,24 +1174,53 @@ export const tQueryParamsSchema = tConversationSchema * `spec` is set by the client from `modelSpec.name` via `getModelSpecPreset` and is * omitted to avoid duplicate configuration surface. */ -export const tModelSpecPresetSchema = tPresetSchema.omit({ - conversationId: true, - presetId: true, - title: true, - defaultPreset: true, - order: true, - isArchived: true, - user: true, - messages: true, - tags: true, - file_ids: true, - expiredAt: true, - parentMessageId: true, - resendImages: true, - chatGptLabel: true, - presetOverride: true, - spec: true, -}); +export const tModelSpecPresetSchema = tPresetSchema + .omit({ + conversationId: true, + presetId: true, + title: true, + defaultPreset: true, + order: true, + isArchived: true, + user: true, + messages: true, + tags: true, + file_ids: true, + expiredAt: true, + parentMessageId: true, + resendImages: true, + chatGptLabel: true, + presetOverride: true, + spec: true, + }) + .merge( + z.object({ + /** + * Optional here, unlike `tPresetSchema`, where the key is required (though + * nullable). A preset naming an `agent_id` has an unambiguous endpoint, so + * config may omit it and `resolveModelSpecEndpoint` infers `agents` when + * specs are materialized at config load. + */ + endpoint: extendedModelEndpointSchema.nullish(), + }), + ) + .superRefine((preset, ctx) => { + /** + * Omission is only legal when the endpoint is inferable, which requires a + * NON-EMPTY `agent_id` — form-backed writers persist untouched fields as + * `''`, which names no agent. An explicit `endpoint: null` stays accepted: + * it validated before the key became optional, so rejecting it now would + * break previously valid configs. + */ + if (preset.endpoint === undefined && !preset.agent_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['endpoint'], + message: + 'endpoint is required unless the preset names a non-empty agent_id (the agents endpoint is then inferred)', + }); + } + }); export type TModelSpecPreset = z.infer; diff --git a/packages/data-schemas/src/app/specs.spec.ts b/packages/data-schemas/src/app/specs.spec.ts new file mode 100644 index 0000000000..6bd24ca2d0 --- /dev/null +++ b/packages/data-schemas/src/app/specs.spec.ts @@ -0,0 +1,86 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import { processModelSpecs } from './specs'; + +const specsConfig = (list: unknown[]): TCustomConfig['modelSpecs'] => + ({ enforce: false, prioritize: true, list }) as TCustomConfig['modelSpecs']; + +describe('processModelSpecs', () => { + it('returns undefined without model specs', () => { + expect(processModelSpecs(undefined, undefined, undefined)).toBeUndefined(); + }); + + it('keeps specs targeting system endpoints', () => { + const result = processModelSpecs( + undefined, + specsConfig([ + { name: 'openai-spec', label: 'OpenAI', preset: { endpoint: EModelEndpoint.openAI } }, + ]), + undefined, + ); + + expect(result?.list).toHaveLength(1); + }); + + /** + * The missing-endpoint guard runs after materialization, so an agent spec + * that omits its endpoint is inferred rather than skipped. + */ + it('materializes the agents endpoint for agent specs before the missing-endpoint guard', () => { + const result = processModelSpecs( + undefined, + specsConfig([{ name: 'agent-spec', label: 'Agent Spec', preset: { agent_id: 'agent_abc' } }]), + undefined, + ); + + expect(result?.list).toHaveLength(1); + expect(result?.list?.[0]?.preset?.endpoint).toBe(EModelEndpoint.agents); + expect(result?.list?.[0]?.preset?.agent_id).toBe('agent_abc'); + }); + + it('still skips endpoint-less specs that name no agent', () => { + const result = processModelSpecs( + undefined, + specsConfig([ + { name: 'dead-spec', label: 'Dead Spec', preset: { model: 'gpt-4o' } }, + { name: 'live-spec', label: 'Live Spec', preset: { endpoint: EModelEndpoint.openAI } }, + ]), + undefined, + ); + + expect(result?.list?.map((spec) => spec?.name)).toEqual(['live-spec']); + }); + + /** Prior behavior for previously valid configs: a null endpoint means skip, even with an agent. */ + it('still skips specs with an explicit null endpoint, even when they name an agent', () => { + const result = processModelSpecs( + undefined, + specsConfig([ + { + name: 'null-agent-spec', + label: 'Null Agent Spec', + preset: { endpoint: null, agent_id: 'agent_abc' }, + }, + ]), + undefined, + ); + + expect(result?.list).toHaveLength(0); + }); + + it('keeps an explicit endpoint over the inferred one', () => { + const result = processModelSpecs( + undefined, + specsConfig([ + { + name: 'explicit-spec', + label: 'Explicit Spec', + preset: { endpoint: EModelEndpoint.openAI, agent_id: 'agent_abc' }, + }, + ]), + undefined, + ); + + expect(result?.list?.[0]?.preset?.endpoint).toBe(EModelEndpoint.openAI); + }); +}); diff --git a/packages/data-schemas/src/app/specs.ts b/packages/data-schemas/src/app/specs.ts index 4b8ce3d42e..e21558ae96 100644 --- a/packages/data-schemas/src/app/specs.ts +++ b/packages/data-schemas/src/app/specs.ts @@ -1,6 +1,10 @@ -import logger from '~/config/winston'; -import { EModelEndpoint, normalizeEndpointName } from 'librechat-data-provider'; +import { + EModelEndpoint, + normalizeEndpointName, + materializeModelSpecEndpoints, +} from 'librechat-data-provider'; import type { TCustomConfig } from 'librechat-data-provider'; +import logger from '~/config/winston'; /** * Sets up Model Specs from the config (`librechat.yaml`) file. @@ -18,7 +22,12 @@ export function processModelSpecs( return undefined; } - const list = _modelSpecs.list; + /** + * Fill inferable endpoints (agent specs may omit one) before the + * missing-endpoint guard below, which would otherwise skip them. + */ + const specsConfig = materializeModelSpecEndpoints(_modelSpecs); + const list = specsConfig.list; const modelSpecs: typeof list = []; const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; @@ -85,7 +94,7 @@ For more information, see the documentation at https://www.librechat.ai/docs/con } return { - ..._modelSpecs, + ...specsConfig, list: modelSpecs, }; }