Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion api/server/controllers/ModelController.js
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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);
Comment on lines 21 to 23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move generation stamping out of the legacy controller

Move the newly added generation lookup and response-header behavior into a TypeScript handler under packages/api, leaving this legacy CJS controller as wiring only. Calling the config-generation service and implementing the versioned response contract here extends backend behavior in /api, contrary to the repository's explicit module boundary and makes this reload protocol split across the legacy and typed layers.

AGENTS.md reference: AGENTS.md:L77-L81

Useful? React with 👍 / 👎.

} catch (error) {
logger.error('Error fetching models:', error);
Expand Down
17 changes: 16 additions & 1 deletion api/server/controllers/ModelController.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const mockLoadDefaultModels = jest.fn();
const mockLoadConfigModels = jest.fn();
const mockGetGeneration = jest.fn(() => '2');

jest.mock('@librechat/data-schemas', () => ({
logger: {
Expand All @@ -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;
Expand Down Expand Up @@ -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'] });
});
});
22 changes: 22 additions & 0 deletions api/server/routes/__tests__/config.spec.js
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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 () => {
Expand Down
12 changes: 11 additions & 1 deletion api/server/routes/admin/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions api/server/routes/admin/config.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 4 additions & 1 deletion api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -210,6 +211,8 @@ function buildCloudFrontStartupConfig() {
};
}

router.get('/revision', createConfigRevisionHandler(getConfigRefreshStatus));

router.get('/', async function (req, res) {
try {
const preLoginPayload = buildPreLoginPayload();
Expand Down
4 changes: 3 additions & 1 deletion api/server/routes/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, modelController);

module.exports = router;
32 changes: 32 additions & 0 deletions api/server/routes/models.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 16 additions & 16 deletions api/server/services/Config/__tests__/invalidateConfigCaches.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({}));
Expand Down Expand Up @@ -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),
Expand All @@ -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);
});

Expand All @@ -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 () => {
Expand All @@ -90,15 +99,6 @@ describe('invalidateConfigCaches', () => {
}, 10),
),
);
mockInvalidateCachedTools.mockImplementation(
() =>
new Promise((r) =>
setTimeout(() => {
order.push('tools');
r();
}, 10),
),
);
mockClearMcpConfigCache.mockImplementation(
() =>
new Promise((r) =>
Expand All @@ -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 () => {
Expand All @@ -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();
});
});
Loading
Loading