🛟 fix: Allow Empty modelSpecs.list to Unstick Admin-Panel Saves (#13036)

* 🛟 fix: Allow empty modelSpecs.list to unstick admin-panel saves

The unconditional `.min(1)` on `specsConfigSchema.list` rejected an empty
list even when `enforce: false`, leaving admin panels (which save fields
path-granularly) with no atomic way to clear the list once it had been
populated. Once an admin reached `list: [entry]` and deleted the only
entry, every subsequent save failed schema validation and the section
became stuck.

Relax the schema to `.default([])`. The `.min(1)` was added in #5218 as
part of bundled cleanup, not as a deliberate rule. Every consumer of
`modelSpecs.list` already handles the empty/undefined case (`?.list`,
`?? []`, length-checked), and `processModelSpecs` short-circuits to
`undefined` when the list is empty so the runtime treats it as "no
specs configured." No call site is load-bearing on length >= 1.

Tighten the `buildEndpointOption.js` enforce guard from
`?.list && ?.enforce` to `?.list?.length && ?.enforce`. Empty arrays
are truthy in JS, so the existing guard would have entered the enforce
branch on `list: []` and returned "No model spec selected" or "Invalid
model spec" had `processModelSpecs` ever been bypassed.

Add a runtime warn in `processModelSpecs` when `enforce: true` is
configured alongside an empty list, so operators see the resulting
"enforcement disabled" state in logs rather than silently getting a
permissive runtime.

Add coverage for the empty-list parse path in `config-schemas.spec.ts`
and for the empty-list-with-enforce branch in `buildEndpointOption.spec.js`.

* chore: update import order in config-schemas.spec.ts
This commit is contained in:
Dustin Healy 2026-05-09 08:39:15 -07:00 committed by GitHub
parent ac3600cdd7
commit 0d5c2b339a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 77 additions and 2 deletions

View file

@ -48,7 +48,7 @@ async function buildEndpointOption(req, res, next) {
}
const appConfig = req.config;
if (appConfig.modelSpecs?.list && appConfig.modelSpecs?.enforce) {
if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) {
/** @type {{ list: TModelSpec[] }}*/
const { list } = appConfig.modelSpecs;
const { spec } = parsedBody;

View file

@ -234,4 +234,34 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
expect(parsedResult.maxOutputTokens).toBeUndefined();
expect(parsedResult.max_tokens).toBe(4096);
});
it('should not enter the enforce branch when modelSpecs.list is empty', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
model: 'gpt-4',
},
{
modelSpecs: {
enforce: true,
list: [],
},
},
);
const res = createRes();
const { handleError } = require('@librechat/api');
await buildEndpointOption(req, res, jest.fn());
expect(handleError).not.toHaveBeenCalledWith(
res,
expect.objectContaining({ text: 'No model spec selected' }),
);
expect(handleError).not.toHaveBeenCalledWith(
res,
expect.objectContaining({ text: 'Invalid model spec' }),
);
});
});

View file

@ -11,6 +11,7 @@ import {
summarizationConfigSchema,
} from '../src/config';
import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas';
import { specsConfigSchema } from '../src/models';
import { FileSources } from '../src/types/files';
describe('paramDefinitionSchema', () => {
@ -718,3 +719,42 @@ describe('summarizationTriggerSchema', () => {
expect(result.success).toBe(true);
});
});
describe('specsConfigSchema', () => {
it('accepts an empty list (defaults applied)', () => {
const result = specsConfigSchema.safeParse({ list: [] });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.list).toEqual([]);
expect(result.data.enforce).toBe(false);
expect(result.data.prioritize).toBe(true);
}
});
it('defaults list to [] when omitted', () => {
const result = specsConfigSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.list).toEqual([]);
}
});
it('accepts a populated list', () => {
const result = specsConfigSchema.safeParse({
enforce: true,
list: [
{
name: 'spec-1',
label: 'Spec 1',
preset: { endpoint: EModelEndpoint.openAI },
},
],
});
expect(result.success).toBe(true);
});
it('still rejects null list', () => {
const result = specsConfigSchema.safeParse({ list: null });
expect(result.success).toBe(false);
});
});

View file

@ -62,7 +62,7 @@ export const tModelSpecSchema = z.object({
export const specsConfigSchema = z.object({
enforce: z.boolean().default(false),
prioritize: z.boolean().default(true),
list: z.array(tModelSpecSchema).min(1),
list: z.array(tModelSpecSchema).default([]),
addedEndpoints: z.array(z.union([z.string(), eModelEndpointSchema])).optional(),
});

View file

@ -37,6 +37,11 @@ export function processModelSpecs(
}
if (!list || list.length === 0) {
if (_modelSpecs.enforce) {
logger.warn(
'modelSpecs.enforce is true but list is empty — enforcement disabled at runtime.',
);
}
return undefined;
}