🧹 chore: Clean Up Config Fields (#12537)

* chore: remove unused `interface.endpointsMenu` config field

* chore: address review — restore JSDoc UI-only example, add Zod strip test

* chore: remove unused `interface.sidePanel` config field

* chore: restrict fileStrategy/fileStrategies schema to valid storage backends

* fix: use valid FileStorage value in AppService test

* chore: address review — version bump, exhaustiveness guard, JSDoc, configSchema test

* chore: remove debug logger.log from MessageIcon render path

* fix: rewrite MessageIcon render tests to use render counting instead of logger spying

* chore: bump librechat-data-provider to 0.8.407

* chore: sync example YAML version to 1.3.7
This commit is contained in:
Danny Avila 2026-04-03 12:22:58 -04:00 committed by GitHub
parent b4d97bd888
commit ea28dbfa89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 211 additions and 190 deletions

View file

@ -1,10 +1,10 @@
import { useMemo, useEffect, useRef, memo } from 'react';
import { useMemo, memo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { Assistant, Agent } from 'librechat-data-provider';
import type { TMessageIcon } from '~/common';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint, logger } from '~/utils';
import { getIconEndpoint } from '~/utils';
import Icon from '~/components/Endpoints/Icon';
type MessageIconProps = {
@ -19,25 +19,20 @@ type MessageIconProps = {
* this component renders display properties only, not identity-derived content.
*/
export function arePropsEqual(prev: MessageIconProps, next: MessageIconProps): boolean {
const checks: [string, unknown, unknown][] = [
['iconData.endpoint', prev.iconData?.endpoint, next.iconData?.endpoint],
['iconData.model', prev.iconData?.model, next.iconData?.model],
['iconData.iconURL', prev.iconData?.iconURL, next.iconData?.iconURL],
['iconData.modelLabel', prev.iconData?.modelLabel, next.iconData?.modelLabel],
['iconData.isCreatedByUser', prev.iconData?.isCreatedByUser, next.iconData?.isCreatedByUser],
['agent.name', prev.agent?.name, next.agent?.name],
['agent.avatar.filepath', prev.agent?.avatar?.filepath, next.agent?.avatar?.filepath],
['assistant.name', prev.assistant?.name, next.assistant?.name],
[
'assistant.metadata.avatar',
prev.assistant?.metadata?.avatar,
next.assistant?.metadata?.avatar,
],
const checks: [unknown, unknown][] = [
[prev.iconData?.endpoint, next.iconData?.endpoint],
[prev.iconData?.model, next.iconData?.model],
[prev.iconData?.iconURL, next.iconData?.iconURL],
[prev.iconData?.modelLabel, next.iconData?.modelLabel],
[prev.iconData?.isCreatedByUser, next.iconData?.isCreatedByUser],
[prev.agent?.name, next.agent?.name],
[prev.agent?.avatar?.filepath, next.agent?.avatar?.filepath],
[prev.assistant?.name, next.assistant?.name],
[prev.assistant?.metadata?.avatar, next.assistant?.metadata?.avatar],
];
for (const [field, prevVal, nextVal] of checks) {
for (const [prevVal, nextVal] of checks) {
if (prevVal !== nextVal) {
logger.log('icon_memo_diff', `field "${field}" changed:`, prevVal, '→', nextVal);
return false;
}
}
@ -45,28 +40,6 @@ export function arePropsEqual(prev: MessageIconProps, next: MessageIconProps): b
}
const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
useEffect(() => {
logger.log(
'icon_lifecycle',
'MOUNT',
iconData?.modelLabel,
`render #${renderCountRef.current}`,
);
return () => {
logger.log('icon_lifecycle', 'UNMOUNT', iconData?.modelLabel);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
logger.log(
'icon_data',
`render #${renderCountRef.current}`,
iconData?.isCreatedByUser ? 'user' : iconData?.modelLabel,
iconData,
);
const { data: endpointsConfig } = useGetEndpointsQuery();
const agentName = agent?.name ?? '';

View file

@ -11,29 +11,25 @@ jest.mock('librechat-data-provider', () => ({
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: jest.fn(() => ({ data: {} })),
}));
// logger is a plain object with a real function — not a jest.fn() —
// so restoreMocks/clearMocks won't touch it. We spy on it per-test instead.
const logCalls: unknown[][] = [];
jest.mock('~/utils', () => ({
getIconEndpoint: jest.fn(() => 'agents'),
logger: {
log: (...args: unknown[]) => {
logCalls.push(args);
},
},
}));
const iconRenderCount = { current: 0 };
jest.mock('~/components/Endpoints/ConvoIconURL', () => {
const ConvoIconURL = (props: Record<string, unknown>) => (
<div data-testid="convo-icon-url" data-icon-url={props.iconURL as string} />
);
const ConvoIconURL = (props: Record<string, unknown>) => {
iconRenderCount.current += 1;
return <div data-testid="convo-icon-url" data-icon-url={props.iconURL as string} />;
};
ConvoIconURL.displayName = 'ConvoIconURL';
return { __esModule: true, default: ConvoIconURL };
});
jest.mock('~/components/Endpoints/Icon', () => {
const Icon = (props: Record<string, unknown>) => (
<div data-testid="icon" data-icon-url={props.iconURL as string} />
);
const Icon = (props: Record<string, unknown>) => {
iconRenderCount.current += 1;
return <div data-testid="icon" data-icon-url={props.iconURL as string} />;
};
Icon.displayName = 'Icon';
return { __esModule: true, default: Icon };
});
@ -58,79 +54,68 @@ const baseIconData: TMessageIcon = {
describe('MessageIcon render cycles', () => {
beforeEach(() => {
logCalls.length = 0;
iconRenderCount.current = 0;
});
it('renders once on initial mount', () => {
render(<MessageIcon iconData={baseIconData} agent={makeAgent()} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(1);
expect(iconRenderCount.current).toBe(1);
});
it('does not re-render when parent re-renders with same field values but new object references', () => {
const agent = makeAgent();
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent} />);
logCalls.length = 0;
iconRenderCount.current = 0;
// Simulate parent re-render: new iconData object (same field values), new agent object (same data)
rerender(<MessageIcon iconData={{ ...baseIconData }} agent={makeAgent()} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(0);
expect(iconRenderCount.current).toBe(0);
});
it('does not re-render when agent object reference changes but name and avatar are the same', () => {
const agent1 = makeAgent();
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent1} />);
logCalls.length = 0;
iconRenderCount.current = 0;
// New agent object with different id but same display fields
const agent2 = makeAgent({ id: 'agent_456' });
rerender(<MessageIcon iconData={baseIconData} agent={agent2} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(0);
expect(iconRenderCount.current).toBe(0);
});
it('re-renders when agent avatar filepath changes', () => {
const agent1 = makeAgent();
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent1} />);
logCalls.length = 0;
iconRenderCount.current = 0;
const agent2 = makeAgent({ avatar: { filepath: '/images/new-avatar.png' } });
rerender(<MessageIcon iconData={baseIconData} agent={agent2} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(1);
expect(iconRenderCount.current).toBe(1);
});
it('re-renders when agent goes from undefined to defined (name changes from undefined to string)', () => {
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={undefined} />);
logCalls.length = 0;
iconRenderCount.current = 0;
rerender(<MessageIcon iconData={baseIconData} agent={makeAgent()} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(1);
expect(iconRenderCount.current).toBe(1);
});
describe('simulates message lifecycle', () => {
it('renders exactly twice during new message + streaming start: initial render + modelLabel update', () => {
// Phase 1: Initial response message created by useChatFunctions
// model is set to agent_id, iconURL is undefined, modelLabel is '' or sender
const initialIconData: TMessageIcon = {
endpoint: EModelEndpoint.agents,
model: 'agent_123',
iconURL: undefined,
modelLabel: '', // Not yet resolved
modelLabel: '',
isCreatedByUser: false,
};
const agent = makeAgent();
const { rerender } = render(<MessageIcon iconData={initialIconData} agent={agent} />);
// Phase 2: First streaming chunk arrives, messageLabel resolves to agent name
// This is a legitimate re-render — modelLabel changed from '' to 'GitHub Agent'
const streamingIconData: TMessageIcon = {
...initialIconData,
modelLabel: 'GitHub Agent',
@ -138,9 +123,7 @@ describe('MessageIcon render cycles', () => {
rerender(<MessageIcon iconData={streamingIconData} agent={agent} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
// Exactly 2: initial mount + modelLabel change
expect(iconDataCalls).toHaveLength(2);
expect(iconRenderCount.current).toBe(2);
});
it('does NOT re-render on subsequent streaming chunks (content changes, isSubmitting stays true)', () => {
@ -154,17 +137,13 @@ describe('MessageIcon render cycles', () => {
const agent = makeAgent();
const { rerender } = render(<MessageIcon iconData={iconData} agent={agent} />);
logCalls.length = 0;
iconRenderCount.current = 0;
// Simulate multiple parent re-renders from streaming chunks
// Parent (ContentRender) re-renders because chatContext changed,
// but MessageIcon props are identical field-by-field
for (let i = 0; i < 5; i++) {
rerender(<MessageIcon iconData={{ ...iconData }} agent={makeAgent()} />);
}
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(0);
expect(iconRenderCount.current).toBe(0);
});
it('does NOT re-render when agentsMap context updates with same agent data', () => {
@ -176,18 +155,15 @@ describe('MessageIcon render cycles', () => {
isCreatedByUser: false,
};
// First render with agent from original agentsMap
const agent1 = makeAgent();
const { rerender } = render(<MessageIcon iconData={iconData} agent={agent1} />);
logCalls.length = 0;
iconRenderCount.current = 0;
// agentsMap refetched → new agent object, same display data
const agent2 = makeAgent();
expect(agent1).not.toBe(agent2); // different reference
expect(agent1).not.toBe(agent2);
rerender(<MessageIcon iconData={iconData} agent={agent2} />);
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
expect(iconDataCalls).toHaveLength(0);
expect(iconRenderCount.current).toBe(0);
});
});
});

View file

@ -2,7 +2,7 @@
# https://www.librechat.ai/docs/configuration/librechat_yaml
# Configuration version (required)
version: 1.3.6
version: 1.3.7
# Cache settings: Set to true to enable caching
cache: true
@ -80,10 +80,8 @@ interface:
By using the Website, you acknowledge that you have read these Terms of Service and agree to be bound by them.
endpointsMenu: true
modelSelect: true
parameters: true
sidePanel: true
presets: true
prompts:
use: true

View file

@ -54,7 +54,7 @@ function createHandlers(overrides = {}) {
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
hasConfigCapability: jest.fn().mockResolvedValue(true),
getAppConfig: jest.fn().mockResolvedValue({ interface: { endpointsMenu: true } }),
getAppConfig: jest.fn().mockResolvedValue({ interface: { modelSelect: true } }),
...overrides,
};
const handlers = createAdminConfigHandlers(deps);
@ -133,7 +133,7 @@ describe('createAdminConfigHandlers', () => {
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { overrides: { interface: { endpointsMenu: false } } },
body: { overrides: { interface: { modelSelect: false } } },
});
const res = mockRes();
@ -148,7 +148,7 @@ describe('createAdminConfigHandlers', () => {
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { overrides: { interface: { endpointsMenu: false } } },
body: { overrides: { interface: { modelSelect: false } } },
});
const res = mockRes();
@ -178,7 +178,7 @@ describe('createAdminConfigHandlers', () => {
params: { principalType: 'role', principalId: 'admin' },
body: {
overrides: {
interface: { endpointsMenu: false, prompts: false, agents: { use: false } },
interface: { modelSelect: false, prompts: false, agents: { use: false } },
},
},
});
@ -188,7 +188,7 @@ describe('createAdminConfigHandlers', () => {
expect(res.statusCode).toBe(201);
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
expect(savedOverrides.interface).toEqual({ endpointsMenu: false });
expect(savedOverrides.interface).toEqual({ modelSelect: false });
});
it('preserves UI sub-keys in composite permission fields like mcpServers', async () => {
@ -263,17 +263,13 @@ describe('createAdminConfigHandlers', () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
query: { fieldPath: 'interface.endpointsMenu' },
query: { fieldPath: 'interface.modelSelect' },
});
const res = mockRes();
await handlers.deleteConfigField(req, res);
expect(deps.unsetConfigField).toHaveBeenCalledWith(
'role',
'admin',
'interface.endpointsMenu',
);
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect');
});
it('allows deleting mcpServers UI sub-key paths', async () => {
@ -343,18 +339,14 @@ describe('createAdminConfigHandlers', () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
query: { fieldPath: 'interface.endpointsMenu' },
query: { fieldPath: 'interface.modelSelect' },
});
const res = mockRes();
await handlers.deleteConfigField(req, res);
expect(res.statusCode).toBe(200);
expect(deps.unsetConfigField).toHaveBeenCalledWith(
'role',
'admin',
'interface.endpointsMenu',
);
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect');
});
it('returns 400 when fieldPath query param is missing', async () => {
@ -407,7 +399,7 @@ describe('createAdminConfigHandlers', () => {
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [
{ fieldPath: 'interface.endpointsMenu', value: false },
{ fieldPath: 'interface.modelSelect', value: false },
{ fieldPath: 'interface.prompts', value: false },
],
},
@ -418,7 +410,7 @@ describe('createAdminConfigHandlers', () => {
expect(res.statusCode).toBe(200);
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
expect(patchedFields['interface.endpointsMenu']).toBe(false);
expect(patchedFields['interface.modelSelect']).toBe(false);
expect(patchedFields['interface.prompts']).toBeUndefined();
});
@ -632,21 +624,21 @@ describe('createAdminConfigHandlers', () => {
name: 'upsertConfigOverrides',
reqOverrides: {
params: { principalType: 'role', principalId: 'admin' },
body: { overrides: { interface: { endpointsMenu: false } } },
body: { overrides: { interface: { modelSelect: false } } },
},
},
{
name: 'patchConfigField',
reqOverrides: {
params: { principalType: 'role', principalId: 'admin' },
body: { entries: [{ fieldPath: 'interface.endpointsMenu', value: false }] },
body: { entries: [{ fieldPath: 'interface.modelSelect', value: false }] },
},
},
{
name: 'deleteConfigField',
reqOverrides: {
params: { principalType: 'role', principalId: 'admin' },
query: { fieldPath: 'interface.endpointsMenu' },
query: { fieldPath: 'interface.modelSelect' },
},
},
{
@ -775,7 +767,7 @@ describe('createAdminConfigHandlers', () => {
await handlers.getBaseConfig(req, res);
expect(res.statusCode).toBe(200);
expect(res.body!.config).toEqual({ interface: { endpointsMenu: true } });
expect(res.body!.config).toEqual({ interface: { modelSelect: true } });
});
});
});

View file

@ -2,7 +2,7 @@ import { isValidFieldPath, getTopLevelSection } from './config';
describe('isValidFieldPath', () => {
it('accepts simple dot paths', () => {
expect(isValidFieldPath('interface.endpointsMenu')).toBe(true);
expect(isValidFieldPath('interface.modelSelect')).toBe(true);
expect(isValidFieldPath('registration.socialLogins')).toBe(true);
expect(isValidFieldPath('a')).toBe(true);
expect(isValidFieldPath('a.b.c.d')).toBe(true);
@ -47,7 +47,7 @@ describe('isValidFieldPath', () => {
describe('getTopLevelSection', () => {
it('returns first segment of a dot path', () => {
expect(getTopLevelSection('interface.endpointsMenu')).toBe('interface');
expect(getTopLevelSection('interface.modelSelect')).toBe('interface');
expect(getTopLevelSection('registration.socialLogins.github')).toBe('registration');
});

View file

@ -40,7 +40,7 @@ export function getTopLevelSection(fieldPath: string): string {
* - `"interface.mcpServers.use"` true (permission sub-key)
* - `"interface.mcpServers.placeholder"` false (UI-only sub-key)
* - `"interface.peoplePicker.users"` true (all peoplePicker sub-keys are permissions)
* - `"interface.endpointsMenu"` false (UI-only field)
* - `"interface.modelSelect"` false (UI-only field)
*/
function isInterfacePermissionPath(fieldPath: string): boolean {
const parts = fieldPath.split('.');

View file

@ -85,7 +85,7 @@ describe('AppService', () => {
it('should correctly assign process.env and initialize app config based on custom config', async () => {
const config: Partial<TCustomConfig> = {
registration: { socialLogins: ['testLogin'] },
fileStrategy: 'testStrategy' as FileSources,
fileStrategy: FileSources.s3,
balance: {
enabled: true,
},
@ -93,22 +93,20 @@ describe('AppService', () => {
const result = await AppService({ config, systemTools: mockSystemTools });
expect(process.env.CDN_PROVIDER).toEqual('testStrategy');
expect(process.env.CDN_PROVIDER).toEqual('s3');
expect(result).toEqual(
expect.objectContaining({
config: expect.objectContaining({
fileStrategy: 'testStrategy',
fileStrategy: 's3',
}),
registration: expect.objectContaining({
socialLogins: ['testLogin'],
}),
fileStrategy: 'testStrategy',
fileStrategy: 's3',
interfaceConfig: expect.objectContaining({
endpointsMenu: true,
modelSelect: true,
parameters: true,
sidePanel: true,
presets: true,
}),
mcpConfig: null,

View file

@ -192,16 +192,13 @@ export function checkInterfaceConfig(appConfig: AppConfig) {
if (i === 0) i++;
}
// warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
// warn about config.modelSpecs.enforce if true and if any of these, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
if (
appConfig?.modelSpecs?.enforce &&
(interfaceConfig?.endpointsMenu ||
interfaceConfig?.modelSelect ||
interfaceConfig?.presets ||
interfaceConfig?.parameters)
(interfaceConfig?.modelSelect || interfaceConfig?.presets || interfaceConfig?.parameters)
) {
logger.warn(
"Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.",
"Note: Enforcing model specs can conflict with the interface options: modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.",
);
if (i === 0) i++;
}

View file

@ -10,7 +10,7 @@ const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
version: '1.0.0',
cache: true,
interface: {
endpointsMenu: true,
modelSelect: true,
},
registration: {
socialLogins: [],

View file

@ -34,7 +34,7 @@ function createMockCache(namespace = 'app_config') {
function createDeps(overrides = {}) {
const cache = createMockCache();
const baseConfig = { interfaceConfig: { endpointsMenu: true }, endpoints: ['openAI'] };
const baseConfig = { interfaceConfig: { modelSelect: true }, endpoints: ['openAI'] };
return {
loadBaseConfig: jest.fn().mockResolvedValue(baseConfig),
@ -79,7 +79,7 @@ describe('createAppConfigService', () => {
getApplicableConfigs: jest
.fn()
.mockResolvedValue([
{ priority: 10, overrides: { interface: { endpointsMenu: false } }, isActive: true },
{ priority: 10, overrides: { interface: { modelSelect: false } }, isActive: true },
]),
});
const { getAppConfig } = createAppConfigService(deps);
@ -125,7 +125,7 @@ describe('createAppConfigService', () => {
getApplicableConfigs: jest
.fn()
.mockResolvedValue([
{ priority: 10, overrides: { interface: { endpointsMenu: false } }, isActive: true },
{ priority: 10, overrides: { interface: { modelSelect: false } }, isActive: true },
]),
});
const { getAppConfig } = createAppConfigService(deps);
@ -133,7 +133,7 @@ describe('createAppConfigService', () => {
const config = await getAppConfig({ role: 'ADMIN' });
const merged = config as TestConfig;
expect(merged.interfaceConfig?.endpointsMenu).toBe(false);
expect(merged.interfaceConfig?.modelSelect).toBe(false);
expect(merged.endpoints).toEqual(['openAI']);
});

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.8.406",
"version": "0.8.407",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

View file

@ -4,8 +4,12 @@ import {
azureEndpointSchema,
endpointSchema,
configSchema,
interfaceSchema,
fileStorageSchema,
fileStrategiesSchema,
} from '../src/config';
import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas';
import { FileSources } from '../src/types/files';
describe('paramDefinitionSchema', () => {
it('accepts a minimal definition with only key', () => {
@ -421,3 +425,80 @@ describe('azureEndpointSchema', () => {
expect(result.success).toBe(false);
});
});
describe('fileStorageSchema', () => {
const validStrategies = [
FileSources.local,
FileSources.firebase,
FileSources.s3,
FileSources.azure_blob,
];
const invalidStrategies = [
FileSources.openai,
FileSources.azure,
FileSources.vectordb,
FileSources.execute_code,
FileSources.mistral_ocr,
FileSources.azure_mistral_ocr,
FileSources.vertexai_mistral_ocr,
FileSources.text,
FileSources.document_parser,
];
for (const strategy of validStrategies) {
it(`accepts storage strategy "${strategy}"`, () => {
expect(fileStorageSchema.safeParse(strategy).success).toBe(true);
});
}
for (const strategy of invalidStrategies) {
it(`rejects processing strategy "${strategy}"`, () => {
expect(fileStorageSchema.safeParse(strategy).success).toBe(false);
});
}
});
describe('fileStrategiesSchema', () => {
it('accepts valid storage strategies for all sub-fields', () => {
const result = fileStrategiesSchema.safeParse({
default: FileSources.s3,
avatar: FileSources.local,
image: FileSources.firebase,
document: FileSources.azure_blob,
});
expect(result.success).toBe(true);
});
it('rejects processing strategies in sub-fields', () => {
const result = fileStrategiesSchema.safeParse({
default: FileSources.vectordb,
});
expect(result.success).toBe(false);
});
});
describe('configSchema fileStrategy', () => {
it('rejects a processing strategy as fileStrategy', () => {
const result = configSchema.safeParse({ version: '1.3.7', fileStrategy: FileSources.vectordb });
expect(result.success).toBe(false);
});
it('defaults fileStrategy to local when absent', () => {
const result = configSchema.safeParse({ version: '1.3.7' });
expect(result.success).toBe(true);
expect(result.data?.fileStrategy).toBe(FileSources.local);
});
});
describe('interfaceSchema', () => {
it('silently strips removed legacy fields', () => {
const result = interfaceSchema.parse({
endpointsMenu: true,
sidePanel: true,
modelSelect: false,
});
expect(result).not.toHaveProperty('endpointsMenu');
expect(result).not.toHaveProperty('sidePanel');
expect(result.modelSelect).toBe(false);
});
});

View file

@ -62,14 +62,27 @@ export enum SettingsViews {
advanced = 'advanced',
}
/** Validates any FileSources value — use for file metadata, DB records, and upload routing. */
export const fileSourceSchema = z.nativeEnum(FileSources);
/** Storage backend strategies only — use for config fields that set where files are stored. */
const FILE_STORAGE_BACKENDS = [
FileSources.local,
FileSources.firebase,
FileSources.s3,
FileSources.azure_blob,
] as const satisfies ReadonlyArray<FileSources>;
export const fileStorageSchema = z.enum(FILE_STORAGE_BACKENDS);
export type FileStorage = z.infer<typeof fileStorageSchema>;
export const fileStrategiesSchema = z
.object({
default: fileSourceSchema.optional(),
avatar: fileSourceSchema.optional(),
image: fileSourceSchema.optional(),
document: fileSourceSchema.optional(),
default: fileStorageSchema.optional(),
avatar: fileStorageSchema.optional(),
image: fileStorageSchema.optional(),
document: fileStorageSchema.optional(),
})
.optional();
@ -677,10 +690,8 @@ export const interfaceSchema = z
termsOfService: termsOfServiceSchema.optional(),
customWelcome: z.string().optional(),
mcpServers: mcpServersSchema.optional(),
endpointsMenu: z.boolean().optional(),
modelSelect: z.boolean().optional(),
parameters: z.boolean().optional(),
sidePanel: z.boolean().optional(),
multiConvo: z.boolean().optional(),
bookmarks: z.boolean().optional(),
memories: z.boolean().optional(),
@ -735,10 +746,8 @@ export const interfaceSchema = z
.optional(),
})
.default({
endpointsMenu: true,
modelSelect: true,
parameters: true,
sidePanel: true,
presets: true,
multiConvo: true,
bookmarks: true,
@ -1059,7 +1068,7 @@ export const configSchema = z.object({
.optional(),
interface: interfaceSchema,
turnstile: turnstileSchema.optional(),
fileStrategy: fileSourceSchema.default(FileSources.local),
fileStrategy: fileStorageSchema.default(FileSources.local),
fileStrategies: fileStrategiesSchema,
actions: z
.object({
@ -1833,7 +1842,7 @@ export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.8.4',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.3.6',
CONFIG_VERSION = '1.3.7',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value to use whatever the submission prelim. `responseMessageId` is */

View file

@ -29,14 +29,11 @@ export async function loadDefaultInterface({
const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({
// UI elements - use schema defaults
endpointsMenu:
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
modelSelect:
interfaceConfig?.modelSelect ??
(hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect),
parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters),
presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets),
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,

View file

@ -16,7 +16,7 @@ function fakeConfig(overrides: Record<string, unknown>, priority: number): IConf
}
const baseConfig = {
interfaceConfig: { endpointsMenu: true, sidePanel: true },
interfaceConfig: { modelSelect: true, parameters: true },
registration: { enabled: true },
endpoints: ['openAI'],
} as unknown as AppConfig;
@ -32,11 +32,11 @@ describe('mergeConfigOverrides', () => {
});
it('deep merges interface UI fields into interfaceConfig', () => {
const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)];
const configs = [fakeConfig({ interface: { modelSelect: false } }, 10)];
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;
const iface = result.interfaceConfig as Record<string, unknown>;
expect(iface.endpointsMenu).toBe(false);
expect(iface.sidePanel).toBe(true);
expect(iface.modelSelect).toBe(false);
expect(iface.parameters).toBe(true);
});
it('sorts by priority — higher priority wins', () => {
@ -58,16 +58,16 @@ describe('mergeConfigOverrides', () => {
it('does not mutate the base config', () => {
const original = JSON.parse(JSON.stringify(baseConfig));
const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)];
const configs = [fakeConfig({ interface: { modelSelect: false } }, 10)];
mergeConfigOverrides(baseConfig, configs);
expect(baseConfig).toEqual(original);
});
it('handles null override values', () => {
const configs = [fakeConfig({ interface: { endpointsMenu: null } }, 10)];
const configs = [fakeConfig({ interface: { modelSelect: null } }, 10)];
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;
const iface = result.interfaceConfig as Record<string, unknown>;
expect(iface.endpointsMenu).toBeNull();
expect(iface.modelSelect).toBeNull();
});
it('skips configs with no overrides object', () => {
@ -97,20 +97,20 @@ describe('mergeConfigOverrides', () => {
it('merges three priority levels in order', () => {
const configs = [
fakeConfig({ interface: { endpointsMenu: false } }, 0),
fakeConfig({ interface: { endpointsMenu: true, sidePanel: false } }, 10),
fakeConfig({ interface: { sidePanel: true } }, 100),
fakeConfig({ interface: { modelSelect: false } }, 0),
fakeConfig({ interface: { modelSelect: true, parameters: false } }, 10),
fakeConfig({ interface: { parameters: true } }, 100),
];
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;
const iface = result.interfaceConfig as Record<string, unknown>;
expect(iface.endpointsMenu).toBe(true);
expect(iface.sidePanel).toBe(true);
expect(iface.modelSelect).toBe(true);
expect(iface.parameters).toBe(true);
});
it('remaps all renamed YAML keys (exhaustiveness check)', () => {
const base = {
mcpConfig: null,
interfaceConfig: { endpointsMenu: true },
interfaceConfig: { modelSelect: true },
turnstileConfig: {},
} as unknown as AppConfig;
@ -118,7 +118,7 @@ describe('mergeConfigOverrides', () => {
fakeConfig(
{
mcpServers: { srv: { url: 'http://mcp' } },
interface: { endpointsMenu: false },
interface: { modelSelect: false },
turnstile: { siteKey: 'key-123' },
},
10,
@ -127,7 +127,7 @@ describe('mergeConfigOverrides', () => {
const result = mergeConfigOverrides(base, configs) as unknown as Record<string, unknown>;
expect(result.mcpConfig).toEqual({ srv: { url: 'http://mcp' } });
expect((result.interfaceConfig as Record<string, unknown>).endpointsMenu).toBe(false);
expect((result.interfaceConfig as Record<string, unknown>).modelSelect).toBe(false);
expect((result.turnstileConfig as Record<string, unknown>).siteKey).toBe('key-123');
expect(result.mcpServers).toBeUndefined();
@ -137,14 +137,14 @@ describe('mergeConfigOverrides', () => {
it('strips interface permission fields from overrides', () => {
const base = {
interfaceConfig: { endpointsMenu: true, sidePanel: true },
interfaceConfig: { modelSelect: true, parameters: true },
} as unknown as AppConfig;
const configs = [
fakeConfig(
{
interface: {
endpointsMenu: false,
modelSelect: false,
prompts: false,
agents: { use: false },
marketplace: { use: false },
@ -157,14 +157,14 @@ describe('mergeConfigOverrides', () => {
const iface = result.interfaceConfig as Record<string, unknown>;
// UI field should be merged
expect(iface.endpointsMenu).toBe(false);
expect(iface.modelSelect).toBe(false);
// Boolean permission fields should be stripped
expect(iface.prompts).toBeUndefined();
// Object permission fields with only permission sub-keys should be stripped
expect(iface.agents).toBeUndefined();
expect(iface.marketplace).toBeUndefined();
// Untouched base field preserved
expect(iface.sidePanel).toBe(true);
expect(iface.parameters).toBe(true);
});
it('preserves UI sub-keys in composite permission fields like mcpServers', () => {
@ -220,7 +220,7 @@ describe('mergeConfigOverrides', () => {
it('drops interface entirely when only permission fields are present', () => {
const base = {
interfaceConfig: { endpointsMenu: true },
interfaceConfig: { modelSelect: true },
} as unknown as AppConfig;
const configs = [fakeConfig({ interface: { prompts: false, agents: false } }, 10)];
@ -228,7 +228,7 @@ describe('mergeConfigOverrides', () => {
const iface = result.interfaceConfig as Record<string, unknown>;
// Base should be unchanged
expect(iface.endpointsMenu).toBe(true);
expect(iface.modelSelect).toBe(true);
expect(iface.prompts).toBeUndefined();
expect(iface.agents).toBeUndefined();
});
@ -281,7 +281,7 @@ describe('INTERFACE_PERMISSION_FIELDS', () => {
});
it('does not contain UI-only fields', () => {
const uiFields = ['endpointsMenu', 'modelSelect', 'parameters', 'presets', 'sidePanel'];
const uiFields = ['modelSelect', 'parameters', 'presets'];
for (const field of uiFields) {
expect(INTERFACE_PERMISSION_FIELDS.has(field)).toBe(false);
}

View file

@ -32,7 +32,7 @@ describe('upsertConfig', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: false } },
{ interface: { modelSelect: false } },
10,
);
@ -49,7 +49,7 @@ describe('upsertConfig', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: false } },
{ interface: { modelSelect: false } },
10,
);
@ -57,7 +57,7 @@ describe('upsertConfig', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: true } },
{ interface: { modelSelect: true } },
10,
);
@ -70,7 +70,7 @@ describe('upsertConfig', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: true } },
{ interface: { modelSelect: true } },
10,
);
@ -78,7 +78,7 @@ describe('upsertConfig', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: false } },
{ interface: { modelSelect: false } },
10,
);
@ -240,7 +240,7 @@ describe('patchConfigFields', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: true, sidePanel: true } },
{ interface: { modelSelect: true, parameters: true } },
10,
);
@ -248,14 +248,14 @@ describe('patchConfigFields', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ 'interface.endpointsMenu': false },
{ 'interface.modelSelect': false },
10,
);
const overrides = result!.overrides as Record<string, unknown>;
const iface = overrides.interface as Record<string, unknown>;
expect(iface.endpointsMenu).toBe(false);
expect(iface.sidePanel).toBe(true);
expect(iface.modelSelect).toBe(false);
expect(iface.parameters).toBe(true);
});
it('creates a config if none exists (upsert)', async () => {
@ -263,7 +263,7 @@ describe('patchConfigFields', () => {
PrincipalType.ROLE,
'newrole',
PrincipalModel.ROLE,
{ 'interface.endpointsMenu': false },
{ 'interface.modelSelect': false },
10,
);
@ -278,19 +278,19 @@ describe('unsetConfigField', () => {
PrincipalType.ROLE,
'admin',
PrincipalModel.ROLE,
{ interface: { endpointsMenu: false, sidePanel: false } },
{ interface: { modelSelect: false, parameters: false } },
10,
);
const result = await methods.unsetConfigField(
PrincipalType.ROLE,
'admin',
'interface.endpointsMenu',
'interface.modelSelect',
);
const overrides = result!.overrides as Record<string, unknown>;
const iface = overrides.interface as Record<string, unknown>;
expect(iface.endpointsMenu).toBeUndefined();
expect(iface.sidePanel).toBe(false);
expect(iface.modelSelect).toBeUndefined();
expect(iface.parameters).toBe(false);
});
it('returns null for non-existent config', async () => {

View file

@ -1,6 +1,6 @@
import type {
TEndpoint,
FileSources,
FileStorage,
TFileConfig,
TAzureConfig,
TCustomConfig,
@ -62,7 +62,7 @@ export interface AppConfig {
/** Web search configuration */
webSearch?: TCustomConfig['webSearch'];
/** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */
fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob;
fileStrategy: FileStorage;
/** File strategies configuration */
fileStrategies?: TCustomConfig['fileStrategies'];
/** Registration configurations */