From d793891a7125d6160e47bb322351e1ec33dd7320 Mon Sep 17 00:00:00 2001 From: Lia Date: Mon, 28 Sep 2026 21:23:05 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A5=A1=20feat:=20Reload=20Custom=20Mo?= =?UTF-8?q?del=20Lists=20Across=20Replicas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/ModelController.js | 8 +- .../controllers/ModelController.spec.js | 17 +- api/server/routes/__tests__/config.spec.js | 22 + api/server/routes/admin/config.js | 12 +- api/server/routes/admin/config.test.js | 96 ++ api/server/routes/config.js | 5 +- api/server/routes/models.js | 4 +- .../__tests__/invalidateConfigCaches.spec.js | 32 +- api/server/services/Config/app.js | 83 +- .../Nav/Settings/__tests__/Sidebar.spec.tsx | 1 + .../Nav/Settings/__tests__/registry.spec.ts | 7 + .../src/components/Nav/Settings/context.tsx | 5 +- .../src/components/Nav/Settings/registry.tsx | 10 + client/src/components/Nav/Settings/types.ts | 1 + .../Nav/SettingsTabs/General/ConfigReload.tsx | 136 +++ .../General/__tests__/ConfigReload.spec.tsx | 112 +++ .../src/data-provider/ConfigReload/index.ts | 30 + .../ConfigReload/queries.test.tsx | 61 ++ .../src/data-provider/ConfigReload/queries.ts | 59 ++ client/src/data-provider/index.ts | 1 + client/src/locales/en/translation.json | 15 + client/src/routes/Root.tsx | 11 +- packages/api/src/admin/config.handler.spec.ts | 75 ++ packages/api/src/admin/config.ts | 48 +- packages/api/src/app/index.ts | 3 + packages/api/src/app/lifecycle.spec.ts | 75 ++ packages/api/src/app/lifecycle.ts | 70 ++ packages/api/src/app/loader.ts | 52 +- packages/api/src/app/reload.spec.ts | 869 ++++++++++++++++++ packages/api/src/app/reload.ts | 554 +++++++++++ packages/api/src/app/service.spec.ts | 412 ++++++++- packages/api/src/app/service.ts | 230 ++++- packages/api/src/app/status.spec.ts | 30 + packages/api/src/app/status.ts | 25 + .../standardCache.namespace_isolation.spec.ts | 4 +- packages/api/src/cache/cacheConfig.ts | 2 +- packages/api/src/cache/cacheFactory.ts | 9 +- packages/api/src/middleware/capabilities.ts | 1 + packages/data-provider/src/api-endpoints.ts | 4 + packages/data-provider/src/config.ts | 18 + packages/data-provider/src/data-service.ts | 30 + packages/data-provider/src/index.ts | 1 + packages/data-provider/src/keys.ts | 3 + .../data-provider/src/types/configReload.ts | 29 + packages/data-provider/src/types/index.ts | 1 + 45 files changed, 3124 insertions(+), 149 deletions(-) create mode 100644 api/server/routes/admin/config.test.js create mode 100644 client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx create mode 100644 client/src/components/Nav/SettingsTabs/General/__tests__/ConfigReload.spec.tsx create mode 100644 client/src/data-provider/ConfigReload/index.ts create mode 100644 client/src/data-provider/ConfigReload/queries.test.tsx create mode 100644 client/src/data-provider/ConfigReload/queries.ts create mode 100644 packages/api/src/app/lifecycle.spec.ts create mode 100644 packages/api/src/app/lifecycle.ts create mode 100644 packages/api/src/app/reload.spec.ts create mode 100644 packages/api/src/app/reload.ts create mode 100644 packages/api/src/app/status.spec.ts create mode 100644 packages/api/src/app/status.ts create mode 100644 packages/data-provider/src/types/configReload.ts diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 920306bfc5b..e597faab78c 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,5 +1,10 @@ const { logger } = require('@librechat/data-schemas'); -const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config'); +const { + loadDefaultModels, + loadConfigModels, + getConfigGenerationForConfig, +} = require('~/server/services/Config'); +const { CONFIG_GENERATION_HEADER } = require('librechat-data-provider'); const getModelsConfig = (req) => loadModels(req); @@ -14,6 +19,7 @@ async function loadModels(req) { async function modelController(req, res) { try { const modelConfig = await loadModels(req); + res.set(CONFIG_GENERATION_HEADER, getConfigGenerationForConfig(req.config)); res.send(modelConfig); } catch (error) { logger.error('Error fetching models:', error); diff --git a/api/server/controllers/ModelController.spec.js b/api/server/controllers/ModelController.spec.js index b7920487bca..3caf399041b 100644 --- a/api/server/controllers/ModelController.spec.js +++ b/api/server/controllers/ModelController.spec.js @@ -1,5 +1,6 @@ const mockLoadDefaultModels = jest.fn(); const mockLoadConfigModels = jest.fn(); +const mockGetGeneration = jest.fn(() => '2'); jest.mock('@librechat/data-schemas', () => ({ logger: { @@ -10,9 +11,10 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/server/services/Config', () => ({ loadDefaultModels: (...args) => mockLoadDefaultModels(...args), loadConfigModels: (...args) => mockLoadConfigModels(...args), + getConfigGenerationForConfig: (...args) => mockGetGeneration(...args), })); -const { loadModels } = require('./ModelController'); +const { loadModels, modelController } = require('./ModelController'); function deferred() { let resolve; @@ -55,3 +57,16 @@ describe('loadModels', () => { }); }); }); + +describe('versioned model response', () => { + it('stamps the generation of the config used for the actual model request', async () => { + const req = { user: { id: 'user-1' }, config: { endpoints: {} } }; + const res = { set: jest.fn(), send: jest.fn(), status: jest.fn() }; + mockLoadDefaultModels.mockResolvedValue({}); + mockLoadConfigModels.mockResolvedValue({ gateway: ['new-model'] }); + await modelController(req, res); + expect(mockGetGeneration).toHaveBeenCalledWith(req.config); + expect(res.set).toHaveBeenCalledWith('X-LibreChat-Config-Generation', '2'); + expect(res.send).toHaveBeenCalledWith({ gateway: ['new-model'] }); + }); +}); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 82c714873b4..5b55732ba20 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -1,8 +1,10 @@ jest.mock('~/cache/getLogStores'); const mockGetAppConfig = jest.fn(); +const mockGetConfigRefreshStatus = jest.fn(); jest.mock('~/server/services/Config/app', () => ({ getAppConfig: (...args) => mockGetAppConfig(...args), + getConfigRefreshStatus: () => mockGetConfigRefreshStatus(), })); jest.mock('~/server/services/Config/ldap', () => ({ @@ -119,6 +121,26 @@ afterEach(() => { delete process.env.CODE_ENVIRONMENT_DECISION_VERSION; }); +describe('GET /api/config/revision', () => { + it('never exposes even a generation to anonymous callers', async () => { + await request(createApp()).get('/api/config/revision').expect(401); + expect(mockGetConfigRefreshStatus).not.toHaveBeenCalled(); + }); + + it('reports the local applied revision without cache or extra permission queries', async () => { + mockGetConfigRefreshStatus.mockResolvedValue({ + distributed: true, + generation: 2, + pollIntervalMs: 3000, + }); + const response = await request(createApp(mockUser)).get('/api/config/revision').expect(200); + expect(response.headers['cache-control']).toBe('private, no-store'); + expect(response.body).toEqual({ distributed: true, generation: 2, pollIntervalMs: 3000 }); + expect(mockHasCapability).not.toHaveBeenCalled(); + expect(mockGetAppConfig).not.toHaveBeenCalled(); + }); +}); + describe('GET /api/config', () => { describe('unauthenticated (no req.user)', () => { it('should call getAppConfig with baseOnly when no tenant context', async () => { diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js index 9333495280d..31f87ad4674 100644 --- a/api/server/routes/admin/config.js +++ b/api/server/routes/admin/config.js @@ -8,13 +8,20 @@ const { hasAnyConfigReadAccess, getReadableConfigSections, } = require('~/server/middleware/roles/capabilities'); -const { getAppConfig, invalidateConfigCaches } = require('~/server/services/Config'); +const { + getAppConfig, + invalidateConfigCaches, + reloadCustomConfig, +} = require('~/server/services/Config'); const { requireJwtAuth } = require('~/server/middleware'); const db = require('~/models'); const router = express.Router(); const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); +const requirePlatformConfigManager = requireCapability(SystemCapabilities.MANAGE_CONFIGS, { + platformOnly: true, +}); const handlers = createAdminConfigHandlers({ listAllConfigs: db.listAllConfigs, @@ -31,12 +38,15 @@ const handlers = createAdminConfigHandlers({ hasCapability, getAppConfig, invalidateConfigCaches, + reloadCustomConfig, }); router.use(requireJwtAuth, requireAdminAccess); router.get('/', handlers.listConfigs); router.get('/base', handlers.getBaseConfig); +router.get('/reload/access', requirePlatformConfigManager, (_req, res) => res.sendStatus(204)); +router.post('/reload', requirePlatformConfigManager, handlers.reloadConfig); router.get('/:principalType/:principalId', handlers.getConfig); router.put('/:principalType/:principalId', handlers.upsertConfigOverrides); router.patch('/:principalType/:principalId/fields', handlers.patchConfigField); diff --git a/api/server/routes/admin/config.test.js b/api/server/routes/admin/config.test.js new file mode 100644 index 00000000000..343db5b0c2b --- /dev/null +++ b/api/server/routes/admin/config.test.js @@ -0,0 +1,96 @@ +const express = require('express'); +const request = require('supertest'); + +let mockDenyAdminAccess = false; +let mockDenyPlatformAccess = false; +const mockReloadConfig = jest.fn((_req, res) => res.status(200).json({ scope: 'local' })); +const mockHandlers = { + listConfigs: jest.fn(), + getBaseConfig: jest.fn(), + reloadConfig: mockReloadConfig, + getConfig: jest.fn(), + upsertConfigOverrides: jest.fn(), + patchConfigField: jest.fn(), + tombstoneConfigField: jest.fn(), + deleteConfigField: jest.fn(), + deleteConfigOverrides: jest.fn(), + toggleConfig: jest.fn(), +}; + +jest.mock('@librechat/api', () => ({ + createAdminConfigHandlers: jest.fn(() => mockHandlers), +})); + +jest.mock('@librechat/data-schemas', () => ({ + SystemCapabilities: { ACCESS_ADMIN: 'access:admin', MANAGE_CONFIGS: 'manage:configs' }, +})); + +jest.mock('~/server/middleware/roles/capabilities', () => ({ + hasCapability: jest.fn(), + hasConfigCapability: jest.fn(), + hasAnyConfigReadAccess: jest.fn(), + getReadableConfigSections: jest.fn(), + requireCapability: jest.fn((capability, options) => (_req, res, next) => { + if (mockDenyAdminAccess || (options?.platformOnly && mockDenyPlatformAccess)) { + return res.status(403).json({ error: 'Forbidden' }); + } + next(); + }), +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: jest.fn((req, _res, next) => { + req.user = { id: 'admin-1', role: 'ADMIN' }; + next(); + }), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), + invalidateConfigCaches: jest.fn(), + reloadCustomConfig: jest.fn(), +})); + +jest.mock('~/models', () => ({})); + +function createApp() { + delete require.cache[require.resolve('./config')]; + const app = express(); + app.use('/api/admin/config', require('./config')); + return app; +} + +describe('admin config reload route', () => { + beforeEach(() => { + mockDenyAdminAccess = false; + mockDenyPlatformAccess = false; + jest.clearAllMocks(); + }); + + it('allows an authenticated admin', async () => { + const response = await request(createApp()).post('/api/admin/config/reload').expect(200); + + expect(response.body).toEqual({ scope: 'local' }); + expect(mockReloadConfig).toHaveBeenCalledTimes(1); + }); + + it('denies a tenant administrator before reloading deployment YAML', async () => { + mockDenyPlatformAccess = true; + await request(createApp()).post('/api/admin/config/reload').expect(403); + expect(mockReloadConfig).not.toHaveBeenCalled(); + await request(createApp()).get('/api/admin/config/reload/access').expect(403); + }); + + it('permits the settings capability probe after a platform grant', async () => { + const response = await request(createApp()).get('/api/admin/config/reload/access'); + expect(response.status).toBe(204); + }); + + it('returns 403 before the handler for a non-admin', async () => { + mockDenyAdminAccess = true; + + await request(createApp()).post('/api/admin/config/reload').expect(403); + + expect(mockReloadConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index d3e3682ab4b..de5be277245 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -15,13 +15,14 @@ const { resolveCodeEnvironmentDecisionVersion, resolveCodeEnvironmentMoveCapabilities, resolveCodeEnvironmentTransitionVersion, + createConfigRevisionHandler, } = require('@librechat/api'); const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); const { hasCapability, hasConfigCapability } = require('~/server/middleware/roles/capabilities'); const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getRumConfig } = require('~/server/services/Config/rum'); -const { getAppConfig } = require('~/server/services/Config/app'); +const { getAppConfig, getConfigRefreshStatus } = require('~/server/services/Config/app'); const router = express.Router(); const emailLoginEnabled = @@ -210,6 +211,8 @@ function buildCloudFrontStartupConfig() { }; } +router.get('/revision', createConfigRevisionHandler(getConfigRefreshStatus)); + router.get('/', async function (req, res) { try { const preLoginPayload = buildPreLoginPayload(); diff --git a/api/server/routes/models.js b/api/server/routes/models.js index e3272087a76..0402ed5ccfe 100644 --- a/api/server/routes/models.js +++ b/api/server/routes/models.js @@ -2,7 +2,9 @@ const express = require('express'); const { modelController } = require('~/server/controllers/ModelController'); const { requireJwtAuth } = require('~/server/middleware/'); +const configMiddleware = require('~/server/middleware/config/app'); + const router = express.Router(); -router.get('/', requireJwtAuth, modelController); +router.get('/', requireJwtAuth, configMiddleware.strictConfigMiddleware, modelController); module.exports = router; diff --git a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js index 8d21fb2e84e..d6a1f277e0d 100644 --- a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js +++ b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js @@ -2,6 +2,7 @@ const mockClearAppConfigCache = jest.fn().mockResolvedValue(undefined); const mockClearOverrideCache = jest.fn().mockResolvedValue(undefined); +const mockReplaceBaseConfig = jest.fn().mockResolvedValue(undefined); jest.mock('~/cache/getLogStores', () => { return jest.fn(() => ({})); @@ -33,11 +34,19 @@ jest.mock('../getCachedTools', () => ({ const mockClearMcpConfigCache = jest.fn().mockResolvedValue(undefined); jest.mock('@librechat/api', () => ({ - createAppConfigService: jest.fn(() => ({ + createDeploymentConfigService: jest.fn(() => ({ getAppConfig: jest.fn().mockResolvedValue({ availableTools: {} }), + replaceBaseConfig: mockReplaceBaseConfig, clearAppConfigCache: mockClearAppConfigCache, clearOverrideCache: mockClearOverrideCache, })), + createConfigGenerationTracker: jest.fn(() => ({ + distributed: false, + check: jest.fn().mockResolvedValue(false), + bump: jest.fn().mockResolvedValue(undefined), + })), + ioredisClient: null, + cacheConfig: { USE_REDIS: false }, clearMcpConfigCache: mockClearMcpConfigCache, createCodeEnvironmentRegistry: jest.fn(() => ({})), mergeAccessibleCodeEnvironments: jest.fn(({ appConfig }) => appConfig), @@ -52,12 +61,12 @@ describe('invalidateConfigCaches', () => { jest.clearAllMocks(); }); - it('clears all caches', async () => { + it('clears reloadable caches while preserving the startup-owned tool catalog', async () => { await invalidateConfigCaches(); expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1); expect(mockClearOverrideCache).toHaveBeenCalledTimes(1); - expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); + expect(mockInvalidateCachedTools).not.toHaveBeenCalled(); expect(mockClearMcpConfigCache).toHaveBeenCalledTimes(1); }); @@ -66,7 +75,7 @@ describe('invalidateConfigCaches', () => { expect(mockClearOverrideCache).toHaveBeenCalledWith('tenant-a'); expect(mockClearAppConfigCache).toHaveBeenCalledTimes(1); - expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); + expect(mockInvalidateCachedTools).not.toHaveBeenCalled(); }); it('all operations run in parallel (not sequentially)', async () => { @@ -90,15 +99,6 @@ describe('invalidateConfigCaches', () => { }, 10), ), ); - mockInvalidateCachedTools.mockImplementation( - () => - new Promise((r) => - setTimeout(() => { - order.push('tools'); - r(); - }, 10), - ), - ); mockClearMcpConfigCache.mockImplementation( () => new Promise((r) => @@ -111,8 +111,8 @@ describe('invalidateConfigCaches', () => { await invalidateConfigCaches(); - expect(order).toHaveLength(4); - expect(new Set(order)).toEqual(new Set(['base', 'override', 'tools', 'mcp'])); + expect(order).toHaveLength(3); + expect(new Set(order)).toEqual(new Set(['base', 'override', 'mcp'])); }); it('resolves even when clearAppConfigCache throws (partial failure)', async () => { @@ -121,6 +121,6 @@ describe('invalidateConfigCaches', () => { await expect(invalidateConfigCaches()).resolves.not.toThrow(); expect(mockClearOverrideCache).toHaveBeenCalledTimes(1); - expect(mockInvalidateCachedTools).toHaveBeenCalledWith({ invalidateGlobal: true }); + expect(mockInvalidateCachedTools).not.toHaveBeenCalled(); }); }); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index d67c0f03641..063e34bafc4 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -2,14 +2,17 @@ const mongoose = require('mongoose'); const { CacheKeys } = require('librechat-data-provider'); const { AppService, logger } = require('@librechat/data-schemas'); const { - createAppConfigService, + createDeploymentConfigService, + createConfigGenerationTracker, + createRedisConfigGenerationStore, clearMcpConfigCache, createCodeEnvironmentRegistry, mergeAccessibleCodeEnvironments, cacheConfig, + ioredisClient, standardCache, } = require('@librechat/api'); -const { setCachedTools, invalidateCachedTools } = require('./getCachedTools'); +const { setCachedTools } = require('./getCachedTools'); const { loadAndFormatTools } = require('~/server/services/start/tools'); const loadCustomConfig = require('./loadCustomConfig'); const getLogStores = require('~/cache/getLogStores'); @@ -33,9 +36,7 @@ async function invalidateCodeEnvironmentConfigCache(tenantId) { await getCodeEnvironmentRegistry().invalidateAccessibleConfigurations(tenantId); } -const loadBaseConfig = async (mode) => { - /** @type {TCustomConfig} */ - const config = (await loadCustomConfig(true, { mode })) ?? {}; +const buildBaseConfig = async (config) => { /** @type {Record} */ const systemTools = loadAndFormatTools({ adminFilter: config.filteredTools, @@ -45,48 +46,59 @@ const loadBaseConfig = async (mode) => { return AppService({ config, paths, systemTools }); }; -const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfigService({ - loadBaseConfig, - setCachedTools, - getCache: getLogStores, - cacheKeys: CacheKeys, - getApplicableConfigs: db.getApplicableConfigs, - getUserPrincipals: db.getUserPrincipals, - augmentConfig: ({ appConfig, baseConfig, principals, options }) => { - if (!options.userId) return appConfig; - return mergeAccessibleCodeEnvironments({ - appConfig, - deploymentConfig: baseConfig, - actor: { - userId: options.userId, - role: options.role ?? null, - idOnTheSource: options.idOnTheSource ?? null, - principals, - }, - registry: getCodeEnvironmentRegistry(), - }); +const configGeneration = createConfigGenerationTracker( + cacheConfig.USE_REDIS && ioredisClient ? createRedisConfigGenerationStore(ioredisClient) : null, + { bootstrapTimeoutMs: cacheConfig.REDIS_CONNECT_TIMEOUT }, +); + +const { + getAppConfig, + getConfigRefreshStatus, + getConfigGenerationForConfig, + clearAppConfigCache, + clearOverrideCache, + reloadCustomConfig, +} = createDeploymentConfigService({ + loadCustomConfig, + buildBaseConfig, + generation: configGeneration, + configService: { + setCachedTools, + getCache: getLogStores, + cacheKeys: CacheKeys, + getApplicableConfigs: db.getApplicableConfigs, + getUserPrincipals: db.getUserPrincipals, + augmentConfig: ({ appConfig, baseConfig, principals, options }) => { + if (!options.userId) return appConfig; + return mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig: baseConfig, + actor: { + userId: options.userId, + role: options.role ?? null, + idOnTheSource: options.idOnTheSource ?? null, + principals, + }, + registry: getCodeEnvironmentRegistry(), + }); + }, }, }); /** * Invalidate all config-related caches after an admin config mutation. - * Clears the base config, per-principal override caches, tool caches, - * and the MCP config-source server cache. + * Clears the base config, per-principal overrides and MCP config-source cache. + * Global static tools remain startup-owned; clearing them here would leave them + * absent until a restart while live reload intentionally pins tool filters. * @param {string} [tenantId] - Optional tenant ID to scope override cache clearing. */ async function invalidateConfigCaches(tenantId) { const results = await Promise.allSettled([ clearAppConfigCache(), clearOverrideCache(tenantId), - invalidateCachedTools({ invalidateGlobal: true }), clearMcpConfigCache(), ]); - const labels = [ - 'clearAppConfigCache', - 'clearOverrideCache', - 'invalidateCachedTools', - 'clearMcpConfigCache', - ]; + const labels = ['clearAppConfigCache', 'clearOverrideCache', 'clearMcpConfigCache']; for (let i = 0; i < results.length; i++) { if (results[i].status === 'rejected') { logger.error(`[invalidateConfigCaches] ${labels[i]} failed:`, results[i].reason); @@ -96,9 +108,12 @@ async function invalidateConfigCaches(tenantId) { module.exports = { getAppConfig, + getConfigRefreshStatus, + getConfigGenerationForConfig, clearAppConfigCache, clearOverrideCache, invalidateConfigCaches, + reloadCustomConfig, getCodeEnvironmentRegistry, invalidateCodeEnvironmentConfigCache, }; diff --git a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx index 8272fe91d74..5a4bdea0159 100644 --- a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx +++ b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx @@ -21,6 +21,7 @@ const ctx: SettingsContextValue = { engineTTS: 'browser', langfuseConnectionAccess: false, adminPanelURL: '', + configReloadAccess: false, }; function setup(extra: Partial = {}, query = '') { diff --git a/client/src/components/Nav/Settings/__tests__/registry.spec.ts b/client/src/components/Nav/Settings/__tests__/registry.spec.ts index d277ebfe441..b5ab3f0f945 100644 --- a/client/src/components/Nav/Settings/__tests__/registry.spec.ts +++ b/client/src/components/Nav/Settings/__tests__/registry.spec.ts @@ -23,6 +23,7 @@ const settingsContext: SettingsContextValue = { engineTTS: 'browser', langfuseConnectionAccess: false, adminPanelURL: '', + configReloadAccess: false, }; describe('settings registry', () => { @@ -51,6 +52,12 @@ describe('settings registry', () => { } }); + it('shows config reload only to admins, even without an external admin panel', () => { + const reload = registry.find((entry) => entry.id === 'configReload'); + expect(reload?.show?.({ ...settingsContext, configReloadAccess: true })).toBe(true); + expect(reload?.show?.(settingsContext)).toBe(false); + }); + describe('Langfuse connection visibility', () => { const langfuseEntry = registry.find((entry) => entry.id === 'langfuseConnection'); diff --git a/client/src/components/Nav/Settings/context.tsx b/client/src/components/Nav/Settings/context.tsx index bb725d3c79d..5a25b07fd56 100644 --- a/client/src/components/Nav/Settings/context.tsx +++ b/client/src/components/Nav/Settings/context.tsx @@ -2,15 +2,16 @@ import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { AgentCapabilities, PermissionTypes, Permissions } from 'librechat-data-provider'; import type { SettingsContextValue } from './types'; +import { useGetStartupConfig, useConfigReloadAccessQuery } from '~/data-provider'; import useProviderKeys from '../SettingsTabs/ProviderKeys/useProviderKeys'; import { useHasAccess, useAuthContext, useGetAgentsConfig } from '~/hooks'; import usePersonalizationAccess from '~/hooks/usePersonalizationAccess'; -import { useGetStartupConfig } from '~/data-provider'; import store from '~/store'; export function useSettingsContext(): SettingsContextValue { const { user } = useAuthContext(); const { data: startupConfig } = useGetStartupConfig(); + const { data: configReloadAccess = false } = useConfigReloadAccessQuery(user?.id); const { agentsConfig } = useGetAgentsConfig(); const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess(); @@ -59,6 +60,7 @@ export function useSettingsContext(): SettingsContextValue { engineTTS, langfuseConnectionAccess, adminPanelURL, + configReloadAccess, }), [ balanceEnabled, @@ -76,6 +78,7 @@ export function useSettingsContext(): SettingsContextValue { engineTTS, langfuseConnectionAccess, adminPanelURL, + configReloadAccess, ], ); } diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index ba831ccbd29..9b798196f7d 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -33,6 +33,7 @@ import DuringRunAction from '../SettingsTabs/Chat/DuringRunAction'; import DeleteAccount from '../SettingsTabs/Account/DeleteAccount'; import StatefulWorkspaceDefault from './StatefulWorkspaceDefault'; import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings'; +import ConfigReload from '../SettingsTabs/General/ConfigReload'; import ChatDirection from '../SettingsTabs/Chat/ChatDirection'; import { DeleteCache } from '../SettingsTabs/Data/DeleteCache'; import { ManageFiles } from '../SettingsTabs/Data/ManageFiles'; @@ -191,6 +192,15 @@ export const registry: SettingEntry[] = [ Component: AdminPanel, show: (ctx) => ctx.adminPanelURL !== '', }, + { + id: 'configReload', + tab: GENERAL, + section: 'admin', + labelKey: 'com_ui_config_reload_title', + keywords: ['admin', 'yaml', 'remote', 'config', 'reload'], + Component: ConfigReload, + show: (ctx) => ctx.configReloadAccess, + }, // Chat · Sending { diff --git a/client/src/components/Nav/Settings/types.ts b/client/src/components/Nav/Settings/types.ts index faf1c6b124e..409f01c0895 100644 --- a/client/src/components/Nav/Settings/types.ts +++ b/client/src/components/Nav/Settings/types.ts @@ -53,6 +53,7 @@ export interface SettingsContextValue { engineTTS: string; langfuseConnectionAccess: boolean; adminPanelURL: string; + configReloadAccess: boolean; } export interface SettingEntry { diff --git a/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx b/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx new file mode 100644 index 00000000000..ab535bddc4a --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import axios from 'axios'; +import { RefreshCw } from 'lucide-react'; +import { Button, Label } from '@librechat/client'; +import type { TConfigReloadError, TConfigReloadResult } from 'librechat-data-provider'; +import { useReloadCustomConfigMutation, useConfigReloadAccessQuery } from '~/data-provider'; +import { useAuthContext, useLocalize } from '~/hooks'; + +const SCOPE_LABELS = { + cluster: 'com_ui_config_reload_cluster', + local: 'com_ui_config_reload_local', + unchanged: 'com_ui_config_reload_unchanged', +} as const; + +const SECTION_LABELS = { + applied_live: 'com_ui_config_reload_applied_live', + restart_required: 'com_ui_config_reload_restart_required', + unchanged: 'com_ui_config_reload_no_change', +} as const; + +export default function ConfigReload() { + const localize = useLocalize(); + const { user } = useAuthContext(); + const { data: canReload } = useConfigReloadAccessQuery(user?.id); + const mutation = useReloadCustomConfigMutation(); + const [report, setReport] = useState(); + const [errorKey, setErrorKey] = useState< + | 'com_ui_config_reload_conflict' + | 'com_ui_config_reload_forbidden' + | 'com_ui_config_reload_source_error' + | 'com_ui_config_reload_failed' + >(); + const [validationErrors, setValidationErrors] = + useState(); + + if (canReload !== true) { + return null; + } + + const handleReload = () => { + setReport(undefined); + setErrorKey(undefined); + setValidationErrors(undefined); + mutation.mutate(undefined, { + onSuccess: (result) => setReport(result), + onError: (error: unknown) => { + const response = axios.isAxiosError(error) ? error.response : undefined; + const issues = response?.data?.validationErrors ?? []; + setValidationErrors(issues); + if (response?.status === 409) { + setErrorKey('com_ui_config_reload_conflict'); + return; + } + if (response?.status === 403) { + setErrorKey('com_ui_config_reload_forbidden'); + return; + } + if (response?.status === 400 && issues.length === 0) { + setErrorKey('com_ui_config_reload_source_error'); + return; + } + setErrorKey('com_ui_config_reload_failed'); + }, + }); + }; + + return ( +
+
+
+ +

+ {localize('com_ui_config_reload_description')} +

+
+ +
+ {errorKey && ( +
+

{localize(errorKey)}

+ {validationErrors && validationErrors.length > 0 && ( +
    + {validationErrors.map(({ path, message }, index) => ( +
  • + {path.join('.')}: {message} +
  • + ))} +
+ )} +
+ )} + {report && ( +
+

{localize(SCOPE_LABELS[report.scope])}

+ {report.propagationError && ( +

+ {localize('com_ui_config_reload_propagation_error')} +

+ )} +
    + {report.sections.map(({ section, status, restartRequired, restartRequiredPaths }) => ( +
  • + {section} + + {localize(SECTION_LABELS[status])} + {restartRequired && status !== 'restart_required' && ( + · {localize('com_ui_config_reload_restart_required')} + )} + {restartRequiredPaths && restartRequiredPaths.length > 0 && ( + + {restartRequiredPaths.join(', ')} + + )} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/General/__tests__/ConfigReload.spec.tsx b/client/src/components/Nav/SettingsTabs/General/__tests__/ConfigReload.spec.tsx new file mode 100644 index 00000000000..c48c0a15624 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/__tests__/ConfigReload.spec.tsx @@ -0,0 +1,112 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import ConfigReload from '../ConfigReload'; + +const mockMutate = jest.fn(); +const mockAccess = jest.fn(); +const mockMutation = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useReloadCustomConfigMutation: () => mockMutation(), + useConfigReloadAccessQuery: () => mockAccess(), +})); +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useAuthContext: () => ({ user: { id: 'u1' } }), +})); + +beforeEach(() => { + mockMutate.mockReset(); + mockAccess.mockReturnValue({ data: true }); + mockMutation.mockReturnValue({ mutate: mockMutate, isLoading: false }); +}); + +describe('ConfigReload', () => { + it('hides the control without config-management capability', () => { + mockAccess.mockReturnValue({ data: false }); + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('disables reload and exposes progress while a request is pending', () => { + mockMutation.mockReturnValue({ mutate: mockMutate, isLoading: true }); + render(); + expect(screen.getByRole('button', { name: 'com_ui_config_reload_title' })).toBeDisabled(); + expect(screen.getByText('com_ui_config_reload_loading')).toBeVisible(); + }); + + it('shows the cluster scope, live changes, and restart-only paths after success', async () => { + mockMutate.mockImplementation((_variables, options) => + options.onSuccess({ + scope: 'cluster', + distributed: true, + generation: 2, + sections: [ + { section: 'endpoints', status: 'applied_live', restartRequired: false }, + { + section: 'mcpServers', + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: ['mcpServers.docs.url'], + }, + ], + }), + ); + render(); + await userEvent.click(screen.getByRole('button', { name: 'com_ui_config_reload_title' })); + expect(screen.getByText('com_ui_config_reload_cluster')).toBeInTheDocument(); + expect(screen.getByText('mcpServers.docs.url')).toBeInTheDocument(); + expect(screen.getByText('com_ui_config_reload_restart_required')).toBeInTheDocument(); + expect(screen.getByText('com_ui_config_reload_applied_live')).toBeInTheDocument(); + }); + + it('renders validation issues without exposing an old success report on retry', async () => { + mockMutate + .mockImplementationOnce((_variables, options) => + options.onSuccess({ scope: 'local', distributed: false, sections: [] }), + ) + .mockImplementationOnce((_variables, options) => + options.onError({ + isAxiosError: true, + response: { + status: 400, + data: { + error: 'Custom config validation failed', + validationErrors: [{ path: ['endpoints', 'custom'], message: 'Unsupported model' }], + }, + }, + }), + ); + render(); + const button = screen.getByRole('button', { name: 'com_ui_config_reload_title' }); + await userEvent.click(button); + expect(screen.getByText('com_ui_config_reload_local')).toBeInTheDocument(); + await userEvent.click(button); + expect(screen.queryByText('com_ui_config_reload_local')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('endpoints.custom: Unsupported model'); + }); + + it('shows a permission error for rejected admin roles', async () => { + mockMutate.mockImplementation((_variables, options) => + options.onError({ isAxiosError: true, response: { status: 403 } }), + ); + render(); + await userEvent.click(screen.getByRole('button', { name: 'com_ui_config_reload_title' })); + expect(screen.getByRole('alert')).toHaveTextContent('com_ui_config_reload_forbidden'); + }); + + it('warns that failed propagation stayed local and can be retried', async () => { + mockMutate.mockImplementation((_variables, options) => + options.onSuccess({ + scope: 'local', + distributed: false, + propagationError: 'Redis generation update failed', + sections: [], + }), + ); + render(); + await userEvent.click(screen.getByRole('button', { name: 'com_ui_config_reload_title' })); + expect(screen.getByText('com_ui_config_reload_local')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('com_ui_config_reload_propagation_error'); + }); +}); diff --git a/client/src/data-provider/ConfigReload/index.ts b/client/src/data-provider/ConfigReload/index.ts new file mode 100644 index 00000000000..7961a0223e1 --- /dev/null +++ b/client/src/data-provider/ConfigReload/index.ts @@ -0,0 +1,30 @@ +export { useModelCatalogRefresh } from './queries'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { dataService, MutationKeys, QueryKeys } from 'librechat-data-provider'; +import type { TConfigReloadResult } from 'librechat-data-provider'; +import type { UseMutationResult } from '@tanstack/react-query'; + +export function useConfigReloadAccessQuery(userId?: string) { + return useQuery([QueryKeys.configReloadAccess, userId], dataService.getConfigReloadAccess, { + enabled: !!userId, + retry: false, + staleTime: 1_000, + }); +} + +export function useReloadCustomConfigMutation(): UseMutationResult< + TConfigReloadResult, + unknown, + void +> { + const queryClient = useQueryClient(); + return useMutation(() => dataService.reloadCustomConfig(), { + mutationKey: [MutationKeys.reloadCustomConfig], + onSuccess: (result) => { + if (result.scope !== 'unchanged') { + void queryClient.invalidateQueries([QueryKeys.models]); + void queryClient.invalidateQueries([QueryKeys.configRevision]); + } + }, + }); +} diff --git a/client/src/data-provider/ConfigReload/queries.test.tsx b/client/src/data-provider/ConfigReload/queries.test.tsx new file mode 100644 index 00000000000..0f1740ad79e --- /dev/null +++ b/client/src/data-provider/ConfigReload/queries.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { QueryKeys } from 'librechat-data-provider'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useModelCatalogRefresh } from './queries'; + +const mockGetRevision = jest.fn(); +const mockGetModelsAtRevision = jest.fn(); +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + dataService: { + ...jest.requireActual('librechat-data-provider').dataService, + getConfigRevision: (...args: unknown[]) => mockGetRevision(...args), + getModelsAtRevision: (...args: unknown[]) => mockGetModelsAtRevision(...args), + }, +})); + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +describe('model catalog refresh', () => { + beforeEach(() => { + mockGetRevision.mockReset(); + mockGetModelsAtRevision.mockReset(); + }); + + it('does not poll configuration when the browser is unauthenticated', async () => { + const client = makeClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const view = renderHook(() => useModelCatalogRefresh(false), { wrapper }); + expect(mockGetRevision).not.toHaveBeenCalled(); + expect(mockGetModelsAtRevision).not.toHaveBeenCalled(); + view.unmount(); + client.clear(); + }); + + it('retains the old picker until a replica serves models at its applied revision', async () => { + const client = makeClient(); + client.setQueryData([QueryKeys.models], { gateway: ['old-model'] }); + mockGetRevision.mockResolvedValue({ distributed: true, generation: 2, pollIntervalMs: 3000 }); + mockGetModelsAtRevision.mockRejectedValueOnce(new Error('replica still on generation 1')); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const view = renderHook(() => useModelCatalogRefresh(true, 'user-1'), { wrapper }); + const key = [QueryKeys.configRevision, 'user-1', 'models', 2]; + await waitFor(() => expect(client.getQueryState(key)?.status).toBe('error')); + expect(client.getQueryData([QueryKeys.models])).toEqual({ gateway: ['old-model'] }); + mockGetModelsAtRevision.mockResolvedValue({ gateway: ['new-model'] }); + await client.invalidateQueries(key); + await waitFor(() => + expect(client.getQueryData([QueryKeys.models])).toEqual({ gateway: ['new-model'] }), + ); + expect(mockGetModelsAtRevision).toHaveBeenCalledWith(2, expect.anything()); + view.unmount(); + client.clear(); + }); +}); diff --git a/client/src/data-provider/ConfigReload/queries.ts b/client/src/data-provider/ConfigReload/queries.ts new file mode 100644 index 00000000000..a9c856e8fb6 --- /dev/null +++ b/client/src/data-provider/ConfigReload/queries.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + dataService, + QueryKeys, + DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS, +} from 'librechat-data-provider'; + +/** A cheap revision probe runs only while an authenticated chat is visible. */ +export function useModelCatalogRefresh(authenticated: boolean, userId?: string): void { + const client = useQueryClient(); + const [target, setTarget] = useState(null); + const applied = useRef(0); + const revision = useQuery( + [QueryKeys.configRevision, userId], + ({ signal }) => dataService.getConfigRevision(signal), + { + enabled: authenticated && !!userId, + retry: false, + refetchInterval: (data) => + data == null || data.distributed + ? (data?.pollIntervalMs ?? DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS) + : false, + }, + ); + + useEffect(() => { + applied.current = 0; + setTarget(null); + }, [userId]); + + useEffect(() => { + const next = revision.data?.generation; + if (authenticated && next != null && next > 0) { + setTarget((current) => Math.max(current ?? 0, next)); + } + }, [authenticated, revision.data?.generation]); + + const models = useQuery( + [QueryKeys.configRevision, userId, 'models', target], + ({ signal }) => dataService.getModelsAtRevision(target!, signal), + { + enabled: authenticated && !!userId && target != null && target > applied.current, + retry: false, + refetchInterval: (data) => + data ? false : (revision.data?.pollIntervalMs ?? DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS), + }, + ); + + useEffect(() => { + if (!authenticated || !userId || target == null || !models.data || target <= applied.current) { + return; + } + applied.current = target; + void client.cancelQueries([QueryKeys.models]).then(() => { + if (applied.current === target) client.setQueryData([QueryKeys.models], models.data); + }); + }, [authenticated, userId, target, models.data, client]); +} diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index b554ae0de50..80451df08ef 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -19,6 +19,7 @@ export * from './Traces'; export * from './connection'; export * from './Favorites'; export * from './CodeEnvironments'; +export * from './ConfigReload'; export * from './mutations'; export * from './prompts'; export * from './queries'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b31bcbd938b..55c5b76e34e 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -990,6 +990,21 @@ "com_ui_admin": "Admin", "com_ui_admin_access_warning": "Disabling Admin access to this feature may cause unexpected UI issues requiring refresh. If saved, the only way to revert is via the interface setting in librechat.yaml config which affects all roles.", "com_ui_admin_panel": "Admin Panel", + "com_ui_config_reload_action": "Reload configuration", + "com_ui_config_reload_applied_live": "Applied live", + "com_ui_config_reload_cluster": "Model list published to Redis. Other replicas apply it after verifying their sources.", + "com_ui_config_reload_conflict": "Another replica published a different model list. Verify the current source and retry.", + "com_ui_config_reload_description": "Reload default models of an existing custom endpoint from librechat.yaml or the remote URL. Other settings require a restart.", + "com_ui_config_reload_failed": "Could not reload the configuration. Review the validation errors and try again.", + "com_ui_config_reload_forbidden": "You do not have permission to manage configuration.", + "com_ui_config_reload_loading": "Reloading...", + "com_ui_config_reload_local": "Configuration updated on this replica only. Redis is unavailable or not configured.", + "com_ui_config_reload_no_change": "Unchanged", + "com_ui_config_reload_propagation_error": "The change applied locally but could not reach other replicas. Retry reload when Redis recovers.", + "com_ui_config_reload_restart_required": "Restart required", + "com_ui_config_reload_source_error": "Could not load the configuration source. Check its availability and try again.", + "com_ui_config_reload_title": "Reload configuration", + "com_ui_config_reload_unchanged": "Configuration is unchanged.", "com_ui_admin_settings": "Admin Settings", "com_ui_admin_settings_section": "Admin Settings - {{section}}", "com_ui_advanced": "Advanced", diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 0051f7e57ab..2a2ef98ac29 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -29,9 +29,14 @@ import { useAgentsMap, useFileMap, } from '~/hooks'; +import { + useHealthCheck, + useGetStartupConfig, + useUserTermsQuery, + useModelCatalogRefresh, +} from '~/data-provider'; import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog'; import KeyboardDeleteDialog from '~/components/Nav/KeyboardDeleteDialog'; -import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider'; import { MobileDrawerScrim } from '~/components/UnifiedSidebar/mobile'; import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts'; import useDrawerDismiss from '~/hooks/Nav/useDrawerDismiss'; @@ -39,7 +44,6 @@ import useSidebarToggle from '~/hooks/Nav/useSidebarToggle'; import useSidebarState from '~/hooks/Nav/useSidebarState'; import { TermsAndConditionsModal } from '~/components/ui'; import useDrawerSwipe from '~/hooks/Nav/useDrawerSwipe'; -import { useHealthCheck } from '~/data-provider'; import { Banner } from '~/components/Banners'; import store from '~/store'; @@ -94,7 +98,7 @@ export default function Root() { }, [setSidebarExpanded], ); - const { isAuthenticated, logout } = useAuthContext(); + const { isAuthenticated, logout, user } = useAuthContext(); /** Releases feature-catalog queries after first paint on browser idle. */ useCatalogWarmup(isAuthenticated); @@ -109,6 +113,7 @@ export default function Root() { }); useHealthCheck(isAuthenticated); + useModelCatalogRefresh(isAuthenticated, user?.id); const assistantsMap = useAssistantsMap({ isAuthenticated }); const agentsMap = useAgentsMap({ isAuthenticated }); diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index b2168470b72..96847f6282f 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -14,6 +14,9 @@ jest.mock('@librechat/data-schemas', () => { }; }); +import { ConfigReloadError } from '~/app/loader'; +import { SystemCapabilities } from '@librechat/data-schemas'; +import { ConfigGenerationConflictError } from '~/app/reload'; import { createAdminConfigHandlers } from './config'; function mockReq(overrides = {}) { @@ -81,6 +84,78 @@ function createHandlers(overrides = {}) { } describe('createAdminConfigHandlers', () => { + describe('reloadConfig', () => { + it('rejects an admin without broad manage:configs before reading the deployment source', async () => { + const reloadCustomConfig = jest.fn(); + const hasCapability = jest.fn().mockResolvedValue(false); + const { handlers } = createHandlers({ reloadCustomConfig, hasCapability }); + const res = mockRes(); + + await handlers.reloadConfig(mockReq(), res); + + expect(res.statusCode).toBe(403); + expect(hasCapability).toHaveBeenCalledWith( + expect.objectContaining({ id: 'u1' }), + SystemCapabilities.MANAGE_CONFIGS, + { platformOnly: true }, + ); + expect(reloadCustomConfig).not.toHaveBeenCalled(); + }); + + it('returns the per-section reload report', async () => { + const report = { + scope: 'cluster' as const, + distributed: true, + generation: 7, + sections: [ + { + section: 'endpoints', + status: 'applied_live' as const, + restartRequired: false, + }, + ], + }; + const { handlers } = createHandlers({ + reloadCustomConfig: jest.fn().mockResolvedValue(report), + }); + const res = mockRes(); + + await handlers.reloadConfig(mockReq(), res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(report); + }); + + it('returns a retryable conflict when another replica publishes first', async () => { + const { handlers } = createHandlers({ + reloadCustomConfig: jest.fn().mockRejectedValue(new ConfigGenerationConflictError()), + }); + const res = mockRes(); + await handlers.reloadConfig(mockReq(), res); + expect(res.statusCode).toBe(409); + }); + + it('returns validation issues without applying an invalid config', async () => { + const reloadCustomConfig = jest + .fn() + .mockRejectedValue( + new ConfigReloadError('Invalid custom config', undefined, [ + { code: 'custom', path: ['endpoints'], message: 'Invalid endpoints' }, + ]), + ); + const { handlers } = createHandlers({ reloadCustomConfig }); + const res = mockRes(); + + await handlers.reloadConfig(mockReq(), res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: 'Custom config validation failed', + validationErrors: [{ code: 'custom', path: ['endpoints'], message: 'Invalid endpoints' }], + }); + }); + }); + describe('listConfigs', () => { it('redacts secret fields from config list responses', async () => { const { handlers } = createHandlers({ diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index ddeaffe0e16..6079fa9aed0 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -1,5 +1,6 @@ import { logger, + SystemCapabilities, getConfigFieldIssues, applyConfigTombstones, getConfigOverrideIssues, @@ -28,6 +29,7 @@ import type { TCustomConfig } from 'librechat-data-provider'; import type { Types, ClientSession } from 'mongoose'; import type { Response } from 'express'; import type { CapabilityUser } from '~/middleware/capabilities'; +import type { ConfigReloadResult } from '~/app/reload'; import type { ServerRequest } from '~/types/http'; import { encryptConfigSecretFields, @@ -41,6 +43,8 @@ import { preserveConfigSecrets, redactConfigSecrets, } from './secrets'; +import { ConfigGenerationConflictError } from '~/app/reload'; +import { ConfigReloadError } from '~/app/loader'; const UNSAFE_SEGMENTS = /(?:^|\.)(__[\w]*|constructor|prototype)(?:\.|$)/; const MAX_PATCH_ENTRIES = 100; @@ -242,7 +246,11 @@ export interface AdminConfigDeps { user: CapabilityUser, sections: ConfigSection[], ) => Promise<{ broad: boolean; sections: Set }>; - hasCapability?: (user: CapabilityUser, capability: SystemCapability) => Promise; + hasCapability?: ( + user: CapabilityUser, + capability: SystemCapability, + options?: { platformOnly?: boolean }, + ) => Promise; getAppConfig?: (options?: { role?: string; userId?: string; @@ -251,6 +259,8 @@ export interface AdminConfigDeps { }) => Promise; /** Invalidate all config-related caches after a mutation. */ invalidateConfigCaches?: (tenantId?: string) => Promise; + /** Validate, install, and publish a new deployment config generation. */ + reloadCustomConfig?: () => Promise; } // ── Validation helpers ─────────────────────────────────────────────── @@ -450,6 +460,7 @@ function preservePatchedConfigSecretFields( export function createAdminConfigHandlers(deps: AdminConfigDeps): { listConfigs: (req: ServerRequest, res: Response) => Promise; getBaseConfig: (req: ServerRequest, res: Response) => Promise; + reloadConfig: (req: ServerRequest, res: Response) => Promise; getConfig: (req: ServerRequest, res: Response) => Promise; upsertConfigOverrides: (req: ServerRequest, res: Response) => Promise; patchConfigField: (req: ServerRequest, res: Response) => Promise; @@ -481,6 +492,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { hasCapability = async () => false, getAppConfig, invalidateConfigCaches, + reloadCustomConfig, } = deps; /** The deployment's `librechat.yaml` config, which overrides are validated on top of. */ @@ -554,6 +566,39 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { } } + async function reloadConfig(req: ServerRequest, res: Response): Promise { + const user = getCapabilityUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + try { + if (!(await hasCapability(user, SystemCapabilities.MANAGE_CONFIGS, { platformOnly: true }))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + if (!reloadCustomConfig) { + return res.status(501).json({ error: 'Config reload is not configured' }); + } + return res.status(200).json(await reloadCustomConfig()); + } catch (error) { + if (error instanceof ConfigGenerationConflictError) { + return res.status(409).json({ error: error.message }); + } + if (error instanceof ConfigReloadError) { + const validationErrors = error.validationErrors ?? []; + return res.status(400).json({ + error: + validationErrors.length > 0 + ? 'Custom config validation failed' + : 'Custom config source could not be loaded', + validationErrors, + }); + } + logger.error('[adminConfig] reloadConfig error:', error); + return res.status(500).json({ error: 'Failed to reload config' }); + } + } + /** * GET /:principalType/:principalId — Get config for a specific principal. */ @@ -1303,6 +1348,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return { listConfigs, getBaseConfig, + reloadConfig, getConfig, upsertConfigOverrides, patchConfigField, diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 52e45da305a..b0df7b0e513 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,5 +1,8 @@ export * from './service'; +export * from './reload'; +export * from './status'; export * from './loader'; +export * from './lifecycle'; export * from './config'; export * from './metrics'; export * from './permissions'; diff --git a/packages/api/src/app/lifecycle.spec.ts b/packages/api/src/app/lifecycle.spec.ts new file mode 100644 index 00000000000..d52707ddf51 --- /dev/null +++ b/packages/api/src/app/lifecycle.spec.ts @@ -0,0 +1,75 @@ +import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { createDeploymentConfigService } from './lifecycle'; +import { createConfigGenerationTracker } from './reload'; + +function appConfig(config: TCustomConfig): AppConfig { + return { config, availableTools: {}, endpoints: config.endpoints } as AppConfig; +} + +describe('createDeploymentConfigService', () => { + it('bootstraps before startup, then pins restart-only settings and uses the last-good timeout', async () => { + const source: TCustomConfig = { + version: '1.0', + memory: { disabled: false }, + configReload: { remoteTimeoutMs: 12_000 }, + }; + const candidate: TCustomConfig = { + version: '2.0', + memory: { disabled: true }, + configReload: { remoteTimeoutMs: 12_000 }, + }; + const loadCustomConfig = jest + .fn() + .mockResolvedValueOnce(source) + .mockResolvedValueOnce(candidate); + const buildBaseConfig = jest.fn(async (config: TCustomConfig) => appConfig(config)); + const bootstrap = jest.fn().mockResolvedValue(undefined); + const generation = { + ...createConfigGenerationTracker(), + distributed: true, + bootstrap, + check: jest.fn().mockResolvedValue(undefined), + }; + const store = new Map(); + const service = createDeploymentConfigService({ + loadCustomConfig, + buildBaseConfig, + generation, + configService: { + setCachedTools: jest.fn().mockResolvedValue(undefined), + getCache: () => ({ + get: async (key: string) => store.get(key), + set: async (key: string, value: unknown) => { + store.set(key, value); + }, + delete: async (key: string) => store.delete(key), + }), + cacheKeys: { APP_CONFIG: 'APP_CONFIG' }, + getApplicableConfigs: async () => [], + getUserPrincipals: async () => [], + }, + }); + + await service.getAppConfig({ baseOnly: true }); + expect(bootstrap).toHaveBeenCalledTimes(1); + expect(bootstrap.mock.invocationCallOrder[0]).toBeLessThan( + loadCustomConfig.mock.invocationCallOrder[0], + ); + await service.clearAppConfigCache(); + const base = await service.getAppConfig({ baseOnly: true }); + expect(loadCustomConfig).toHaveBeenNthCalledWith(1, true, { + mode: 'startup', + remoteTimeoutMs: undefined, + }); + expect(loadCustomConfig).toHaveBeenNthCalledWith(2, false, { + mode: 'reload', + remoteTimeoutMs: 12_000, + }); + expect(base.config?.memory?.disabled).toBe(false); + expect(base.config?.version).toBe('1.0'); + expect(buildBaseConfig).toHaveBeenLastCalledWith( + expect.objectContaining({ memory: { disabled: false } }), + ); + }); +}); diff --git a/packages/api/src/app/lifecycle.ts b/packages/api/src/app/lifecycle.ts new file mode 100644 index 00000000000..58211a25866 --- /dev/null +++ b/packages/api/src/app/lifecycle.ts @@ -0,0 +1,70 @@ +import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { CustomConfigLoadOptions, CustomConfigLoadMode } from './loader'; +import type { ConfigGenerationTracker } from './reload'; +import type { AppConfigServiceDeps } from './service'; +import { createConfigReloader, retainRestartOnlyConfig } from './reload'; +import { createAppConfigService } from './service'; + +type ConfigServiceDependencies = Omit< + AppConfigServiceDeps, + 'loadBaseConfig' | 'syncConfigGeneration' | 'bootstrapConfigGeneration' +>; + +export type DeploymentConfigService = ReturnType & { + reloadCustomConfig: ReturnType; +}; + +export interface DeploymentConfigDependencies { + loadCustomConfig: ( + printConfig: boolean, + options: CustomConfigLoadOptions, + ) => Promise; + buildBaseConfig: (config: TCustomConfig) => Promise; + generation: ConfigGenerationTracker; + configService: ConfigServiceDependencies; +} + +/** Compose source loading and explicit publication without putting policy in the CJS host. */ +export function createDeploymentConfigService({ + loadCustomConfig, + buildBaseConfig, + generation, + configService, +}: DeploymentConfigDependencies): DeploymentConfigService { + const loadBaseConfig = async (mode: CustomConfigLoadMode = 'startup', previous?: AppConfig) => { + const source = + (await loadCustomConfig(mode === 'startup', { + mode, + remoteTimeoutMs: previous?.config?.configReload?.remoteTimeoutMs, + })) ?? {}; + return buildBaseConfig(retainRestartOnlyConfig(previous?.config, source)); + }; + + const service = createAppConfigService({ + ...configService, + loadBaseConfig, + ...(generation.distributed + ? { + syncConfigGeneration: generation.check, + bootstrapConfigGeneration: generation.bootstrap, + getAppliedGeneration: generation.applied, + } + : {}), + }); + const reloadCustomConfig = createConfigReloader({ + loadConfig: (current) => + loadCustomConfig(false, { + mode: 'reload', + remoteTimeoutMs: current.config?.configReload?.remoteTimeoutMs, + }), + buildBaseConfig, + getBaseConfig: () => service.getAppConfig({ baseOnly: true }), + replaceBaseConfig: service.replaceBaseConfig, + clearOverrideCache: service.clearOverrideCache, + withConfigUpdate: service.withConfigUpdate, + generation, + }); + + return { ...service, reloadCustomConfig }; +} diff --git a/packages/api/src/app/loader.ts b/packages/api/src/app/loader.ts index 6365450c4ea..da5a3bbd291 100644 --- a/packages/api/src/app/loader.ts +++ b/packages/api/src/app/loader.ts @@ -22,12 +22,17 @@ export type CustomConfigLoadMode = 'startup' | 'reload'; export interface CustomConfigLoadOptions { mode?: CustomConfigLoadMode; + remoteTimeoutMs?: number; } export interface CustomConfigLoaderOptions { defaultConfigPath: string; loadLocal: (configPath: string) => unknown; - fetchRemote?: (configPath: string) => Promise; + fetchRemote?: ( + configPath: string, + mode: CustomConfigLoadMode, + timeoutMs: number, + ) => Promise; redactConfig: (config: TCustomConfig) => TCustomConfig; } @@ -43,6 +48,8 @@ export class ConfigReloadError extends Error { } } +const REMOTE_CONFIG_RELOAD_TIMEOUT_MS = 10_000; + const OPENROUTER_PROMPT_CACHE_DEFAULT = { key: 'promptCache', default: true, @@ -159,7 +166,16 @@ export function createCustomConfigLoader({ defaultConfigPath, loadLocal, redactConfig, - fetchRemote = async (configPath: string): Promise => (await axios.get(configPath)).data, + fetchRemote = async ( + configPath: string, + mode: CustomConfigLoadMode, + timeoutMs: number, + ): Promise => + ( + await (mode === 'reload' + ? axios.get(configPath, { timeout: timeoutMs }) + : axios.get(configPath)) + ).data, }: CustomConfigLoaderOptions): ( printConfig?: boolean, options?: CustomConfigLoadOptions, @@ -205,7 +221,11 @@ export function createCustomConfigLoader({ let loadedConfig: unknown; if (isRemoteConfigPath(configPath)) { try { - loadedConfig = await fetchRemote(configPath); + loadedConfig = await fetchRemote( + configPath, + mode, + options.remoteTimeoutMs ?? REMOTE_CONFIG_RELOAD_TIMEOUT_MS, + ); } catch (error) { return failSourceLoad(`Failed to fetch the remote config file from ${configPath}`, error); } @@ -242,13 +262,20 @@ export function createCustomConfigLoader({ } } - setMaxSubagents(getConfiguredMaxSubagents(loadedConfig)); + if (mode === 'startup') setMaxSubagents(getConfiguredMaxSubagents(loadedConfig)); const result = configSchema.strict().safeParse(loadedConfig); if ( result.error?.errors.some( (error) => error.path != null && error.path.includes('imageOutputType'), ) ) { + if (mode === 'reload') { + throw new ConfigReloadError( + 'Invalid imageOutputType in custom config', + result.error, + result.error.errors, + ); + } throw new Error( `\nPlease specify a correct \`imageOutputType\` value (case-sensitive).\n\n` + 'The available options are:\n' + @@ -301,7 +328,20 @@ export function createCustomConfigLoader({ } for (const endpoint of customEndpoints) { if (endpoint.customParams) { - parseCustomParams(endpoint.name, endpoint.customParams); + try { + parseCustomParams(endpoint.name, endpoint.customParams); + } catch (error) { + if (mode !== 'reload') { + throw error; + } + throw new ConfigReloadError('Invalid custom endpoint parameters', error, [ + { + code: 'custom', + path: ['endpoints', 'custom'], + message: error instanceof Error ? error.message : 'Invalid parameter definitions', + }, + ]); + } } } if (result.data.modelSpecs) { @@ -323,7 +363,7 @@ export function createCustomConfigLoader({ } throw error; } finally { - if (!loadedSuccessfully) { + if (mode === 'reload' || !loadedSuccessfully) { setMaxSubagents(previousMaxSubagents); } } diff --git a/packages/api/src/app/reload.spec.ts b/packages/api/src/app/reload.spec.ts new file mode 100644 index 00000000000..be5b5c4908f --- /dev/null +++ b/packages/api/src/app/reload.spec.ts @@ -0,0 +1,869 @@ +import { FileSources } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { + createConfigReloader, + createConfigReloadReport, + createConfigGenerationTracker, + hashConfig, + retainRestartOnlyConfig, +} from './reload'; +import { createAppConfigService } from './service'; +import { ConfigReloadError } from './loader'; + +class MemoryGenerationStore { + private generation = 0; + private digest = ''; + + get = jest.fn( + async (_key?: string): Promise => + JSON.stringify({ generation: this.generation, digest: this.digest }), + ); + + publish = jest.fn( + async (_key: string, digest: string, expectedRaw?: string | null): Promise => { + const raw = JSON.stringify({ generation: this.generation, digest: this.digest }); + if (expectedRaw !== undefined && expectedRaw !== raw) return null; + this.generation += 1; + this.digest = digest; + return this.generation; + }, + ); +} + +function appConfig(config: TCustomConfig): AppConfig { + return { config, endpoints: config.endpoints } as AppConfig; +} + +function createReplica( + source: { current: TCustomConfig }, + generation: ReturnType, +) { + const entries = new Map(); + const cache = { + get: async (key: string) => entries.get(`APP_CONFIG:${key}`), + set: async (key: string, value: unknown) => { + entries.set(`APP_CONFIG:${key}`, value as AppConfig); + }, + delete: async (key: string) => entries.delete(`APP_CONFIG:${key}`), + opts: { store: { keys: () => entries.keys() } }, + }; + return createAppConfigService({ + loadBaseConfig: async (_mode, previous) => + appConfig(retainRestartOnlyConfig(previous?.config, source.current)), + setCachedTools: async () => undefined, + getCache: () => cache, + cacheKeys: { APP_CONFIG: 'APP_CONFIG' }, + getApplicableConfigs: async () => [], + getUserPrincipals: async () => [], + syncConfigGeneration: generation.check, + bootstrapConfigGeneration: generation.bootstrap, + getAppliedGeneration: generation.applied, + }); +} + +function customConfig(model: string): TCustomConfig { + return { + version: '1.2.1', + configReload: { clusterReady: true }, + endpoints: { + custom: [ + { + name: 'gateway', + apiKey: 'user_provided', + baseURL: 'https://example.com/v1', + models: { default: [model], fetch: false }, + }, + ], + }, + }; +} + +describe('config reload', () => { + it('applies a custom endpoint model change on two replicas through one generation bump', async () => { + const source = { current: customConfig('old-model') }; + const store = new MemoryGenerationStore(); + const generationA = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + const generationB = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + const replicaA = createReplica(source, generationA); + const replicaB = createReplica(source, generationB); + + await Promise.all([ + replicaA.getAppConfig({ baseOnly: true }), + replicaB.getAppConfig({ baseOnly: true }), + ]); + source.current = customConfig('new-model'); + + const reload = createConfigReloader({ + loadConfig: async () => source.current, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: () => replicaA.getAppConfig({ baseOnly: true }), + replaceBaseConfig: replicaA.replaceBaseConfig, + clearOverrideCache: () => replicaA.clearOverrideCache(), + generation: generationA, + }); + const result = await reload(); + + await replicaB.getAppConfig({ baseOnly: true }); + await new Promise((resolve) => setImmediate(resolve)); + const [configA, configB] = await Promise.all([ + replicaA.getAppConfig({ baseOnly: true }), + replicaB.getAppConfig({ baseOnly: true }), + ]); + expect(result).toMatchObject({ scope: 'cluster', distributed: true, generation: 1 }); + expect(configA.config?.endpoints?.custom?.[0].models?.default).toEqual(['new-model']); + expect(configB.config?.endpoints?.custom?.[0].models?.default).toEqual(['new-model']); + expect(store.publish).toHaveBeenCalledTimes(1); + let applied = await replicaB.getConfigRefreshStatus(); + for (let attempt = 0; attempt < 8 && applied.generation !== 1; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + applied = await replicaB.getConfigRefreshStatus(); + } + expect(applied.generation).toBe(1); + }); + + it('reconciles an unchanged local config with an older published digest', async () => { + const store = new MemoryGenerationStore(); + await store.publish('config:model-catalog:v1', hashConfig(customConfig('old-model'))); + const generation = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + await generation.bootstrap(); + let current = appConfig(customConfig('new-model')); + const reload = createConfigReloader({ + loadConfig: async () => customConfig('new-model'), + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: async () => current, + replaceBaseConfig: async (config) => (current = config), + clearOverrideCache: async () => undefined, + generation, + }); + + await expect(reload()).resolves.toMatchObject({ scope: 'cluster', generation: 2 }); + expect(JSON.parse(await store.get('config:model-catalog:v1'))).toMatchObject({ + generation: 2, + digest: hashConfig(customConfig('new-model')), + }); + expect(current.config?.endpoints?.custom?.[0].models?.default).toEqual(['new-model']); + }); + + it('rejects a lagging unchanged source instead of replacing a newer publication', async () => { + const store = new MemoryGenerationStore(); + const current = customConfig('old-model'); + const latest = customConfig('new-model'); + const older = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + const newer = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + await Promise.all([older.bootstrap(), newer.bootstrap()]); + const reloadNewer = createConfigReloader({ + loadConfig: async () => latest, + buildBaseConfig: async (source) => appConfig(source), + getBaseConfig: async () => appConfig(current), + replaceBaseConfig: async (source) => source, + clearOverrideCache: async () => undefined, + generation: newer, + }); + await expect(reloadNewer()).resolves.toMatchObject({ scope: 'cluster', generation: 1 }); + const replaceBaseConfig = jest.fn(); + const reloadOlder = createConfigReloader({ + loadConfig: async () => current, + buildBaseConfig: async (source) => appConfig(source), + getBaseConfig: async () => appConfig(current), + replaceBaseConfig, + clearOverrideCache: async () => undefined, + generation: older, + }); + await expect(reloadOlder()).rejects.toMatchObject({ name: 'ConfigGenerationConflictError' }); + expect(replaceBaseConfig).not.toHaveBeenCalled(); + expect(JSON.parse(await store.get('config:model-catalog:v1'))).toMatchObject({ + generation: 1, + digest: hashConfig(latest), + }); + }); + + it('does not let an older replica publish a source that lost a concurrent reload', async () => { + const store = new MemoryGenerationStore(); + const olderSource = { current: customConfig('old-model') }; + const newerSource = { current: customConfig('old-model') }; + const olderGeneration = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + const newerGeneration = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + const older = createReplica(olderSource, olderGeneration); + const newer = createReplica(newerSource, newerGeneration); + await Promise.all([ + older.getAppConfig({ baseOnly: true }), + newer.getAppConfig({ baseOnly: true }), + ]); + + let releaseOlder: (() => void) | undefined; + let signalStarted: (() => void) | undefined; + const started = new Promise((resolve) => (signalStarted = resolve)); + const holdOlder = new Promise((resolve) => (releaseOlder = resolve)); + const obsolete = customConfig('intermediate-model'); + const reloadOlder = createConfigReloader({ + loadConfig: async () => { + signalStarted?.(); + await holdOlder; + return obsolete; + }, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: () => older.getAppConfig({ baseOnly: true }), + replaceBaseConfig: older.replaceBaseConfig, + clearOverrideCache: older.clearOverrideCache, + withConfigUpdate: older.withConfigUpdate, + generation: olderGeneration, + }); + const reloadNewer = createConfigReloader({ + loadConfig: async () => newerSource.current, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: () => newer.getAppConfig({ baseOnly: true }), + replaceBaseConfig: newer.replaceBaseConfig, + clearOverrideCache: newer.clearOverrideCache, + withConfigUpdate: newer.withConfigUpdate, + generation: newerGeneration, + }); + const pending = reloadOlder(); + await started; + newerSource.current = customConfig('newest-model'); + await expect(reloadNewer()).resolves.toMatchObject({ scope: 'cluster', generation: 1 }); + olderSource.current = newerSource.current; + releaseOlder?.(); + await expect(pending).rejects.toMatchObject({ name: 'ConfigGenerationConflictError' }); + expect(JSON.parse(await store.get('config:model-catalog:v1'))).toMatchObject({ + generation: 1, + digest: hashConfig(newerSource.current), + }); + expect( + (await older.getAppConfig({ baseOnly: true })).config?.endpoints?.custom?.[0].models?.default, + ).toEqual(['old-model']); + let observed = await older.getAppConfig({ baseOnly: true }); + for ( + let attempt = 0; + attempt < 8 && + observed.config?.endpoints?.custom?.[0].models?.default?.[0] !== 'newest-model'; + attempt++ + ) { + await new Promise((resolve) => setImmediate(resolve)); + observed = await older.getAppConfig({ baseOnly: true }); + } + expect(observed.config?.endpoints?.custom?.[0].models?.default).toEqual(['newest-model']); + expect(store.publish).toHaveBeenCalledTimes(2); + }); + + it('does not acknowledge a valid but lagging replica source until it matches the published digest', async () => { + const sourceA = { current: customConfig('old-model') }; + const sourceB = { current: customConfig('old-model') }; + const store = new MemoryGenerationStore(); + const publisherGeneration = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + const replicaA = createReplica(sourceA, publisherGeneration); + let time = 1_000; + const replicaB = createReplica( + sourceB, + createConfigGenerationTracker(store, { pollIntervalMs: 0, now: () => time }), + ); + await Promise.all([ + replicaA.getAppConfig({ baseOnly: true }), + replicaB.getAppConfig({ baseOnly: true }), + ]); + sourceA.current = customConfig('new-model'); + const reload = createConfigReloader({ + loadConfig: async () => sourceA.current, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: () => replicaA.getAppConfig({ baseOnly: true }), + replaceBaseConfig: replicaA.replaceBaseConfig, + clearOverrideCache: replicaA.clearOverrideCache, + generation: publisherGeneration, + }); + await reload(); + + await replicaB.getAppConfig({ baseOnly: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect( + (await replicaB.getAppConfig({ baseOnly: true })).config.endpoints?.custom?.[0].models + ?.default, + ).toEqual(['old-model']); + + expect(await replicaB.getConfigRefreshStatus()).toMatchObject({ generation: null }); + sourceB.current = customConfig('new-model'); + time += 5_001; + let observed = await replicaB.getAppConfig({ baseOnly: true }); + for ( + let attempt = 0; + attempt < 8 && observed.config.endpoints?.custom?.[0].models?.default?.[0] !== 'new-model'; + attempt++ + ) { + await new Promise((resolve) => setImmediate(resolve)); + observed = await replicaB.getAppConfig({ baseOnly: true }); + } + expect(observed.config.endpoints?.custom?.[0].models?.default).toEqual(['new-model']); + }); + + it('waits for a lagging replica source while ignoring differences in restart-only settings', async () => { + const publisherSource = { + current: { ...customConfig('old-model'), fileStrategy: FileSources.local } as TCustomConfig, + }; + const followerSource = { + current: { ...customConfig('old-model'), fileStrategy: FileSources.s3 } as TCustomConfig, + }; + const store = new MemoryGenerationStore(); + const publisherGeneration = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + let time = 1_000; + const followerGeneration = createConfigGenerationTracker(store, { + pollIntervalMs: 0, + now: () => time, + }); + const publisher = createReplica(publisherSource, publisherGeneration); + const follower = createReplica(followerSource, followerGeneration); + await Promise.all([ + publisher.getAppConfig({ baseOnly: true }), + follower.getAppConfig({ baseOnly: true }), + ]); + publisherSource.current = { ...customConfig('new-model'), fileStrategy: FileSources.local }; + const reload = createConfigReloader({ + loadConfig: async () => publisherSource.current, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: () => publisher.getAppConfig({ baseOnly: true }), + replaceBaseConfig: publisher.replaceBaseConfig, + clearOverrideCache: () => publisher.clearOverrideCache(), + generation: publisherGeneration, + }); + await reload(); + + await follower.getAppConfig({ baseOnly: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect( + (await follower.getAppConfig({ baseOnly: true })).config?.endpoints?.custom?.[0].models + ?.default, + ).toEqual(['old-model']); + followerSource.current = { ...customConfig('new-model'), fileStrategy: FileSources.s3 }; + time += 5_001; + let config = await follower.getAppConfig({ baseOnly: true }); + for ( + let attempt = 0; + attempt < 8 && config.config?.endpoints?.custom?.[0].models?.default?.[0] !== 'new-model'; + attempt++ + ) { + await new Promise((resolve) => setImmediate(resolve)); + config = await follower.getAppConfig({ baseOnly: true }); + } + expect(config.config?.endpoints?.custom?.[0].models?.default).toEqual(['new-model']); + expect(config.config?.fileStrategy).toBe(FileSources.s3); + }); + + it('applies only a default-model edit while retaining startup-owned settings', async () => { + const previous = customConfig('old-model'); + const candidate = { + ...customConfig('new-model'), + version: '2.0', + skillSync: { github: { runOnStartup: true } }, + mcpSettings: { catalogRecovery: { maxStateEntries: 30 } }, + endpoints: { + ...customConfig('new-model').endpoints, + custom: [{ ...customConfig('new-model').endpoints!.custom![0], apiKey: 'rotated' }], + }, + } as TCustomConfig; + const effective = retainRestartOnlyConfig(previous, candidate); + expect(effective.endpoints?.custom?.[0].models?.default).toEqual(['new-model']); + expect(effective.endpoints?.custom?.[0].apiKey).toBe('user_provided'); + expect(effective.version).toBe('1.2.1'); + expect(effective.skillSync).toBeUndefined(); + expect(effective.mcpSettings).toBeUndefined(); + expect(hashConfig(effective)).toBe(hashConfig(customConfig('new-model'))); + expect(createConfigReloadReport(previous, candidate)).toContainEqual({ + section: 'endpoints', + status: 'applied_live', + restartRequired: true, + restartRequiredPaths: ['endpoints.custom.0.apiKey'], + }); + for (const section of ['skillSync', 'mcpSettings', 'version']) { + expect(createConfigReloadReport(previous, candidate)).toEqual( + expect.arrayContaining([expect.objectContaining({ section, status: 'restart_required' })]), + ); + } + }); + + it('never applies an added, removed, renamed or reordered custom endpoint', () => { + const previous = customConfig('old-model'); + const inserted = { + ...customConfig('new-model'), + endpoints: { + custom: [ + ...customConfig('new-model').endpoints!.custom!, + { + name: 'second', + apiKey: 'user_provided', + baseURL: 'https://example.com', + models: { default: ['extra-model'], fetch: false }, + }, + ], + }, + } as TCustomConfig; + expect(retainRestartOnlyConfig(previous, inserted)).toBe(previous); + expect(hashConfig(retainRestartOnlyConfig(previous, inserted))).toBe(hashConfig(previous)); + expect(createConfigReloadReport(previous, inserted)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ section: 'endpoints', status: 'restart_required' }), + ]), + ); + }); + + it('requires explicit cluster activation before loading an edited deployment source', async () => { + const source = customConfig('old-model'); + const loadConfig = jest.fn().mockResolvedValue(customConfig('new-model')); + const generation = createConfigGenerationTracker(new MemoryGenerationStore()); + const reload = createConfigReloader({ + loadConfig, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: async () => appConfig({ ...source, configReload: { clusterReady: false } }), + replaceBaseConfig: async (config) => config, + clearOverrideCache: async () => undefined, + generation, + }); + await expect(reload()).rejects.toThrow('clusterReady'); + expect(loadConfig).not.toHaveBeenCalled(); + }); + + it('rejects invalid config without changing the base config or generation', async () => { + const replaceBaseConfig = jest.fn(); + const clearOverrideCache = jest.fn(); + const generation = { + distributed: true, + check: jest.fn().mockResolvedValue(undefined), + bootstrap: jest.fn().mockResolvedValue(undefined), + snapshot: jest.fn().mockResolvedValue({ raw: null }), + applied: jest.fn(), + accept: jest.fn(), + superseded: jest.fn().mockReturnValue(false), + bump: jest.fn(), + }; + const reload = createConfigReloader({ + loadConfig: jest + .fn() + .mockRejectedValue( + new ConfigReloadError('Invalid custom config', undefined, [ + { code: 'custom', path: ['endpoints'], message: 'Invalid endpoints' }, + ]), + ), + buildBaseConfig: jest.fn(), + getBaseConfig: jest.fn().mockResolvedValue(appConfig(customConfig('old-model'))), + replaceBaseConfig, + clearOverrideCache, + generation, + }); + + await expect(reload()).rejects.toMatchObject({ + name: 'ConfigReloadError', + validationErrors: [{ message: 'Invalid endpoints' }], + }); + expect(replaceBaseConfig).not.toHaveBeenCalled(); + expect(clearOverrideCache).not.toHaveBeenCalled(); + expect(generation.bump).not.toHaveBeenCalled(); + }); + + it('retries a failed generation bump when the local config is already current', async () => { + let current = appConfig(customConfig('old-model')); + const candidate = customConfig('new-model'); + const replaceBaseConfig = jest.fn(async (config: AppConfig) => { + current = config; + return config; + }); + const generation = { + distributed: true, + check: jest.fn().mockResolvedValue(undefined), + bootstrap: jest.fn().mockResolvedValue(undefined), + snapshot: jest.fn().mockResolvedValue({ raw: null }), + applied: jest.fn(), + accept: jest.fn(), + superseded: jest.fn().mockReturnValue(false), + bump: jest + .fn() + .mockRejectedValueOnce(new Error('Redis unavailable')) + .mockResolvedValueOnce(1), + }; + const reload = createConfigReloader({ + loadConfig: async () => candidate, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: async () => current, + replaceBaseConfig, + clearOverrideCache: async () => undefined, + generation, + }); + + await expect(reload()).resolves.toMatchObject({ + scope: 'local', + propagationError: 'Redis generation update failed', + }); + await expect(reload()).resolves.toMatchObject({ + scope: 'cluster', + generation: 1, + }); + expect(generation.bump).toHaveBeenCalledTimes(2); + expect(replaceBaseConfig).toHaveBeenCalledTimes(1); + }); + + it('reports local-only scope when Redis is not configured', async () => { + const previous = appConfig(customConfig('old-model')); + const next = customConfig('new-model'); + const reload = createConfigReloader({ + loadConfig: async () => next, + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: async () => previous, + replaceBaseConfig: async (config) => config, + clearOverrideCache: async () => undefined, + generation: createConfigGenerationTracker(), + }); + + await expect(reload()).resolves.toMatchObject({ scope: 'local', distributed: false }); + }); + + it('restores the previous base when local override invalidation fails', async () => { + const previous = appConfig(customConfig('old-model')); + const replaceBaseConfig = jest.fn(async (config: AppConfig) => config); + const generation = { + distributed: true, + check: jest.fn().mockResolvedValue(undefined), + bootstrap: jest.fn().mockResolvedValue(undefined), + snapshot: jest.fn().mockResolvedValue({ raw: null }), + applied: jest.fn(), + accept: jest.fn(), + superseded: jest.fn().mockReturnValue(false), + bump: jest.fn(), + }; + const reload = createConfigReloader({ + loadConfig: async () => customConfig('new-model'), + buildBaseConfig: async (config) => appConfig(config), + getBaseConfig: async () => previous, + replaceBaseConfig, + clearOverrideCache: jest.fn().mockRejectedValue(new Error('cache failure')), + generation, + }); + + await expect(reload()).rejects.toThrow('cache failure'); + expect(replaceBaseConfig).toHaveBeenCalledTimes(2); + expect(replaceBaseConfig).toHaveBeenLastCalledWith(previous); + expect(generation.bump).not.toHaveBeenCalled(); + }); + + it('keeps restart-only storage settings out of the installed config', async () => { + const previous = appConfig({ ...customConfig('old-model'), fileStrategy: FileSources.local }); + const next: TCustomConfig = { ...customConfig('new-model'), fileStrategy: FileSources.s3 }; + const buildBaseConfig = jest.fn(async (config: TCustomConfig) => appConfig(config)); + const reload = createConfigReloader({ + loadConfig: async () => next, + buildBaseConfig, + getBaseConfig: async () => previous, + replaceBaseConfig: async (config) => config, + clearOverrideCache: async () => undefined, + generation: createConfigGenerationTracker(), + }); + + const result = await reload(); + + expect(buildBaseConfig).toHaveBeenCalledWith( + expect.objectContaining({ fileStrategy: 'local', endpoints: next.endpoints }), + ); + expect(result.sections).toContainEqual({ + section: 'fileStrategy', + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: ['fileStrategy'], + }); + expect(result.sections).toContainEqual({ + section: 'endpoints', + status: 'applied_live', + restartRequired: false, + }); + }); + + it('keeps startup-only nested agent policies from becoming live', () => { + const previous: TCustomConfig = { + version: '1.0', + endpoints: { agents: { backgroundTasks: { completionWakeups: false } } }, + }; + const next: TCustomConfig = { + version: '1.0', + endpoints: { agents: { backgroundTasks: { completionWakeups: true } } }, + }; + const effective = retainRestartOnlyConfig(previous, next); + + expect(effective.endpoints?.agents?.backgroundTasks?.completionWakeups).toBe(false); + expect(createConfigReloadReport(previous, next)).toContainEqual({ + section: 'endpoints', + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: ['endpoints.agents.backgroundTasks.completionWakeups'], + }); + expect(hashConfig(effective)).toBe(hashConfig(previous)); + }); + + it('ignores startup-only differences in the shared digest after a replica restarts', () => { + const live = customConfig('new-model'); + const publisher: TCustomConfig = { ...live, fileStrategy: FileSources.local }; + const restartedReplica: TCustomConfig = { ...live, fileStrategy: FileSources.s3 }; + expect(hashConfig(restartedReplica)).toBe(hashConfig(publisher)); + expect(hashConfig(customConfig('old-model'))).not.toBe(hashConfig(publisher)); + }); + + it('prunes newly added restart-only parents so startup defaults remain available', () => { + const previous: TCustomConfig = { version: '1.0' }; + const next: TCustomConfig = { + version: '1.0', + registration: { socialLogins: ['openid'] }, + endpoints: { agents: { backgroundTasks: { completionWakeups: false } } }, + }; + + expect(retainRestartOnlyConfig(previous, next)).toEqual(previous); + expect(hashConfig(next)).toBe(hashConfig(previous)); + expect(next.registration?.socialLogins).toEqual(['openid']); + }); + + it('pins memory policy and global static tool filters until restart', () => { + const previous: TCustomConfig = { + version: '1.0', + memory: { disabled: false }, + includedTools: ['Calculator'], + filteredTools: ['OpenWeather'], + }; + const candidate: TCustomConfig = { + version: '1.0', + memory: { disabled: true }, + includedTools: ['OpenWeather'], + filteredTools: ['Calculator'], + }; + expect(retainRestartOnlyConfig(previous, candidate)).toEqual(previous); + for (const section of ['memory', 'includedTools', 'filteredTools']) { + expect(createConfigReloadReport(previous, candidate)).toContainEqual({ + section, + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: [section === 'memory' ? 'memory.disabled' : section], + }); + } + expect(hashConfig(candidate)).toBe(hashConfig(previous)); + }); + + it('retains a missing versus empty config section until restart', async () => { + const previous: TCustomConfig = { version: '1.0' }; + const candidate: TCustomConfig = { version: '1.0', ocr: {} }; + expect(createConfigReloadReport(previous, candidate)).toContainEqual({ + section: 'ocr', + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: ['ocr'], + }); + expect(createConfigReloadReport(candidate, previous)).toContainEqual({ + section: 'ocr', + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: ['ocr'], + }); + + const buildBaseConfig = jest.fn(async (source: TCustomConfig) => appConfig(source)); + const reload = createConfigReloader({ + loadConfig: async () => candidate, + buildBaseConfig, + getBaseConfig: async () => appConfig(previous), + replaceBaseConfig: async (next) => next, + clearOverrideCache: async () => undefined, + generation: createConfigGenerationTracker(), + }); + await expect(reload()).resolves.toMatchObject({ scope: 'unchanged' }); + expect(buildBaseConfig).not.toHaveBeenCalled(); + }); + + it('flags an MCP server edit as restart-required', () => { + const previous: TCustomConfig = { + version: '1.2.1', + mcpServers: { docs: { type: 'streamable-http', url: 'https://old.example.com/mcp' } }, + }; + const next: TCustomConfig = { + version: '1.2.1', + mcpServers: { docs: { type: 'streamable-http', url: 'https://new.example.com/mcp' } }, + }; + + expect(createConfigReloadReport(previous, next)).toContainEqual({ + section: 'mcpServers', + status: 'restart_required', + restartRequired: true, + restartRequiredPaths: ['mcpServers.docs.url'], + }); + }); + + it('baselines a persisted generation before startup and detects later publications', async () => { + const store = new MemoryGenerationStore(); + const previous = hashConfig(customConfig('older-file')); + const next = hashConfig(customConfig('newer-file')); + await store.publish('config:model-catalog:v1', previous); + const tracker = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + + await tracker.bootstrap(); + expect(await tracker.check(next)).toBeUndefined(); + await store.publish('config:model-catalog:v1', next); + const change = await tracker.check(previous); + expect(change?.expectedDigest).toBe(next); + }); + + it('bounds startup baselining when Redis is offline and baselines after recovery', async () => { + jest.useFakeTimers(); + try { + const store = new MemoryGenerationStore(); + await store.publish('config:model-catalog:v1', 'stale'); + let resolveRead: ((value: string) => void) | undefined; + store.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const tracker = createConfigGenerationTracker(store, { + pollIntervalMs: 0, + bootstrapTimeoutMs: 250, + }); + const startup = tracker.bootstrap(); + await jest.advanceTimersByTimeAsync(250); + await expect(startup).resolves.toBeUndefined(); + resolveRead?.(JSON.stringify({ generation: 1, digest: 'stale' })); + await Promise.resolve(); + await Promise.resolve(); + await expect(tracker.check('current')).resolves.toMatchObject({ expectedDigest: 'stale' }); + await store.publish('config:model-catalog:v1', 'next'); + await expect(tracker.check('current')).resolves.toMatchObject({ expectedDigest: 'next' }); + } finally { + jest.useRealTimers(); + } + }); + + it('does not baseline a new publication after startup when the bootstrap read timed out', async () => { + jest.useFakeTimers(); + try { + const store = new MemoryGenerationStore(); + let resolveBootstrap: ((value: string) => void) | undefined; + store.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBootstrap = resolve; + }), + ); + const tracker = createConfigGenerationTracker(store, { + pollIntervalMs: 0, + bootstrapTimeoutMs: 100, + }); + const startup = tracker.bootstrap(); + await jest.advanceTimersByTimeAsync(100); + await startup; + await store.publish('config:model-catalog:v1', 'new-digest'); + resolveBootstrap?.(JSON.stringify({ generation: 0, digest: 'stale' })); + await Promise.resolve(); + await Promise.resolve(); + + const changed = await tracker.check('startup-digest'); + expect(changed?.expectedDigest).toBe('new-digest'); + expect((await tracker.check('startup-digest'))?.expectedDigest).toBe('new-digest'); + changed?.acknowledge(); + await expect(tracker.check('new-digest')).resolves.toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + it('accepts Redis reads slower than 250 ms without losing the next generation', async () => { + const store = new MemoryGenerationStore(); + const tracker = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + await tracker.bootstrap(); + await store.publish('config:model-catalog:v1', hashConfig(customConfig('new-model'))); + let resolveRead: ((value: string) => void) | undefined; + store.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const delayedCheck = tracker.check(hashConfig(customConfig('old-model'))); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(store.get).toHaveBeenCalledTimes(2); + resolveRead?.(await store.get()); + await expect(delayedCheck).resolves.toMatchObject({ + expectedDigest: hashConfig(customConfig('new-model')), + }); + }); + + it('does not regress its generation when an older read resolves after a bump', async () => { + const store = new MemoryGenerationStore(); + const tracker = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + await tracker.check(); + const expected = { raw: await store.get() }; + let resolveRead: ((generation: string) => void) | undefined; + store.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + + const staleCheck = tracker.check(); + await tracker.bump(hashConfig(customConfig('new-model')), expected); + resolveRead?.(JSON.stringify({ generation: 0, digest: hashConfig(customConfig('old-model')) })); + + await expect(staleCheck).resolves.toBeUndefined(); + await expect(tracker.check()).resolves.toBeUndefined(); + }); + + it('retries a generation until a successful reload acknowledges it', async () => { + const store = new MemoryGenerationStore(); + const tracker = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + await tracker.check(); + await store.publish('config:model-catalog:v1', hashConfig(customConfig('new-model'))); + + const failedAttempt = await tracker.check(); + const retry = await tracker.check(); + expect(failedAttempt).toBeDefined(); + expect(retry).toBeDefined(); + + retry?.acknowledge(); + await expect(tracker.check()).resolves.toBeUndefined(); + }); + + it('single-flights concurrent generation reads', async () => { + const store = new MemoryGenerationStore(); + const tracker = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + + await Promise.all([tracker.check(), tracker.check(), tracker.check()]); + + expect(store.get).toHaveBeenCalledTimes(1); + }); + + it('keeps an offline Redis read single-flight and accepts it after recovery', async () => { + let resolveRead: ((value: string) => void) | undefined; + const store = new MemoryGenerationStore(); + const tracker = createConfigGenerationTracker(store, { pollIntervalMs: 0 }); + await tracker.bootstrap(); + store.get.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const pending = tracker.check('old'); + const joined = tracker.check('old'); + expect(store.get).toHaveBeenCalledTimes(2); + resolveRead?.(JSON.stringify({ generation: 1, digest: 'new' })); + await expect(Promise.all([pending, joined])).resolves.toEqual([ + expect.objectContaining({ expectedDigest: 'new' }), + expect.objectContaining({ expectedDigest: 'new' }), + ]); + expect(store.get).toHaveBeenCalledTimes(2); + }); + + it('limits generation reads to one per poll interval', async () => { + const store = new MemoryGenerationStore(); + let now = 1_000; + const tracker = createConfigGenerationTracker(store, { + pollIntervalMs: 1_000, + now: () => now, + }); + + await tracker.check(); + await tracker.check(); + expect(store.get).toHaveBeenCalledTimes(1); + + now += 1_000; + await tracker.check(); + expect(store.get).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/api/src/app/reload.ts b/packages/api/src/app/reload.ts new file mode 100644 index 00000000000..7395c1aaec2 --- /dev/null +++ b/packages/api/src/app/reload.ts @@ -0,0 +1,554 @@ +import isEqual from 'lodash/isEqual'; +import { createHash } from 'node:crypto'; +import isPlainObject from 'lodash/isPlainObject'; +import { logger } from '@librechat/data-schemas'; +import { getMaxSubagents, setMaxSubagents } from 'librechat-data-provider'; +import type { + TCustomConfig, + TConfigReloadResult, + TConfigReloadSection, +} from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { ConfigReloadError } from './loader'; + +const CONFIG_GENERATION_KEY = 'config:model-catalog:v1'; +const PUBLISH_GENERATION_SCRIPT = ` +local previous = redis.call('GET', KEYS[1]) +if (previous == false and ARGV[1] ~= '0') or + (previous ~= false and (ARGV[1] ~= '1' or previous ~= ARGV[2])) then + return -1 +end +local number = 0 +if previous then + local ok, decoded = pcall(cjson.decode, previous) + if ok and type(decoded) == 'table' then + number = tonumber(decoded.generation) or 0 + else + number = tonumber(previous) or 0 + end +end +number = number + 1 +redis.call('SET', KEYS[1], cjson.encode({generation = number, digest = ARGV[3]})) +return number +`; + +/** Bootstrap coordination cannot read its own interval from the config it gates. One second bounds Redis reads per replica while keeping propagation responsive. */ +const DEFAULT_GENERATION_POLL_MS = 1_000; + +export type ConfigSectionStatus = TConfigReloadSection['status']; +export type ConfigSectionReport = TConfigReloadSection; +export type ConfigReloadResult = TConfigReloadResult; + +export interface ConfigGenerationStore { + get(key: string): Promise; + publish(key: string, digest: string, expectedRaw: string | null): Promise; +} + +export interface ConfigGenerationSnapshot { + readonly raw: string | null; + readonly digest?: string; + readonly generation?: number; +} + +export class ConfigGenerationConflictError extends Error { + constructor() { + super('A newer model catalog was published during reload. Verify the source and retry.'); + this.name = 'ConfigGenerationConflictError'; + } +} + +export interface ConfigGenerationChange { + readonly expectedDigest: string; + isCurrent(): boolean; + acknowledge(): void; + defer(retryMs: number): void; +} + +export interface ConfigGenerationTracker { + readonly distributed: boolean; + check(currentDigest?: string): Promise; + bootstrap(): Promise; + snapshot(): Promise; + applied(digest: string): number | undefined; + accept(snapshot: ConfigGenerationSnapshot): void; + superseded(snapshot: ConfigGenerationSnapshot): boolean; + bump(digest: string, expected: ConfigGenerationSnapshot): Promise; +} + +export interface ConfigGenerationTrackerOptions { + pollIntervalMs?: number; + /** Use the deployment's existing Redis connection deadline at startup. */ + bootstrapTimeoutMs?: number; + now?: () => number; +} + +export interface ConfigReloaderDeps { + loadConfig: (current: AppConfig) => Promise; + buildBaseConfig: (config: TCustomConfig) => Promise; + getBaseConfig: () => Promise; + replaceBaseConfig: (config: AppConfig) => Promise; + clearOverrideCache: () => Promise; + withConfigUpdate?: (work: () => Promise) => Promise; + generation: ConfigGenerationTracker; +} + +/** Only an existing, unambiguously named custom endpoint's default model list is live. */ +function matchingCustomEndpoints(previous: TCustomConfig, candidate: TCustomConfig): boolean { + const old = previous.endpoints?.custom ?? []; + const next = candidate.endpoints?.custom ?? []; + return ( + old.length === next.length && + new Set(old.map((endpoint) => endpoint.name)).size === old.length && + old.every( + (endpoint, index) => + endpoint.name === next[index]?.name && + Array.isArray(endpoint.models?.default) && + Array.isArray(next[index]?.models?.default), + ) + ); +} + +function isLivePath(path: string, previous: TCustomConfig, next: TCustomConfig): boolean { + return ( + matchingCustomEndpoints(previous, next) && + /^endpoints\.custom\.\d+\.models\.default$/.test(path) + ); +} + +function collectChangedPaths(previous: unknown, next: unknown, path: string): string[] { + if (isEqual(previous, next)) { + return []; + } + if ( + path === 'endpoints.custom' && + Array.isArray(previous) && + Array.isArray(next) && + previous.length === next.length && + new Set(previous.map((endpoint) => endpoint?.name)).size === previous.length && + previous.every((endpoint, index) => endpoint?.name === next[index]?.name) + ) { + return previous.flatMap((value, index) => + collectChangedPaths(value, next[index], `${path}.${index}`), + ); + } + const previousIsObject = isPlainObject(previous); + const nextIsObject = isPlainObject(next); + if ((!previousIsObject && previous != null) || (!nextIsObject && next != null)) { + return [path]; + } + if (!previousIsObject && !nextIsObject) { + return [path]; + } + + const previousObject = previousIsObject ? (previous as Record) : {}; + const nextObject = nextIsObject ? (next as Record) : {}; + const keys = new Set([...Object.keys(previousObject), ...Object.keys(nextObject)]); + if (keys.size === 0) { + return [path]; + } + return [...keys] + .sort() + .flatMap((key) => + collectChangedPaths(previousObject[key], nextObject[key], path ? `${path}.${key}` : key), + ); +} + +export function createConfigReloadReport( + previous: TCustomConfig, + next: TCustomConfig, +): ConfigSectionReport[] { + const sections = new Set([...Object.keys(previous), ...Object.keys(next)]); + return [...sections].sort().map((section) => { + const changedPaths = collectChangedPaths( + previous[section as keyof TCustomConfig], + next[section as keyof TCustomConfig], + section, + ); + if (changedPaths.length === 0) { + return { section, status: 'unchanged', restartRequired: false }; + } + + const restartRequiredPaths = changedPaths.filter((path) => !isLivePath(path, previous, next)); + const restartOnly = restartRequiredPaths.length === changedPaths.length; + return { + section, + status: restartOnly ? 'restart_required' : 'applied_live', + restartRequired: restartRequiredPaths.length > 0, + ...(restartRequiredPaths.length > 0 ? { restartRequiredPaths } : {}), + }; + }); +} + +function canonicalConfig(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalConfig); + } + if (!isPlainObject(value)) { + return value; + } + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => [key, canonicalConfig(record[key])]), + ); +} + +/** A digest of exactly the fields the live projection installs. Other YAML never affects it. */ +export function hashConfig(config: TCustomConfig): string { + const catalog = (config.endpoints?.custom ?? []).map((endpoint) => ({ + name: endpoint.name, + models: endpoint.models?.default, + })); + return createHash('sha256') + .update(JSON.stringify(canonicalConfig(catalog))) + .digest('hex'); +} + +/** Keep startup-owned state untouched, including tool catalogs and storage clients. */ +export function retainRestartOnlyConfig( + previous: TCustomConfig | undefined, + candidate: TCustomConfig, +): TCustomConfig { + if (!previous || !matchingCustomEndpoints(previous, candidate)) { + return previous ?? candidate; + } + const old = previous.endpoints?.custom ?? []; + const next = candidate.endpoints?.custom ?? []; + let changed = false; + const custom = old.map((endpoint, index) => { + const defaults = next[index]?.models?.default; + if (isEqual(endpoint.models?.default, defaults)) { + return endpoint; + } + changed = true; + return { ...endpoint, models: { ...endpoint.models, default: defaults } }; + }); + return changed ? { ...previous, endpoints: { ...previous.endpoints, custom } } : previous; +} + +/** One atomic, single-key operation works on Redis and Redis Cluster. */ +export function createRedisConfigGenerationStore(client: { + get(key: string): Promise; + eval(script: string, keys: number, key: string, ...args: string[]): Promise; +}): ConfigGenerationStore { + return { + get: (key) => client.get(key), + publish: async (key, digest, expectedRaw) => { + const result = Number( + await client.eval( + PUBLISH_GENERATION_SCRIPT, + 1, + key, + expectedRaw == null ? '0' : '1', + expectedRaw ?? '', + digest, + ), + ); + return result === -1 ? null : result; + }, + }; +} + +export function createConfigGenerationTracker( + store?: ConfigGenerationStore | null, + options: ConfigGenerationTrackerOptions = {}, +): ConfigGenerationTracker { + const pollIntervalMs = Math.max(0, options.pollIntervalMs ?? DEFAULT_GENERATION_POLL_MS); + const now = options.now ?? Date.now; + let seenGeneration: string | undefined; + let seenDigest: string | undefined; + let bootstrapComplete = false; + let nextPollAt = 0; + let checkFlight: Promise | undefined; + let readFlight: Promise | undefined; + let readSequence = 0; + + function readPersisted(): Promise { + if (!store) return Promise.resolve(null); + if (readFlight) return readFlight; + const flight = store.get(CONFIG_GENERATION_KEY); + readFlight = flight; + void flight + .finally(() => { + if (readFlight === flight) readFlight = undefined; + }) + .catch(() => undefined); + return flight; + } + + function parseSnapshot(raw: string | null): ConfigGenerationSnapshot { + if (raw == null) return { raw }; + try { + const value = JSON.parse(raw) as { generation: number; digest: string }; + if (Number.isSafeInteger(value?.generation) && typeof value?.digest === 'string') { + return { raw, digest: value.digest, generation: value.generation }; + } + } catch { + // Unknown persisted formats remain conditionally replaceable but never acknowledged. + } + return { raw }; + } + + async function readGeneration( + currentDigest: string | undefined, + sequence: number, + read: Promise, + ): Promise { + const generationBeforeRead = seenGeneration; + const raw = await read; + if (sequence !== readSequence || seenGeneration !== generationBeforeRead || raw == null) { + return undefined; + } + const payload = parseSnapshot(raw); + if (payload.generation == null || payload.digest == null) return undefined; + const generation = String(payload.generation); + if (payload.digest === currentDigest) { + seenGeneration = generation; + seenDigest = payload.digest; + return undefined; + } + if (seenGeneration == null && (!bootstrapComplete || payload.digest === currentDigest)) { + seenGeneration = generation; + seenDigest = payload.digest; + return undefined; + } + if (generation === seenGeneration) { + return undefined; + } + const previousGeneration = seenGeneration; + return { + expectedDigest: payload.digest, + isCurrent: () => seenGeneration === previousGeneration && readSequence === sequence, + acknowledge() { + if (seenGeneration === previousGeneration) { + seenGeneration = generation; + seenDigest = payload.digest; + } + }, + defer(retryMs) { + nextPollAt = Math.max(nextPollAt, now() + retryMs); + }, + }; + } + + async function check(currentDigest?: string): Promise { + if (!store) { + return undefined; + } + if (checkFlight) { + return checkFlight; + } + if (now() < nextPollAt) { + return undefined; + } + nextPollAt = now() + pollIntervalMs; + const sequence = ++readSequence; + const flight = readGeneration(currentDigest, sequence, readPersisted()); + checkFlight = flight; + try { + return await flight; + } finally { + if (checkFlight === flight) { + checkFlight = undefined; + } + } + } + + async function bootstrap(): Promise { + if (!store) { + return; + } + // Capture the persisted generation before startup reads its local source. + // A publication during that load remains visible to the next check. If + // Redis is offline, startup proceeds and the first recovered read becomes + // the baseline rather than treating an old persisted digest as new work. + let timeout: ReturnType | undefined; + try { + await Promise.race([ + check().then(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(() => { + readSequence += 1; + resolve(); + }, options.bootstrapTimeoutMs ?? 1_000); + timeout.unref?.(); + }), + ]); + } finally { + bootstrapComplete = true; + if (timeout) { + clearTimeout(timeout); + } + } + } + + async function snapshot(): Promise { + if (!store) return undefined; + let timeout: ReturnType | undefined; + try { + const raw = await Promise.race([ + readPersisted(), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(undefined), options.bootstrapTimeoutMs ?? 1_000); + timeout.unref?.(); + }), + ]); + return raw === undefined ? undefined : parseSnapshot(raw); + } catch (error) { + logger.warn('[configReload] Could not read model catalog generation:', error); + return undefined; + } finally { + if (timeout) clearTimeout(timeout); + } + } + + function accept(value: ConfigGenerationSnapshot): void { + if (value.generation == null || value.digest == null) return; + ++readSequence; + seenGeneration = String(value.generation); + seenDigest = value.digest; + nextPollAt = now() + pollIntervalMs; + } + + async function bump( + digest: string, + expected: ConfigGenerationSnapshot, + ): Promise { + if (!store) return undefined; + const generation = await store.publish(CONFIG_GENERATION_KEY, digest, expected.raw); + if (generation == null) return null; + accept({ raw: null, generation, digest }); + return generation; + } + + const applied = (digest: string): number | undefined => + seenDigest === digest && seenGeneration != null ? Number(seenGeneration) : undefined; + + const superseded = (value: ConfigGenerationSnapshot): boolean => + value.generation != null && + (seenGeneration == null || value.generation > Number(seenGeneration)); + + return { + distributed: store != null, + check, + bootstrap, + snapshot, + applied, + accept, + superseded, + bump, + }; +} + +export function createConfigReloader(deps: ConfigReloaderDeps): () => Promise { + let reloadFlight: Promise | undefined; + + async function reload(): Promise { + const current = await deps.getBaseConfig(); + if (deps.generation.distributed && current.config?.configReload?.clusterReady !== true) { + throw new ConfigReloadError('Enable configReload.clusterReady after upgrading all replicas.'); + } + // Observe Redis before loading a potentially slower source on another replica. + const snapshotFlight = deps.generation.snapshot(); + const previousMaxSubagents = getMaxSubagents(); + let installed = false; + try { + const candidate = await deps.loadConfig(current); + if (!candidate) throw new ConfigReloadError('The custom configuration could not be loaded.'); + const report = createConfigReloadReport(current.config ?? {}, candidate); + const effective = retainRestartOnlyConfig(current.config, candidate); + const digest = hashConfig(effective); + const changed = digest !== hashConfig(current.config ?? {}); + const snapshot = await snapshotFlight; + if (snapshot && snapshot.digest !== digest && deps.generation.superseded(snapshot)) { + throw new ConfigGenerationConflictError(); + } + if (!changed && (!deps.generation.distributed || snapshot?.digest === digest)) { + return { scope: 'unchanged', distributed: deps.generation.distributed, sections: report }; + } + + if (changed) { + const next = await deps.buildBaseConfig(effective); + if (hashConfig(next.config ?? {}) !== digest) { + throw new ConfigReloadError('Model catalog changed during validation.'); + } + await deps.replaceBaseConfig(next); + installed = true; + await deps.clearOverrideCache(); + } + if (!deps.generation.distributed) { + return { scope: 'local', distributed: false, sections: report }; + } + if (!snapshot) { + return { + scope: 'local', + distributed: false, + propagationError: 'Redis generation read failed', + sections: report, + }; + } + if (snapshot.digest === digest) { + deps.generation.accept(snapshot); + return { + scope: 'cluster', + distributed: true, + generation: snapshot.generation, + sections: report, + }; + } + + try { + const generation = await deps.generation.bump(digest, snapshot); + if (generation != null) { + return { scope: 'cluster', distributed: true, generation, sections: report }; + } + const winner = await deps.generation.snapshot(); + if (winner?.digest !== digest) throw new ConfigGenerationConflictError(); + deps.generation.accept(winner); + return { + scope: 'cluster', + distributed: true, + generation: winner.generation, + sections: report, + }; + } catch (error) { + if (error instanceof ConfigGenerationConflictError) throw error; + logger.error('[configReload] Failed to publish model catalog generation:', error); + return { + scope: 'local', + distributed: false, + propagationError: 'Redis generation update failed', + sections: report, + }; + } + } catch (error) { + if (installed) { + await deps + .replaceBaseConfig(current) + .then(() => deps.clearOverrideCache()) + .catch((rollbackError) => + logger.error('[configReload] Could not restore the previous base:', rollbackError), + ); + } + setMaxSubagents(previousMaxSubagents); + throw error; + } + } + + return async function reloadConfig(): Promise { + if (reloadFlight) { + return reloadFlight; + } + const flight = deps.withConfigUpdate ? deps.withConfigUpdate(reload) : reload(); + reloadFlight = flight; + try { + return await flight; + } finally { + if (reloadFlight === flight) { + reloadFlight = undefined; + } + } + }; +} diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index ee813ed8d21..9345111cbb2 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -1,10 +1,35 @@ import { getMaxSubagents, setMaxSubagents } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; +import type { ConfigGenerationChange } from './reload'; import { createAppConfigService, _resetOverrideStrictCache, getAppConfigOptionsFromUser, } from './service'; +import { createConfigReloader, createConfigGenerationTracker, hashConfig } from './reload'; + +function modelConfig(model: string): TCustomConfig { + return { + version: '1', + configReload: { clusterReady: true }, + endpoints: { + custom: [ + { + name: 'gateway', + apiKey: 'user_provided', + baseURL: 'https://example.com', + models: { default: [model], fetch: false }, + }, + ], + }, + }; +} + +function modelAppConfig(model: string): AppConfig { + const config = modelConfig(model); + return { config, endpoints: config.endpoints } as AppConfig; +} /** Extends AppConfig with mock fields used by merge behavior tests. */ interface TestConfig extends AppConfig { @@ -66,10 +91,79 @@ describe('createAppConfigService', () => { const config = await getAppConfig(); expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1); - expect(deps.loadBaseConfig).toHaveBeenCalledWith('startup'); + expect(deps.loadBaseConfig).toHaveBeenCalledWith('startup', undefined); expect(config).toEqual(deps._baseConfig); }); + it('reads the persisted generation before the startup source', async () => { + const beforeInitialLoad = jest.fn().mockResolvedValue(undefined); + const deps = createDeps({ bootstrapConfigGeneration: beforeInitialLoad }); + const { getAppConfig } = createAppConfigService(deps); + await getAppConfig({ baseOnly: true }); + + expect(beforeInitialLoad).toHaveBeenCalledTimes(1); + expect(beforeInitialLoad.mock.invocationCallOrder[0]).toBeLessThan( + deps.loadBaseConfig.mock.invocationCallOrder[0], + ); + await getAppConfig({ baseOnly: true }); + expect(beforeInitialLoad).toHaveBeenCalledTimes(1); + }); + + it('keeps startup config available when the optional Redis baseline fails', async () => { + const deps = createDeps({ + bootstrapConfigGeneration: jest.fn().mockRejectedValue(new Error('Redis reconnecting')), + }); + const { getAppConfig } = createAppConfigService(deps); + + await expect(getAppConfig({ baseOnly: true })).resolves.toEqual(deps._baseConfig); + expect(deps.loadBaseConfig).toHaveBeenCalledWith('startup', undefined); + }); + + it('does not publish startup-only tool definitions from live reloads', async () => { + const tools = { calculator: { type: 'function' } }; + const deps = createDeps({ + loadBaseConfig: jest + .fn() + .mockResolvedValue({ availableTools: tools, config: { version: '1' } }), + }); + const { getAppConfig, clearAppConfigCache } = createAppConfigService(deps); + await getAppConfig({ baseOnly: true }); + expect(deps.setCachedTools).toHaveBeenCalledTimes(1); + await clearAppConfigCache(); + await getAppConfig({ baseOnly: true }); + expect(deps.setCachedTools).toHaveBeenCalledTimes(1); + }); + + it('publishes a validated subagent cap only after the new base is committed', async () => { + const oldCap = getMaxSubagents(); + try { + const deps = createDeps({ + loadBaseConfig: jest + .fn() + .mockResolvedValueOnce({ config: { endpoints: { agents: { maxSubagents: 7 } } } }) + .mockResolvedValueOnce({ config: { endpoints: { agents: { maxSubagents: 20 } } } }), + }); + const service = createAppConfigService(deps); + await service.getAppConfig({ baseOnly: true }); + expect(getMaxSubagents()).toBe(7); + let commit: (() => void) | undefined; + deps._cache.set.mockImplementationOnce( + () => + new Promise((resolve) => { + commit = () => resolve(undefined); + }), + ); + const pending = service.getAppConfig({ baseOnly: true, refresh: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(getMaxSubagents()).toBe(7); + commit?.(); + await pending; + expect(getMaxSubagents()).toBe(20); + } finally { + setMaxSubagents(oldCap); + } + }); + it('caches base config — does not reload on second call', async () => { const deps = createDeps(); const { getAppConfig } = createAppConfigService(deps); @@ -105,7 +199,7 @@ describe('createAppConfigService', () => { await getAppConfig({ refresh: true }); expect(deps.loadBaseConfig).toHaveBeenCalledTimes(2); - expect(deps.loadBaseConfig).toHaveBeenLastCalledWith('reload'); + expect(deps.loadBaseConfig).toHaveBeenLastCalledWith('reload', expect.any(Object)); }); it.each(['invalid YAML', 'missing local file', 'remote fetch failure'])( @@ -121,43 +215,7 @@ describe('createAppConfigService', () => { expect(reloaded).toBe(initial); expect(deps._cache._store.get('app_config:_BASE_')).toBe(initial); - expect(deps.loadBaseConfig).toHaveBeenLastCalledWith('reload'); - }, - ); - - it.each(['tools', 'cache'])( - 'restores the subagent cap if %s publication fails', - async (stage) => { - const deps = createDeps({ - loadBaseConfig: jest.fn().mockResolvedValue({ - config: { endpoints: { agents: { maxSubagents: 3 } } }, - availableTools: { previous: {} }, - }), - }); - const { getAppConfig, clearAppConfigCache } = createAppConfigService(deps); - try { - await getAppConfig({ baseOnly: true }); - setMaxSubagents(3); - await clearAppConfigCache(); - deps.loadBaseConfig.mockImplementationOnce(async () => { - setMaxSubagents(20); - return { - config: { endpoints: { agents: { maxSubagents: 20 } } }, - availableTools: { new: {} }, - }; - }); - if (stage === 'tools') { - deps.setCachedTools.mockRejectedValueOnce(new Error('tools unavailable')); - } else { - deps._cache.set.mockRejectedValueOnce(new Error('cache unavailable')); - } - - const kept = await getAppConfig({ baseOnly: true }); - expect(kept.config?.endpoints?.agents?.maxSubagents).toBe(3); - expect(getMaxSubagents()).toBe(3); - } finally { - setMaxSubagents(undefined); - } + expect(deps.loadBaseConfig).toHaveBeenLastCalledWith('reload', expect.any(Object)); }, ); @@ -175,7 +233,7 @@ describe('createAppConfigService', () => { }), ); const reloads = Array.from({ length: 10 }, () => getAppConfig({ baseOnly: true })); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); expect(deps.loadBaseConfig).toHaveBeenCalledTimes(2); const next = { ...initial, interfaceConfig: { modelSelect: false } }; @@ -190,7 +248,279 @@ describe('createAppConfigService', () => { const { getAppConfig } = createAppConfigService(deps); await expect(getAppConfig({ baseOnly: true })).rejects.toBe(failure); - expect(deps.loadBaseConfig).toHaveBeenCalledWith('startup'); + expect(deps.loadBaseConfig).toHaveBeenCalledWith('startup', undefined); + }); + + it('drops base and override entries when another replica publishes a generation', async () => { + const syncConfigGeneration = jest.fn().mockResolvedValue(undefined); + const deps = createDeps({ + syncConfigGeneration, + getApplicableConfigs: jest + .fn() + .mockResolvedValue([{ priority: 10, overrides: { x: 'old' }, isActive: true }]), + }); + const { getAppConfig } = createAppConfigService(deps); + await getAppConfig({ role: 'USER' }); + const next = { ...deps._baseConfig, config: { version: '2.0' }, endpoints: ['new-endpoint'] }; + deps.loadBaseConfig.mockResolvedValueOnce(next); + const acknowledge = jest.fn(); + syncConfigGeneration.mockResolvedValueOnce({ + expectedDigest: hashConfig(next.config), + isCurrent: () => true, + acknowledge, + defer: jest.fn(), + }); + + await getAppConfig({ role: 'USER' }); + await new Promise((resolve) => setImmediate(resolve)); + const config = await getAppConfig({ role: 'USER' }); + + expect(config.endpoints).toEqual(['new-endpoint']); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(deps.loadBaseConfig).toHaveBeenLastCalledWith('reload', expect.any(Object)); + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(2); + }); + + it('does not block an admin reload behind an offline generation read', async () => { + const original = modelAppConfig('old-model'); + const syncConfigGeneration = jest.fn().mockResolvedValue(undefined); + let releaseRead: ((change: undefined) => void) | undefined; + const deps = createDeps({ + loadBaseConfig: jest.fn().mockResolvedValue(original), + syncConfigGeneration, + }); + const service = createAppConfigService(deps); + await service.getAppConfig({ baseOnly: true }); + syncConfigGeneration.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve; + }), + ); + await service.getAppConfig({ baseOnly: true }); + + const reload = createConfigReloader({ + loadConfig: async () => modelConfig('new-model'), + buildBaseConfig: async (source) => ({ ...original, config: source }), + getBaseConfig: () => service.getAppConfig({ baseOnly: true }), + replaceBaseConfig: service.replaceBaseConfig, + clearOverrideCache: () => service.clearOverrideCache(), + withConfigUpdate: service.withConfigUpdate, + generation: { + ...createConfigGenerationTracker(), + distributed: true, + snapshot: jest.fn().mockResolvedValue({ raw: null }), + superseded: jest.fn().mockReturnValue(false), + bump: jest.fn().mockRejectedValue(new Error('Redis unavailable')), + }, + }); + try { + await expect( + Promise.race([ + reload(), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 100)), + ]), + ).resolves.toMatchObject({ + scope: 'local', + propagationError: 'Redis generation update failed', + }); + expect( + (await service.getAppConfig({ baseOnly: true })).config.endpoints?.custom?.[0].models + ?.default, + ).toEqual(['new-model']); + } finally { + releaseRead?.(undefined); + } + }); + + it('ignores an older generation after an admin publishes a newer local base', async () => { + const original = modelAppConfig('old-model'); + const syncConfigGeneration = jest.fn().mockResolvedValue(undefined); + const deps = createDeps({ + loadBaseConfig: jest.fn().mockResolvedValue(original), + syncConfigGeneration, + }); + const service = createAppConfigService(deps); + await service.getAppConfig({ baseOnly: true }); + let releaseRead: ((change: ConfigGenerationChange) => void) | undefined; + let current = true; + syncConfigGeneration.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve; + }), + ); + await service.getAppConfig({ baseOnly: true }); + const acknowledge = jest.fn(); + const bump = jest.fn().mockImplementation(async () => { + current = false; + return 2; + }); + const reload = createConfigReloader({ + loadConfig: async () => modelConfig('new-model'), + buildBaseConfig: async (source) => ({ ...original, config: source }), + getBaseConfig: () => service.getAppConfig({ baseOnly: true }), + replaceBaseConfig: service.replaceBaseConfig, + clearOverrideCache: () => service.clearOverrideCache(), + withConfigUpdate: service.withConfigUpdate, + generation: { + ...createConfigGenerationTracker(), + distributed: true, + snapshot: jest.fn().mockResolvedValue({ raw: null }), + superseded: jest.fn().mockReturnValue(false), + bump, + }, + }); + await reload(); + releaseRead?.({ + expectedDigest: hashConfig(modelConfig('remote-model')), + isCurrent: () => current, + acknowledge, + defer: jest.fn(), + }); + await new Promise((resolve) => setImmediate(resolve)); + expect( + (await service.getAppConfig({ baseOnly: true })).config.endpoints?.custom?.[0].models + ?.default, + ).toEqual(['new-model']); + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1); + expect(acknowledge).not.toHaveBeenCalled(); + }); + + it('serializes a background generation with a concurrent admin reload', async () => { + const original = modelAppConfig('old-model'); + const remote = modelAppConfig('remote-model'); + let resolveRemote: ((config: AppConfig) => void) | undefined; + const loadBaseConfig = jest + .fn() + .mockResolvedValueOnce(original) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRemote = resolve; + }), + ); + const syncConfigGeneration = jest.fn().mockResolvedValue(undefined); + const deps = createDeps({ loadBaseConfig, syncConfigGeneration }); + const service = createAppConfigService(deps); + await service.getAppConfig({ baseOnly: true }); + syncConfigGeneration.mockResolvedValueOnce({ + expectedDigest: hashConfig(remote.config), + isCurrent: () => true, + acknowledge: jest.fn(), + defer: jest.fn(), + }); + await service.getAppConfig({ baseOnly: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(resolveRemote).toBeDefined(); + + const loadConfig = jest.fn().mockResolvedValue(modelConfig('new-model')); + const bump = jest.fn().mockResolvedValue(2); + const adminReload = createConfigReloader({ + loadConfig, + buildBaseConfig: async (source) => ({ ...original, config: source }), + getBaseConfig: () => service.getAppConfig({ baseOnly: true }), + replaceBaseConfig: service.replaceBaseConfig, + clearOverrideCache: () => service.clearOverrideCache(), + withConfigUpdate: service.withConfigUpdate, + generation: { + ...createConfigGenerationTracker(), + distributed: true, + snapshot: jest.fn().mockResolvedValue({ raw: null }), + superseded: jest.fn().mockReturnValue(false), + bump, + }, + }); + const pendingAdmin = adminReload(); + await new Promise((resolve) => setImmediate(resolve)); + expect(loadConfig).not.toHaveBeenCalled(); + resolveRemote?.(remote); + await expect(pendingAdmin).resolves.toMatchObject({ scope: 'cluster', generation: 2 }); + expect( + (await service.getAppConfig({ baseOnly: true })).config.endpoints?.custom?.[0].models + ?.default, + ).toEqual(['new-model']); + expect(bump).toHaveBeenCalledTimes(1); + }); + + it('does not acknowledge a generation until its source reload succeeds', async () => { + const syncConfigGeneration = jest.fn().mockResolvedValue(undefined); + const deps = createDeps({ syncConfigGeneration }); + const { getAppConfig } = createAppConfigService(deps); + const initial = await getAppConfig({ baseOnly: true }); + const acknowledge = jest.fn(); + const next = { ...initial, config: { version: '2.0' } }; + const change = { + expectedDigest: hashConfig(next.config), + isCurrent: () => true, + acknowledge, + }; + let published = true; + syncConfigGeneration.mockImplementation(async () => (published ? change : undefined)); + deps.loadBaseConfig.mockRejectedValueOnce(new Error('remote unavailable')); + await getAppConfig({ baseOnly: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(acknowledge).not.toHaveBeenCalled(); + expect((await getAppConfig({ baseOnly: true })).config).toBe(initial.config); + + deps.loadBaseConfig.mockResolvedValue(next); + let recovered = await getAppConfig({ baseOnly: true }); + for (let attempt = 0; attempt < 10 && recovered.config?.version !== '2.0'; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + recovered = await getAppConfig({ baseOnly: true }); + } + expect(recovered.config).toEqual(next.config); + expect(acknowledge).toHaveBeenCalled(); + published = false; + }); + + it('serves a cached base without waiting for an unavailable Redis generation check', async () => { + const check = jest.fn(() => new Promise(() => undefined)); + const deps = createDeps({ syncConfigGeneration: check }); + const { getAppConfig } = createAppConfigService(deps); + await getAppConfig({ baseOnly: true }); + + await expect(getAppConfig({ baseOnly: true })).resolves.toMatchObject(deps._baseConfig); + expect(check).toHaveBeenCalledTimes(1); + }); + + it('does not cache a merged override produced from an older base revision', async () => { + let resolveQuery: ((configs: []) => void) | undefined; + const getApplicableConfigs = jest + .fn() + .mockImplementationOnce( + () => + new Promise<[]>((resolve) => { + resolveQuery = resolve; + }), + ) + .mockResolvedValue([]); + const deps = createDeps({ getApplicableConfigs }); + const { getAppConfig, replaceBaseConfig } = createAppConfigService(deps); + const initial = await getAppConfig({ baseOnly: true }); + const staleRead = getAppConfig({ role: 'USER' }); + await new Promise((resolve) => setImmediate(resolve)); + const next = { ...initial, interfaceConfig: { modelSelect: false } }; + await replaceBaseConfig(next); + resolveQuery?.([]); + + const result = await staleRead; + expect(result.interfaceConfig?.modelSelect).toBe(false); + expect(getApplicableConfigs).toHaveBeenCalledTimes(2); + expect(deps._cache._store.get('app_config:_OVERRIDE_:__default__:USER')).toBeUndefined(); + }); + + it('installs a validated base config without re-reading its source', async () => { + const deps = createDeps(); + const { getAppConfig, replaceBaseConfig } = createAppConfigService(deps); + const initial = await getAppConfig({ baseOnly: true }); + const next = { ...initial, interfaceConfig: { modelSelect: false } }; + + await replaceBaseConfig(next); + const config = await getAppConfig({ baseOnly: true }); + + expect(config).toBe(next); + expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1); }); it('queries DB for applicable configs', async () => { diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index ebc4da01f95..ab8b9bf38dd 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -1,17 +1,20 @@ -import { - PrincipalType, - materializeModelSpecEndpoints, - setMaxSubagents, -} from 'librechat-data-provider'; import { logger, getTenantId, mergeConfigOverrides, BASE_CONFIG_PRINCIPAL_ID, } from '@librechat/data-schemas'; +import { + PrincipalType, + DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS, + materializeModelSpecEndpoints, + setMaxSubagents, +} from 'librechat-data-provider'; import type { AppConfig, IConfig } from '@librechat/data-schemas'; import type { Types } from 'mongoose'; +import type { ConfigGenerationChange } from './reload'; import type { CustomConfigLoadMode } from './loader'; +import { hashConfig } from './reload'; const BASE_CONFIG_KEY = '_BASE_'; @@ -52,7 +55,10 @@ interface CacheStore { export interface AppConfigServiceDeps { /** Load the base AppConfig from YAML + AppService processing. */ - loadBaseConfig: (mode?: CustomConfigLoadMode) => Promise; + loadBaseConfig: ( + mode?: CustomConfigLoadMode, + previous?: AppConfig, + ) => Promise; /** Cache tools after base config is loaded. */ setCachedTools: (tools: Record) => Promise; /** Get a cache store by key. */ @@ -76,6 +82,11 @@ export interface AppConfigServiceDeps { }) => Promise; /** TTL in ms for per-user/role merged config caches. Defaults to 60 000. */ overrideCacheTtl?: number; + /** Returns an acknowledgement for a newer base-config generation. */ + syncConfigGeneration?: (digest: string) => Promise; + /** Read the persisted generation before the startup source load. */ + bootstrapConfigGeneration?: () => Promise; + getAppliedGeneration?: (digest: string) => number | undefined; } export interface GetAppConfigOptions { @@ -153,6 +164,14 @@ function overrideCacheKey(role?: string, userId?: string, tenantId?: string): st export function createAppConfigService(deps: AppConfigServiceDeps): { getAppConfig: (options?: GetAppConfigOptions) => Promise; + getConfigRefreshStatus: () => Promise<{ + distributed: boolean; + generation: number | null; + pollIntervalMs: number; + }>; + getConfigGenerationForConfig: (config?: AppConfig) => string; + replaceBaseConfig: (config: AppConfig) => Promise; + withConfigUpdate: (work: () => Promise) => Promise; clearAppConfigCache: () => Promise; clearOverrideCache: (tenantId?: string) => Promise; } { @@ -165,11 +184,27 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { getUserPrincipals, augmentConfig, overrideCacheTtl = DEFAULT_OVERRIDE_CACHE_TTL, + syncConfigGeneration, + bootstrapConfigGeneration, + getAppliedGeneration, } = deps; const cache = getCache(cacheKeys.APP_CONFIG); let lastGoodBaseConfig: AppConfig | undefined; let baseConfigFlight: Promise | undefined; + let baseConfigRevision = 0; + let generationFlight: Promise | undefined; + let lastGoodBaseDigest: string | undefined; + let configUpdateTail: Promise = Promise.resolve(); + + function withConfigUpdate(work: () => Promise): Promise { + const next = configUpdateTail.then(work, work); + configUpdateTail = next.then( + () => undefined, + () => undefined, + ); + return next; + } async function buildPrincipals( role?: string, @@ -204,11 +239,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { '[ensureBaseConfig] Failed to reload base configuration; keeping the last good configuration.', error, ); - const restorations = [cache.set(BASE_CONFIG_KEY, lastGood)]; - if (lastGood.availableTools) { - restorations.push(setCachedTools(lastGood.availableTools)); - } - const results = await Promise.allSettled(restorations); + const results = await Promise.allSettled([cache.set(BASE_CONFIG_KEY, lastGood)]); for (const result of results) { if (result.status === 'rejected') { logger.error('[ensureBaseConfig] Failed to restore last-good config state:', result.reason); @@ -217,21 +248,46 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { return lastGood; } - async function loadAndCacheBaseConfig(mode: CustomConfigLoadMode): Promise { + async function cacheBaseConfig(loaded: AppConfig, startup = false): Promise { + const baseConfig = materializeConfigModelSpecs(loaded); + const digest = syncConfigGeneration ? hashConfig(baseConfig.config ?? {}) : undefined; + // Deployment tool filters remain at their startup value until restart. The + // global TOOL_CACHE may be shared, so a live reload must never publish tools + // ahead of source-version verification on the other replicas. + if (startup && baseConfig.availableTools) { + await setCachedTools(baseConfig.availableTools); + } + await cache.set(BASE_CONFIG_KEY, baseConfig); + lastGoodBaseConfig = baseConfig; + lastGoodBaseDigest = digest; + baseConfigRevision += 1; + setMaxSubagents(baseConfig.config?.endpoints?.agents?.maxSubagents); + return baseConfig; + } + + async function replaceBaseConfig(config: AppConfig): Promise { + try { + return await cacheBaseConfig(config); + } catch (error) { + await restoreLastGoodBaseConfig(error); + throw error; + } + } + + async function loadAndCacheBaseConfig( + mode: CustomConfigLoadMode, + expectedDigest?: string, + ): Promise { try { logger.info('[ensureBaseConfig] Loading base configuration...'); - const loaded = await loadBaseConfig(mode); + const loaded = await loadBaseConfig(mode, lastGoodBaseConfig); if (!loaded) { throw new Error('Failed to initialize app configuration through AppService.'); } - - const baseConfig = materializeConfigModelSpecs(loaded); - if (baseConfig.availableTools) { - await setCachedTools(baseConfig.availableTools); + if (expectedDigest && hashConfig(loaded.config ?? {}) !== expectedDigest) { + throw new Error('Config source has not reached the published generation yet.'); } - await cache.set(BASE_CONFIG_KEY, baseConfig); - lastGoodBaseConfig = baseConfig; - return baseConfig; + return await cacheBaseConfig(loaded, mode === 'startup'); } catch (error) { if (mode === 'startup') { throw error; @@ -240,25 +296,90 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { } } - /** - * Ensure the YAML-derived base config is loaded and cached. - * Returns the `_BASE_` config (YAML + AppService). No DB queries. - */ - async function ensureBaseConfig(refresh?: boolean): Promise { + async function applyRemoteGeneration(change: ConfigGenerationChange): Promise { + // A local reload may have published a newer generation while the Redis GET + // was in flight or waiting for this process-local install slot. + if (!change.isCurrent()) { + return; + } + const staleFlight = baseConfigFlight; + if (staleFlight) { + await staleFlight.catch(() => undefined); + } + const previousRevision = baseConfigRevision; + const loaded = await readBaseConfig(true, change.expectedDigest); + if ( + baseConfigRevision === previousRevision || + hashConfig(loaded.config ?? {}) !== change.expectedDigest + ) { + change.defer(lastGoodBaseConfig?.config?.configReload?.mismatchRetryMs ?? 5_000); + return; + } + await clearOverrideCache(); + change.acknowledge(); + } + + function scheduleGenerationCheck(): void { + const sync = syncConfigGeneration; + if (!sync || generationFlight || !lastGoodBaseConfig) { + return; + } + const flight = (async () => { + const current = lastGoodBaseConfig; + if (!current) { + return; + } + const change = await sync(lastGoodBaseDigest ?? hashConfig(current.config ?? {})); + if (change) { + await withConfigUpdate(() => applyRemoteGeneration(change)); + } + })(); + generationFlight = flight; + void flight + .catch((error) => { + logger.error('[ensureBaseConfig] Failed to apply config generation:', error); + }) + .finally(() => { + if (generationFlight === flight) { + generationFlight = undefined; + } + }); + } + + async function readBaseConfig(refresh?: boolean, expectedDigest?: string): Promise { const cached = (await cache.get(BASE_CONFIG_KEY)) as AppConfig | undefined; if (cached) { - lastGoodBaseConfig ??= cached; + if (!lastGoodBaseConfig) { + lastGoodBaseConfig = cached; + lastGoodBaseDigest = syncConfigGeneration ? hashConfig(cached.config ?? {}) : undefined; + } if (!refresh) { return cached; } } if (baseConfigFlight) { - return baseConfigFlight; + const inFlight = await baseConfigFlight; + if (expectedDigest && hashConfig(inFlight.config ?? {}) !== expectedDigest) { + return readBaseConfig(true, expectedDigest); + } + return inFlight; } const mode: CustomConfigLoadMode = lastGoodBaseConfig ? 'reload' : 'startup'; - const flight = loadAndCacheBaseConfig(mode); + const flight = (async () => { + if (mode === 'startup' && bootstrapConfigGeneration) { + try { + await bootstrapConfigGeneration(); + } catch (error) { + logger.warn( + '[ensureBaseConfig] Could not baseline the optional Redis generation:', + error, + ); + } + } + return loadAndCacheBaseConfig(mode, expectedDigest); + })(); baseConfigFlight = flight; try { return await flight; @@ -269,6 +390,32 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { } } + /** + * Ensure the YAML-derived base config is loaded and cached. + * Returns the `_BASE_` config (YAML + AppService). No DB queries. + */ + async function ensureBaseConfig(refresh?: boolean): Promise { + scheduleGenerationCheck(); + return readBaseConfig(refresh); + } + + function getConfigGenerationForConfig(config?: AppConfig): string { + if (!config || !getAppliedGeneration) return ''; + const generation = getAppliedGeneration(hashConfig(config.config ?? {})); + return generation == null ? '' : String(generation); + } + + async function getConfigRefreshStatus() { + const base = await ensureBaseConfig(); + const generation = getConfigGenerationForConfig(base); + return { + distributed: syncConfigGeneration != null, + generation: generation === '' ? null : Number(generation), + pollIntervalMs: + base.config?.configReload?.clientPollIntervalMs ?? DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS, + }; + } + /** * Get the app configuration, optionally merged with DB overrides for the given principal. * @@ -294,6 +441,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { } = options; const baseConfig = await ensureBaseConfig(refresh); + const baseRevision = baseConfigRevision; if (baseOnly) { return baseConfig; @@ -338,11 +486,12 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { } }; - const cacheKey = overrideCacheKey(role, userId, tenantId); + const key = overrideCacheKey(role, userId, tenantId); + const cacheKey = baseRevision > 1 ? `${key}:base:${baseRevision}` : key; if (!refresh) { const cachedMerged = (await cache.get(cacheKey)) as AppConfig | undefined; if (cachedMerged) { - return await augment(cachedMerged); + return baseConfigRevision === baseRevision ? augment(cachedMerged) : getAppConfig(options); } } @@ -358,7 +507,13 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { return baseConfig; } + if (baseRevision !== baseConfigRevision) { + return getAppConfig(options); + } await cache.set(cacheKey, merged, overrideCacheTtl); + if (baseRevision !== baseConfigRevision) { + return getAppConfig(options); + } return await augment(merged); } @@ -381,11 +536,8 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { const namespace = cacheKeys.APP_CONFIG; const overrideSegment = tenantId ? `_OVERRIDE_:${tenantId}:` : '_OVERRIDE_:'; - // In-memory store — enumerate keys directly. - // APP_CONFIG defaults to FORCED_IN_MEMORY_CACHE_NAMESPACES, so this is the - // standard path. Redis SCAN is intentionally avoided here — it can cause 60s+ - // stalls under concurrent load (see #12410). When APP_CONFIG is Redis-backed - // and store.keys() is unavailable, overrides expire naturally via TTL. + // APP_CONFIG is process-local even with Redis. Enumerate its keys directly; + // non-enumerable test stores fall back to the override TTL. const store = (cache as CacheStore).opts?.store; if (store && typeof store.keys === 'function') { // Keyv stores keys with a namespace prefix (e.g. "APP_CONFIG:_OVERRIDE_:..."). @@ -410,15 +562,17 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { logger.warn( '[clearOverrideCache] Cache store does not support key enumeration. ' + - 'Override caches will expire naturally via TTL (%dms). ' + - 'This is expected when APP_CONFIG is Redis-backed — Redis SCAN is avoided ' + - 'for performance reasons (see #12410).', + 'Override caches will expire naturally via TTL (%dms).', overrideCacheTtl, ); } return { getAppConfig, + getConfigRefreshStatus, + getConfigGenerationForConfig, + replaceBaseConfig, + withConfigUpdate, clearAppConfigCache, clearOverrideCache, }; diff --git a/packages/api/src/app/status.spec.ts b/packages/api/src/app/status.spec.ts new file mode 100644 index 00000000000..9d5b81882ec --- /dev/null +++ b/packages/api/src/app/status.spec.ts @@ -0,0 +1,30 @@ +import type { Request, Response } from 'express'; +import { createConfigRevisionHandler } from './status'; + +function response() { + const res = { set: jest.fn(), sendStatus: jest.fn(), json: jest.fn() }; + return res; +} + +describe('local model revision route', () => { + it('does not read or expose model revision to anonymous callers', async () => { + const read = jest.fn(); + const res = response(); + await createConfigRevisionHandler(read)({} as Request, res as unknown as Response, jest.fn()); + expect(res.sendStatus).toHaveBeenCalledWith(401); + expect(read).not.toHaveBeenCalled(); + }); + + it('responds only with the locally applied generation and no-store', async () => { + const status = { distributed: true, generation: 2, pollIntervalMs: 3000 }; + const read = jest.fn().mockResolvedValue(status); + const res = response(); + await createConfigRevisionHandler(read)( + { user: { id: 'u1' } } as unknown as Request, + res as unknown as Response, + jest.fn(), + ); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, no-store'); + expect(res.json).toHaveBeenCalledWith(status); + }); +}); diff --git a/packages/api/src/app/status.ts b/packages/api/src/app/status.ts new file mode 100644 index 00000000000..95c9e62076f --- /dev/null +++ b/packages/api/src/app/status.ts @@ -0,0 +1,25 @@ +import { logger } from '@librechat/data-schemas'; +import type { RequestHandler } from 'express'; + +/** Report the generation actually applied here, without disclosing the YAML or its hash. */ +export function createConfigRevisionHandler( + getStatus: () => Promise<{ + distributed: boolean; + generation: number | null; + pollIntervalMs: number; + }>, +): RequestHandler { + return async (req, res) => { + if (!req.user) { + res.sendStatus(401); + return; + } + try { + res.set('Cache-Control', 'private, no-store'); + res.json(await getStatus()); + } catch (error) { + logger.error('[configReload] Could not read local model catalog revision:', error); + res.sendStatus(500); + } + }; +} diff --git a/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts index 32a1492555d..7aa13792b23 100644 --- a/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts +++ b/packages/api/src/cache/__tests__/cacheFactory/standardCache.namespace_isolation.spec.ts @@ -94,7 +94,7 @@ describe('standardCache - CONFIG_STORE vs TOOL_CACHE namespace isolation', () => expect(await toolCache.get('STARTUP_CONFIG')).toBeUndefined(); }); - it('should use Redis for all namespaces when nothing is forced in-memory', async () => { + it('keeps APP_CONFIG process-local even when other namespaces are Redis-backed', async () => { jest.doMock('../../cacheConfig', () => ({ cacheConfig: { FORCED_IN_MEMORY_CACHE_NAMESPACES: [], @@ -111,7 +111,7 @@ describe('standardCache - CONFIG_STORE vs TOOL_CACHE namespace isolation', () => standardCache(CacheKeys.TOOL_CACHE); standardCache(CacheKeys.APP_CONFIG); - expect(MockKeyvRedis).toHaveBeenCalledTimes(3); + expect(MockKeyvRedis).toHaveBeenCalledTimes(2); }); it('forcing TOOL_CACHE to in-memory should not affect CONFIG_STORE', async () => { diff --git a/packages/api/src/cache/cacheConfig.ts b/packages/api/src/cache/cacheConfig.ts index 00cff485cdd..f02bd1c677e 100644 --- a/packages/api/src/cache/cacheConfig.ts +++ b/packages/api/src/cache/cacheConfig.ts @@ -28,7 +28,7 @@ const USE_REDIS_STREAMS = // Comma-separated list of cache namespaces that should be forced to use in-memory storage // even when Redis is enabled. This allows selective performance optimization for specific caches. // Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container. -// Set to empty string to force all namespaces through Redis. +// Set to empty string to use Redis for all other namespaces; APP_CONFIG stays local. const FORCED_IN_MEMORY_CACHE_NAMESPACES = process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES !== undefined ? process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES.split(',') diff --git a/packages/api/src/cache/cacheFactory.ts b/packages/api/src/cache/cacheFactory.ts index e41d665fbc4..bd2dc1c007e 100644 --- a/packages/api/src/cache/cacheFactory.ts +++ b/packages/api/src/cache/cacheFactory.ts @@ -87,7 +87,14 @@ async function clearRedisNamespace(namespace: string): Promise { * @returns Cache instance. */ export const standardCache = (namespace: string, ttl?: number, fallbackStore?: object): Keyv => { - if (keyvRedisClient && !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)) { + // Each replica accepts a YAML generation only after its own source matches + // the published digest. A shared APP_CONFIG key could bypass that check and + // leak the publisher's startup-only settings onto a lagging replica. + if ( + keyvRedisClient && + namespace !== CacheKeys.APP_CONFIG && + !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace) + ) { const byTtl = redisCacheMap.get(namespace); const existing = byTtl?.get(ttl); if (existing) { diff --git a/packages/api/src/middleware/capabilities.ts b/packages/api/src/middleware/capabilities.ts index 97a45f1aee6..b4186bc900b 100644 --- a/packages/api/src/middleware/capabilities.ts +++ b/packages/api/src/middleware/capabilities.ts @@ -58,6 +58,7 @@ const recentDenialWarnings = new Map(); export type HasCapabilityFn = ( user: CapabilityUser, capability: SystemCapability, + options?: { platformOnly?: boolean }, ) => Promise; export type RequireCapabilityFn = ( diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index bee6c01bad8..223f6d0b9e4 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -270,6 +270,7 @@ export const actionOAuthBind = (actionId: string) => export const config = (context?: StartupConfigContext) => `${BASE_URL}/api/config${buildQuery({ context })}`; +export const configRevision = () => `${BASE_URL}/api/config/revision`; export const prompts = () => `${BASE_URL}/api/prompts`; @@ -495,6 +496,9 @@ export const conversationTraceRecord = ( { message: messageId, ...(sourceId ? { source: sourceId } : {}) }, ).toString()}`; +export const adminConfigReload = () => `${BASE_URL}/api/admin/config/reload`; +export const adminConfigReloadAccess = () => `${adminConfigReload()}/access`; + export const adminSkillsSync = () => `${BASE_URL}/api/admin/skills/sync`; export const adminSkillsSyncStatus = () => `${adminSkillsSync()}/status`; export const adminSkillsSyncRun = () => `${adminSkillsSync()}/run`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index bc84677a2fd..f589ac658e0 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -3059,10 +3059,28 @@ export type TOpenIdDiscoveryConfig = z.infer; /** Maximum CAS attempts per ACL document, including the initial attempt. */ export const permissionWriteAttemptsSchema = z.number().int().min(1).max(100).default(3); +export const DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS = 3_000; + export const configSchema = z.object({ version: z.string(), permissions: z.object({ maxWriteAttempts: permissionWriteAttemptsSchema }).optional(), cache: z.boolean().default(true), + /** Last-good value bounds reload requests to a remote CONFIG_PATH; startup remains unchanged. */ + configReload: z + .object({ + remoteTimeoutMs: z.number().int().positive().max(120_000).default(10_000), + /** Opt in only after every replica runs the same model-catalog reload protocol. */ + clusterReady: z.boolean().default(false), + mismatchRetryMs: z.number().int().min(1_000).max(120_000).default(5_000), + clientPollIntervalMs: z + .number() + .int() + .min(1_000) + .max(120_000) + .default(DEFAULT_CONFIG_RELOAD_CLIENT_POLL_MS), + }) + .strict() + .optional(), ocr: ocrSchema.optional(), webSearch: webSearchSchema.optional(), langfuse: langfuseConfigSchema.optional(), diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index bfc7bea792b..40867ecd014 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -7,9 +7,11 @@ import type { TTraceRecordDetail, } from './types/traces'; import type { TInsightsAccessResponse, TInsightsParams, TInsightsResponse } from './types/insights'; +import type { TConfigReloadResult, TConfigRevision } from './types/configReload'; import type { TFileConfig } from './file-config'; import type * as tl from './types/tools'; import type * as t from './types'; +import { CONFIG_GENERATION_HEADER } from './types/configReload'; import * as permissions from './accessPermissions'; import * as endpoints from './api-endpoints'; import { uploadEventStream } from './upload'; @@ -70,6 +72,14 @@ export function getConversationTraceRecord( ); } +export function reloadCustomConfig(): Promise { + return request.post(endpoints.adminConfigReload()); +} + +export function getConfigReloadAccess(): Promise { + return request.get(endpoints.adminConfigReloadAccess()).then(() => true); +} + export function getLangfuseConnection(): Promise { return request.get(endpoints.adminLangfuseConnection()); } @@ -393,6 +403,26 @@ export const getStartupConfig = ( return request.get(endpoints.config(options?.context)); }; +export const getConfigRevision = (signal?: AbortSignal): Promise => + request.get(endpoints.configRevision(), signal ? { signal } : undefined); + +export const getModelsAtRevision = async ( + generation: number, + signal?: AbortSignal, +): Promise => { + const response = await request.getResponse( + endpoints.models(), + signal ? { signal } : undefined, + ); + if ( + Number(response.headers[CONFIG_GENERATION_HEADER.toLowerCase()]) !== generation || + generation < 1 + ) { + throw new Error('The serving replica has not applied the model catalog revision yet.'); + } + return response.data; +}; + export const getAIEndpoints = (): Promise => { return request.get(endpoints.aiEndpoints()); }; diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 92cb7a6cc86..9b505de117a 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -43,6 +43,7 @@ export * from './types/web'; export * from './types/graph'; export * from './types/insights'; export * from './types/traces'; +export * from './types/configReload'; export * from './types/subagents'; export * from './types/background'; export * from './types/queuedTurns'; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 5e1e7288074..433687d4f78 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -25,6 +25,8 @@ export enum QueryKeys { tokenCount = 'tokenCount', availablePlugins = 'availablePlugins', startupConfig = 'startupConfig', + configReloadAccess = 'configReloadAccess', + configRevision = 'configRevision', insights = 'insights', insightsAccess = 'insightsAccess', assistants = 'assistants', @@ -121,6 +123,7 @@ export enum MutationKeys { updateFavorites = 'updateFavorites', /** Pinned-section display order write, keyed for the same reason. */ updatePinnedOrder = 'updatePinnedOrder', + reloadCustomConfig = 'reloadCustomConfig', updateLangfuseConnection = 'updateLangfuseConnection', testLangfuseConnection = 'testLangfuseConnection', createAgentApiKey = 'createAgentApiKey', diff --git a/packages/data-provider/src/types/configReload.ts b/packages/data-provider/src/types/configReload.ts new file mode 100644 index 00000000000..ea19e6ac431 --- /dev/null +++ b/packages/data-provider/src/types/configReload.ts @@ -0,0 +1,29 @@ +export const CONFIG_GENERATION_HEADER = 'X-LibreChat-Config-Generation'; + +export interface TConfigRevision { + distributed: boolean; + generation: number | null; + pollIntervalMs: number; +} + +/** Results contain only section names and paths, never configuration values. */ +export interface TConfigReloadSection { + section: string; + status: 'applied_live' | 'restart_required' | 'unchanged'; + restartRequired: boolean; + restartRequiredPaths?: string[]; +} + +export interface TConfigReloadResult { + /** Cluster means published, not that every replica has applied it. */ + scope: 'cluster' | 'local' | 'unchanged'; + distributed: boolean; + generation?: number; + propagationError?: string; + sections: TConfigReloadSection[]; +} + +export interface TConfigReloadError { + error: string; + validationErrors?: Array<{ path: (string | number)[]; message: string }>; +} diff --git a/packages/data-provider/src/types/index.ts b/packages/data-provider/src/types/index.ts index 7320b3a3076..1f2b2a18063 100644 --- a/packages/data-provider/src/types/index.ts +++ b/packages/data-provider/src/types/index.ts @@ -1,4 +1,5 @@ export * from './queries'; +export * from './configReload'; export * from './mcpServers'; export * from './subagents'; export * from './background'; From a3b8edc4f220692a8bcb8a647b3787927e7eb7a5 Mon Sep 17 00:00:00 2001 From: Lia Date: Mon, 28 Sep 2026 21:31:07 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Use=20Dev=20Config=20?= =?UTF-8?q?Middleware=20for=20Model=20Route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/routes/models.js | 2 +- api/server/routes/models.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 api/server/routes/models.test.js diff --git a/api/server/routes/models.js b/api/server/routes/models.js index 0402ed5ccfe..9690eeb6872 100644 --- a/api/server/routes/models.js +++ b/api/server/routes/models.js @@ -5,6 +5,6 @@ const { requireJwtAuth } = require('~/server/middleware/'); const configMiddleware = require('~/server/middleware/config/app'); const router = express.Router(); -router.get('/', requireJwtAuth, configMiddleware.strictConfigMiddleware, modelController); +router.get('/', requireJwtAuth, configMiddleware, modelController); module.exports = router; diff --git a/api/server/routes/models.test.js b/api/server/routes/models.test.js new file mode 100644 index 00000000000..e9e78380c17 --- /dev/null +++ b/api/server/routes/models.test.js @@ -0,0 +1,32 @@ +const express = require('express'); +const request = require('supertest'); + +const mockModels = jest.fn((req, res) => res.json({ model: req.config.model })); + +jest.mock('~/server/middleware/', () => ({ + requireJwtAuth: (_req, _res, next) => next(), +})); + +jest.mock('~/server/middleware/config/app', () => (req, _res, next) => { + req.config = { model: 'current-model' }; + next(); +}); + +jest.mock('~/server/controllers/ModelController', () => ({ + modelController: (...args) => mockModels(...args), +})); + +const models = require('./models'); + +describe('model route with dev config middleware', () => { + it('registers a request-scoped config reader and serves models', async () => { + const app = express(); + app.use('/api/models', models); + + const response = await request(app).get('/api/models'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ model: 'current-model' }); + expect(mockModels).toHaveBeenCalledTimes(1); + }); +}); From ed83750f5d6f46e08acdd8184e57d0d0da14ab65 Mon Sep 17 00:00:00 2001 From: Lia Date: Mon, 28 Sep 2026 21:38:53 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20style:=20Match=20De?= =?UTF-8?q?v=20Tailwind=20Class=20Order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Nav/SettingsTabs/General/ConfigReload.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx b/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx index ab535bddc4a..ef200f5976f 100644 --- a/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ConfigReload.tsx @@ -69,7 +69,7 @@ export default function ConfigReload() {
-

+

{localize('com_ui_config_reload_description')}

@@ -87,7 +87,7 @@ export default function ConfigReload() {
{errorKey && ( -
+

{localize(errorKey)}

{validationErrors && validationErrors.length > 0 && (
    @@ -108,20 +108,20 @@ export default function ConfigReload() { {localize('com_ui_config_reload_propagation_error')}

    )} -
      +
        {report.sections.map(({ section, status, restartRequired, restartRequiredPaths }) => (
      • - {section} - + {section} + {localize(SECTION_LABELS[status])} {restartRequired && status !== 'restart_required' && ( · {localize('com_ui_config_reload_restart_required')} )} {restartRequiredPaths && restartRequiredPaths.length > 0 && ( - + {restartRequiredPaths.join(', ')} )}