diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx index 619d2f827d..1c4401c338 100644 --- a/client/src/components/Chat/Messages/MessageIcon.tsx +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -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 ?? ''; diff --git a/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx b/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx index 1c7b3c1aec..c19142494a 100644 --- a/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx +++ b/client/src/components/Chat/Messages/__tests__/MessageIcon.render.test.tsx @@ -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) => ( -
- ); + const ConvoIconURL = (props: Record) => { + iconRenderCount.current += 1; + return
; + }; ConvoIconURL.displayName = 'ConvoIconURL'; return { __esModule: true, default: ConvoIconURL }; }); jest.mock('~/components/Endpoints/Icon', () => { - const Icon = (props: Record) => ( -
- ); + const Icon = (props: Record) => { + iconRenderCount.current += 1; + return
; + }; 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(); - 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(); - logCalls.length = 0; + iconRenderCount.current = 0; - // Simulate parent re-render: new iconData object (same field values), new agent object (same data) rerender(); - 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(); - logCalls.length = 0; + iconRenderCount.current = 0; - // New agent object with different id but same display fields const agent2 = makeAgent({ id: 'agent_456' }); rerender(); - 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(); - logCalls.length = 0; + iconRenderCount.current = 0; const agent2 = makeAgent({ avatar: { filepath: '/images/new-avatar.png' } }); rerender(); - 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(); - logCalls.length = 0; + iconRenderCount.current = 0; rerender(); - 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(); - // 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(); - 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(); - 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(); } - 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(); - 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(); - const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data'); - expect(iconDataCalls).toHaveLength(0); + expect(iconRenderCount.current).toBe(0); }); }); }); diff --git a/librechat.example.yaml b/librechat.example.yaml index 03bb5f5bc2..92206c4b6e 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -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 diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index ad33f5f158..8f4e2002a5 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -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 } }); }); }); }); diff --git a/packages/api/src/admin/config.spec.ts b/packages/api/src/admin/config.spec.ts index 499cfaa35b..3298cb5faa 100644 --- a/packages/api/src/admin/config.spec.ts +++ b/packages/api/src/admin/config.spec.ts @@ -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'); }); diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index 357096da9b..c1ce2cb13f 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -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('.'); diff --git a/packages/api/src/app/AppService.spec.ts b/packages/api/src/app/AppService.spec.ts index df607d612b..2c07460b84 100644 --- a/packages/api/src/app/AppService.spec.ts +++ b/packages/api/src/app/AppService.spec.ts @@ -85,7 +85,7 @@ describe('AppService', () => { it('should correctly assign process.env and initialize app config based on custom config', async () => { const config: Partial = { 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, diff --git a/packages/api/src/app/checks.ts b/packages/api/src/app/checks.ts index 66f5b620e6..50312267ac 100644 --- a/packages/api/src/app/checks.ts +++ b/packages/api/src/app/checks.ts @@ -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++; } diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index 3e2ee6d143..a3e7401efd 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -10,7 +10,7 @@ const createTestAppConfig = (overrides: Partial = {}): AppConfig => { version: '1.0.0', cache: true, interface: { - endpointsMenu: true, + modelSelect: true, }, registration: { socialLogins: [], diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 8d168095c7..e5e076c8eb 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -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']); }); diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 67eebf9cad..725d780b09 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -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", diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 66013baadd..6e76bced06 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -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); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 2b512a84d7..ca40ec2c8c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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; + +export const fileStorageSchema = z.enum(FILE_STORAGE_BACKENDS); + +export type FileStorage = z.infer; + 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 */ diff --git a/packages/data-schemas/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts index 1701a22fad..3cd71cfb20 100644 --- a/packages/data-schemas/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -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, diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts index 991f6afb40..e241b7f9a4 100644 --- a/packages/data-schemas/src/app/resolution.spec.ts +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -16,7 +16,7 @@ function fakeConfig(overrides: Record, 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; const iface = result.interfaceConfig as Record; - 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; const iface = result.interfaceConfig as Record; - 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; const iface = result.interfaceConfig as Record; - 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; expect(result.mcpConfig).toEqual({ srv: { url: 'http://mcp' } }); - expect((result.interfaceConfig as Record).endpointsMenu).toBe(false); + expect((result.interfaceConfig as Record).modelSelect).toBe(false); expect((result.turnstileConfig as Record).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; // 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; // 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); } diff --git a/packages/data-schemas/src/methods/config.spec.ts b/packages/data-schemas/src/methods/config.spec.ts index 8bcf73a733..8ab6be35ee 100644 --- a/packages/data-schemas/src/methods/config.spec.ts +++ b/packages/data-schemas/src/methods/config.spec.ts @@ -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; const iface = overrides.interface as Record; - 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; const iface = overrides.interface as Record; - 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 () => { diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts index 73d65611b0..5118fa9583 100644 --- a/packages/data-schemas/src/types/app.ts +++ b/packages/data-schemas/src/types/app.ts @@ -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 */