mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
🛟 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:
parent
ac3600cdd7
commit
0d5c2b339a
5 changed files with 77 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue