mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🎯 refactor: Infer Agents Endpoint for Model Specs Naming an Agent (#14889)
* 🎯 fix: Infer Agents Endpoint for Model Specs Naming an Agent A model spec whose preset names an `agent_id` but omits `endpoint` was unusable. `isModelSpecEndpointMatch` compares the request's endpoint to `preset.endpoint` by strict equality, so an undefined endpoint matched nothing and every request selecting the spec was rejected with a bare `Model spec mismatch` — an error naming neither the spec nor the missing field. The selector had the matching half of the same gap: `handleSelectSpec` read `preset.endpoint` directly, so it sent no endpoint and skipped assigning `agent_id` to `model`. Fixing only the server would leave the request malformed, so the resolution is shared between both. - Add `resolveModelSpecEndpoint` to `librechat-data-provider`, inferring the agents endpoint when a preset names an agent and none is set. An explicit `endpoint` always wins, so configured specs are unaffected. - Use it for endpoint matching and in the selector, so the menu and the request pipeline resolve a spec identically. * 🔁 refactor: Materialize Inferred Spec Endpoints at Config Load The review showed the lazy-resolver approach was unsound end to end: config validation rejected an endpoint-less spec before the resolver could ever run (`tPresetSchema` requires the `endpoint` key), and the resolver was applied at 2 of ~8 read sites, leaving selection handlers, startup presets, access filters, and provider-key reachability reading the raw preset. Materialize once at the boundary instead: - `tModelSpecPresetSchema` now makes `endpoint` optional (`nullish`). This is barely a widening — `endpoint: null` already validated — and only for model-spec presets; `tPresetSchema` is untouched. - `materializeModelSpecEndpoints` writes each spec's resolved endpoint back onto its preset. `createAppConfigService` applies it at both effective-config assembly points — YAML base load and DB-override merge — so admin-panel specs stored in override documents are covered. Identity-preserving, so cached configs see no new references when nothing needs filling in. - Every consumer now reads complete specs; the client's lazy resolve in `handleSelectSpec` is reverted to a raw read. `getModelSpecPreset` and the two hand-rolled preset constructions resolve the endpoint explicitly, which the narrowed preset type now enforces at compile time for any `TPreset`-shaped destination. - `isModelSpecEndpointMatch` keeps the resolver as request-time defense. * 🩹 fix: Materialize Spec Endpoints Before the YAML Missing-Endpoint Guard `processModelSpecs` warns and skips any spec whose preset lacks an endpoint, and it runs inside `loadBaseConfig` — so the previous commit's materialization received a YAML list from which the inferable spec had already been dropped. Only DB-override specs (merged after the guard) actually benefited. - Materialize at the entry of `processModelSpecs`, so inference happens before the guard and YAML agent specs survive it. The guard keeps skipping genuinely endpoint-less specs. The `createAppConfigService` calls stay: the base-path one guards alternate `loadBaseConfig` implementations, the merged-path one covers override documents, and both are identity-preserving no-ops when specs are already complete. - Constrain the widened schema: omitting `endpoint` is only legal when the preset names an `agent_id`. A preset with neither validated as a hard error before the key became optional, and silently accepting it would trade that startup-time error for a dead spec. An explicit `endpoint: null` (valid before this PR) keeps validating. * 🩹 fix: Infer Only From Non-Empty Agent IDs, Never Over Explicit Null Two edge cases in the inference contract: - `agent_id: ''` (what a form-backed writer persists for an untouched field) passed the nullish checks, validating and materializing a spec that names no agent. Both the refinement and the resolver now require a non-empty id, so such config fails validation loudly instead of producing a selectable spec that cannot work. - `endpoint: null` alongside an `agent_id` was treated as inferable, silently activating a spec that validated — and was skipped — before this PR. An explicit null is a statement, not an omission: the resolver now infers only when the key is absent, preserving prior behavior for previously valid configs.
This commit is contained in:
parent
ed081964d6
commit
bce93f9c55
13 changed files with 437 additions and 29 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
parseCompactConvo,
|
||||
replaceSpecialVars,
|
||||
resolveModelSpecEndpoint,
|
||||
type EModelEndpoint,
|
||||
type TConversation,
|
||||
type TModelSpec,
|
||||
|
|
@ -130,7 +131,7 @@ export function isModelSpecEndpointMatch(
|
|||
modelSpec: Pick<TModelSpec, 'preset'> | undefined,
|
||||
endpoint: string | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(modelSpec && endpoint === modelSpec.preset?.endpoint);
|
||||
return Boolean(modelSpec && endpoint === resolveModelSpecEndpoint(modelSpec));
|
||||
}
|
||||
|
||||
export function applyModelSpecPreset({
|
||||
|
|
|
|||
|
|
@ -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}}.' }, {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<TModelSpecPreset, 'endpoint' | 'agent_id'> | 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(),
|
||||
|
|
|
|||
|
|
@ -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<typeof tModelSpecPresetSchema>;
|
||||
|
||||
|
|
|
|||
86
packages/data-schemas/src/app/specs.spec.ts
Normal file
86
packages/data-schemas/src/app/specs.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue