diff --git a/api/cache/distributedLock.js b/api/cache/distributedLock.js
new file mode 100644
index 00000000000..4fe83e415a3
--- /dev/null
+++ b/api/cache/distributedLock.js
@@ -0,0 +1,6 @@
+const { ioredisClient, createDistributedLock } = require('@librechat/api');
+
+/** The one `DistributedLock` for this process. Redis-backed when `ioredisClient` is set; otherwise an in-process lock that coordinates only callers holding this instance. */
+const distributedLock = createDistributedLock({ redisClient: ioredisClient });
+
+module.exports = { distributedLock };
diff --git a/api/cache/distributedLock.spec.js b/api/cache/distributedLock.spec.js
new file mode 100644
index 00000000000..97ae3563f6b
--- /dev/null
+++ b/api/cache/distributedLock.spec.js
@@ -0,0 +1,18 @@
+const mockLock = { acquire: jest.fn(), extend: jest.fn(), release: jest.fn() };
+const mockCreateDistributedLock = jest.fn(() => mockLock);
+const sentinelRedisClient = {};
+
+jest.mock('@librechat/api', () => ({
+ createDistributedLock: mockCreateDistributedLock,
+ ioredisClient: sentinelRedisClient,
+}));
+
+describe('distributedLock', () => {
+ it('builds the process-wide lock once with the shared Redis client', () => {
+ const { distributedLock } = require('./distributedLock');
+
+ expect(mockCreateDistributedLock).toHaveBeenCalledTimes(1);
+ expect(mockCreateDistributedLock).toHaveBeenCalledWith({ redisClient: sentinelRedisClient });
+ expect(distributedLock).toBe(mockLock);
+ });
+});
diff --git a/api/cache/index.js b/api/cache/index.js
index 1bad242f13f..48df760bffa 100644
--- a/api/cache/index.js
+++ b/api/cache/index.js
@@ -1,4 +1,5 @@
const getLogStores = require('./getLogStores');
const logViolation = require('./logViolation');
+const { distributedLock } = require('./distributedLock');
-module.exports = { getLogStores, logViolation };
+module.exports = { getLogStores, logViolation, distributedLock };
diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
index 51e8db8ada7..2a295e73a0a 100644
--- a/api/server/routes/__tests__/config.spec.js
+++ b/api/server/routes/__tests__/config.spec.js
@@ -10,10 +10,10 @@ jest.mock('~/server/services/Config/ldap', () => ({
}));
const mockHasCapability = jest.fn();
-const mockHasConfigCapability = jest.fn();
+const mockGetHeldCapabilities = jest.fn();
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
- hasConfigCapability: (...args) => mockHasConfigCapability(...args),
+ getHeldCapabilities: (...args) => mockGetHeldCapabilities(...args),
}));
const mockGetTenantId = jest.fn(() => undefined);
@@ -37,6 +37,7 @@ jest.mock('@librechat/api', () => ({
const request = require('supertest');
const express = require('express');
+const { SystemCapabilities, configCapability } = require('@librechat/data-schemas');
const configRoute = require('../config');
function createApp(user) {
@@ -816,10 +817,18 @@ describe('GET /api/config', () => {
});
});
+ /** A held-capability set that grants connection access and prompt-sync manage access. */
+ const fullLangfuseAccess = () =>
+ new Set([
+ SystemCapabilities.ACCESS_ADMIN,
+ SystemCapabilities.MANAGE_CONFIGS,
+ configCapability('langfuse'),
+ SystemCapabilities.MANAGE_PROMPTS,
+ ]);
+
it('should advertise Langfuse fanout only when the toggle and collector URL are configured', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(true);
- mockHasConfigCapability.mockResolvedValue(true);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
const app = createApp(mockUser);
@@ -840,8 +849,7 @@ describe('GET /api/config', () => {
it('hides Langfuse connection access when tenant export is emergency-disabled', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(true);
- mockHasConfigCapability.mockResolvedValue(true);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
@@ -851,8 +859,7 @@ describe('GET /api/config', () => {
expect(response.body.langfuseFanoutEnabled).toBe(true);
expect(response.body.langfuseConnectionAccess).toBe(false);
- expect(mockHasCapability).not.toHaveBeenCalled();
- expect(mockHasConfigCapability).not.toHaveBeenCalled();
+ expect(mockGetHeldCapabilities).not.toHaveBeenCalled();
});
it('advertises Langfuse connection access from capabilities rather than the user role', async () => {
@@ -862,34 +869,81 @@ describe('GET /api/config', () => {
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
const app = createApp({ ...mockUser, role: 'DELEGATED_ADMIN' });
- mockHasCapability.mockImplementation(
- async (_user, capability) => capability === 'access:admin',
+ mockGetHeldCapabilities.mockResolvedValue(
+ new Set([SystemCapabilities.ACCESS_ADMIN, configCapability('langfuse')]),
);
- mockHasConfigCapability.mockResolvedValue(true);
let response = await request(app).get('/api/config');
expect(response.body.langfuseConnectionAccess).toBe(true);
- mockHasConfigCapability.mockResolvedValue(false);
+ mockGetHeldCapabilities.mockResolvedValue(new Set([SystemCapabilities.ACCESS_ADMIN]));
response = await request(app).get('/api/config');
expect(response.body.langfuseFanoutEnabled).toBe(true);
expect(response.body.langfuseConnectionAccess).toBe(false);
});
- it('skips the Langfuse management capability check without admin access', async () => {
+ it('advertises prompt-sync manage access alongside connection access when the admin holds manage:prompts', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(false);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
+ process.env.LANGFUSE_FANOUT_ENABLED = 'true';
+ process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.langfuseConnectionAccess).toBe(true);
+ expect(response.body.langfusePromptManageAccess).toBe(true);
+ expect(mockGetHeldCapabilities).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.arrayContaining([SystemCapabilities.MANAGE_PROMPTS]),
+ );
+ });
+
+ it('withholds prompt-sync manage access when the admin lacks manage:prompts', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.LANGFUSE_FANOUT_ENABLED = 'true';
+ process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
+ const app = createApp(mockUser);
+
+ mockGetHeldCapabilities.mockResolvedValue(
+ new Set([SystemCapabilities.ACCESS_ADMIN, SystemCapabilities.MANAGE_CONFIGS]),
+ );
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.langfuseConnectionAccess).toBe(true);
+ expect(response.body.langfusePromptManageAccess).toBe(false);
+ });
+
+ it('withholds prompt-sync manage access when Langfuse connection access itself is unavailable, even if manage:prompts is held', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.LANGFUSE_FANOUT_ENABLED = 'true';
+ process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
+ const app = createApp(mockUser);
+
+ mockGetHeldCapabilities.mockResolvedValue(
+ new Set([SystemCapabilities.ACCESS_ADMIN, SystemCapabilities.MANAGE_PROMPTS]),
+ );
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.langfuseConnectionAccess).toBe(false);
+ expect(response.body.langfusePromptManageAccess).toBe(false);
+ expect(mockGetHeldCapabilities).toHaveBeenCalledTimes(1);
+ });
+
+ it('withholds Langfuse connection access without admin access', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetHeldCapabilities.mockResolvedValue(new Set());
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.langfuseConnectionAccess).toBe(false);
- expect(mockHasConfigCapability).not.toHaveBeenCalled();
});
it('advertises Langfuse connection access by default in single-tenant mode', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(true);
- mockHasConfigCapability.mockResolvedValue(true);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
@@ -900,8 +954,7 @@ describe('GET /api/config', () => {
it('hides single-tenant connection settings when environment credentials are configured', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(true);
- mockHasConfigCapability.mockResolvedValue(true);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
process.env.LANGFUSE_SECRET_KEY = 'sk-env';
const app = createApp(mockUser);
@@ -909,8 +962,7 @@ describe('GET /api/config', () => {
const response = await request(app).get('/api/config');
expect(response.body.langfuseConnectionAccess).toBe(false);
- expect(mockHasCapability).not.toHaveBeenCalled();
- expect(mockHasConfigCapability).not.toHaveBeenCalled();
+ expect(mockGetHeldCapabilities).not.toHaveBeenCalled();
});
it.each([
@@ -918,15 +970,14 @@ describe('GET /api/config', () => {
['LANGFUSE_SAMPLE_RATE', '0'],
])('hides Langfuse connection settings when %s=%s', async (key, value) => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(true);
- mockHasConfigCapability.mockResolvedValue(true);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
process.env[key] = value;
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.langfuseConnectionAccess).toBe(false);
- expect(mockHasCapability).not.toHaveBeenCalled();
+ expect(mockGetHeldCapabilities).not.toHaveBeenCalled();
});
it.each([
@@ -945,14 +996,13 @@ describe('GET /api/config', () => {
])('advertises prompt access with %s without advertising trace access', async (_mode, env) => {
Object.assign(process.env, env, { LANGFUSE_PROMPT_SYNC_AVAILABLE: 'true' });
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(true);
- mockHasConfigCapability.mockResolvedValue(true);
+ mockGetHeldCapabilities.mockResolvedValue(fullLangfuseAccess());
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.status).toBe(200);
expect(response.body.langfusePromptSyncAccess).toBe(true);
expect(response.body.langfuseConnectionAccess).toBe(false);
- expect(mockHasCapability).toHaveBeenCalledTimes(1);
- expect(mockHasConfigCapability).toHaveBeenCalledTimes(1);
+ expect(response.body.langfusePromptManageAccess).toBe(true);
+ expect(mockGetHeldCapabilities).toHaveBeenCalledTimes(1);
});
it.each([
@@ -962,13 +1012,33 @@ describe('GET /api/config', () => {
process.env.LANGFUSE_PROMPT_SYNC_AVAILABLE = 'true';
process.env.LANGFUSE_TRACING_ENABLED = 'false';
mockGetAppConfig.mockResolvedValue(baseAppConfig);
- mockHasCapability.mockResolvedValue(admin);
- mockHasConfigCapability.mockResolvedValue(manage);
+ mockGetHeldCapabilities.mockResolvedValue(
+ new Set([
+ ...(admin ? [SystemCapabilities.ACCESS_ADMIN] : []),
+ ...(manage ? [configCapability('langfuse')] : []),
+ SystemCapabilities.MANAGE_PROMPTS,
+ ]),
+ );
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.body.langfusePromptSyncAccess).toBe(false);
expect(response.body.langfuseConnectionAccess).toBe(false);
});
+ it('shows prompt settings without sync permission when tracing is disabled', async () => {
+ process.env.LANGFUSE_PROMPT_SYNC_AVAILABLE = 'true';
+ process.env.LANGFUSE_TRACING_ENABLED = 'false';
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ mockGetHeldCapabilities.mockResolvedValue(
+ new Set([SystemCapabilities.ACCESS_ADMIN, configCapability('langfuse')]),
+ );
+
+ const response = await request(createApp(mockUser)).get('/api/config');
+
+ expect(response.body.langfusePromptSyncAccess).toBe(true);
+ expect(response.body.langfuseConnectionAccess).toBe(false);
+ expect(response.body.langfusePromptManageAccess).toBe(false);
+ });
+
it('should include post-login informational fields', async () => {
process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
process.env.CUSTOM_FOOTER = 'authenticated footer text';
diff --git a/api/server/routes/admin/langfuse.js b/api/server/routes/admin/langfuse.js
index 10e91ea4443..0102bf6658a 100644
--- a/api/server/routes/admin/langfuse.js
+++ b/api/server/routes/admin/langfuse.js
@@ -5,6 +5,7 @@ const {
hasConfigCapability,
requireCapability,
} = require('~/server/middleware/roles/capabilities');
+const { syncLangfusePromptCatalog } = require('~/server/services/LangfusePromptSync');
const { invalidateConfigCaches } = require('~/server/services/Config');
const configMiddleware = require('~/server/middleware/config/app');
const { requireJwtAuth } = require('~/server/middleware');
@@ -13,6 +14,7 @@ const db = require('~/models');
const router = express.Router();
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
+const requireManagePrompts = requireCapability(SystemCapabilities.MANAGE_PROMPTS);
async function requireLangfuseManage(req, res, next) {
try {
@@ -40,6 +42,7 @@ const handlers = createAdminLangfuseHandlers({
patchConfigFields: db.patchConfigFields,
toggleConfigActive: db.toggleConfigActive,
getMessages: db.getMessages,
+ syncPromptCatalog: syncLangfusePromptCatalog,
invalidateConfigCaches,
recordAuditEntry: db.recordAuditEntry,
});
@@ -55,6 +58,7 @@ router.put('/connection', handlers.updateConnection);
router.post('/connection/test', handlers.testConnection);
router.put('/prompt-sync', handlers.updatePromptSync);
router.get('/prompts', handlers.listPrompts);
+router.post('/prompts/sync', requireManagePrompts, handlers.syncPrompts);
router.get('/prompts/:name', handlers.getPrompt);
module.exports = router;
diff --git a/api/server/routes/admin/langfuse.test.js b/api/server/routes/admin/langfuse.test.js
index fde9b5be969..4ff8a83ea3b 100644
--- a/api/server/routes/admin/langfuse.test.js
+++ b/api/server/routes/admin/langfuse.test.js
@@ -24,19 +24,27 @@ const mockHandlers = {
testConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'test' })),
updatePromptSync: jest.fn((_req, res) => res.status(200).json({ handler: 'prompt-sync' })),
listPrompts: jest.fn((_req, res) => res.status(200).json({ handler: 'prompts' })),
+ syncPrompts: jest.fn((_req, res) => res.status(200).json({ handler: 'sync' })),
getPrompt: jest.fn((req, res) =>
res.status(200).json({ handler: 'prompt', name: req.params.name }),
),
};
+const mockSyncPromptCatalog = jest.fn();
+const mockLogger = { warn: jest.fn(), error: jest.fn() };
jest.mock('@librechat/data-schemas', () => ({
- SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
+ logger: mockLogger,
+ SystemCapabilities: { ACCESS_ADMIN: 'access:admin', MANAGE_PROMPTS: 'manage:prompts' },
}));
jest.mock('@librechat/api', () => ({
createAdminLangfuseHandlers: jest.fn(() => mockHandlers),
}));
+jest.mock('~/server/services/LangfusePromptSync', () => ({
+ syncLangfusePromptCatalog: mockSyncPromptCatalog,
+}));
+
jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: mockRequireCapability,
hasConfigCapability: mockHasConfigCapability,
@@ -78,6 +86,18 @@ describe('admin Langfuse routes', () => {
return app;
}
+ // The router module builds its handler dependencies once, at `require`
+ // time, not per request. `createApp()` only reloads the module the first
+ // time it runs in this file (later `beforeEach` clears already took effect
+ // by the time any other test observes it), so this captures that one
+ // load's dependency wiring before anything can clear it.
+ let handlerDepsAtLoad;
+ beforeAll(() => {
+ createApp();
+ const { createAdminLangfuseHandlers } = require('@librechat/api');
+ handlerDepsAtLoad = createAdminLangfuseHandlers.mock.calls[0]?.[0];
+ });
+
beforeEach(() => {
deniedCapability = undefined;
canManageLangfuse = true;
@@ -138,6 +158,7 @@ describe('admin Langfuse routes', () => {
['PUT', '/api/admin/langfuse/prompt-sync', 'updatePromptSync'],
['GET', '/api/admin/langfuse/prompts', 'listPrompts'],
['GET', '/api/admin/langfuse/prompts/greeting', 'getPrompt'],
+ ['POST', '/api/admin/langfuse/prompts/sync', 'syncPrompts'],
])('blocks %s %s without Langfuse manage access', async (method, path, handlerName) => {
canManageLangfuse = false;
@@ -149,6 +170,7 @@ describe('admin Langfuse routes', () => {
it.each([
['GET', '/api/admin/langfuse/prompts', 'listPrompts'],
['GET', '/api/admin/langfuse/prompts/greeting', 'getPrompt'],
+ ['POST', '/api/admin/langfuse/prompts/sync', 'syncPrompts'],
])('rejects %s %s for an unauthenticated caller', async (method, path, handlerName) => {
mockRequireJwtAuth.mockImplementationOnce((_req, res) => {
middlewareCalls.push('jwt');
@@ -163,6 +185,7 @@ describe('admin Langfuse routes', () => {
it.each([
['GET', '/api/admin/langfuse/prompts', 'listPrompts'],
['GET', '/api/admin/langfuse/prompts/greeting', 'getPrompt'],
+ ['POST', '/api/admin/langfuse/prompts/sync', 'syncPrompts'],
])('rejects %s %s for a non-admin caller', async (method, path, handlerName) => {
deniedCapability = 'access:admin';
@@ -204,4 +227,32 @@ describe('admin Langfuse routes', () => {
expect(mockConfigMiddleware).not.toHaveBeenCalled();
});
+
+ it('passes the composed Langfuse prompt-sync runner to the handler factory, not its dependencies', () => {
+ expect(handlerDepsAtLoad).toMatchObject({ syncPromptCatalog: mockSyncPromptCatalog });
+ expect(handlerDepsAtLoad).not.toHaveProperty('lock');
+ expect(handlerDepsAtLoad).not.toHaveProperty('grantPermission');
+ expect(handlerDepsAtLoad).not.toHaveProperty('findLangfusePromptGroup');
+ expect(handlerDepsAtLoad).not.toHaveProperty('createLangfusePromptGroup');
+ expect(handlerDepsAtLoad).not.toHaveProperty('deletePromptGroup');
+ });
+
+ it('requires manage:prompts access, in addition to Langfuse manage access, for POST /prompts/sync', async () => {
+ const response = await request(createApp())
+ .post('/api/admin/langfuse/prompts/sync')
+ .send({})
+ .expect(200);
+
+ expect(response.body).toEqual({ handler: 'sync' });
+ expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'config', 'manage:prompts']);
+ expect(mockHandlers.syncPrompts).toHaveBeenCalledTimes(1);
+ });
+
+ it('blocks POST /prompts/sync for a caller without manage:prompts access', async () => {
+ deniedCapability = 'manage:prompts';
+
+ await request(createApp()).post('/api/admin/langfuse/prompts/sync').send({}).expect(403);
+
+ expect(mockHandlers.syncPrompts).not.toHaveBeenCalled();
+ });
});
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index b7908e24eac..a7d32cd15d8 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -34,8 +34,8 @@ const {
defaultSocialLogins,
resolveMCPAppsPolicy,
} = require('librechat-data-provider');
-const { logger, SystemCapabilities } = require('@librechat/data-schemas');
-const { hasCapability, hasConfigCapability } = require('~/server/middleware/roles/capabilities');
+const { logger, SystemCapabilities, configCapability } = require('@librechat/data-schemas');
+const { hasCapability, getHeldCapabilities } = 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');
@@ -278,6 +278,8 @@ router.get('/', async function (req, res) {
const langfuseFanoutEnabled = isLangfuseFanoutEnabled();
const langfuseSettingsAvailable = isLangfuseSettingsAvailable();
let canManageLangfuse = false;
+ // Running a sync also requires the prompt-management capability.
+ let langfusePromptManageAccess = false;
if (langfuseSettingsAvailable) {
try {
@@ -289,13 +291,18 @@ router.get('/', async function (req, res) {
tenantId: req.user.tenantId,
idOnTheSource: req.user.idOnTheSource ?? null,
};
- const hasAdminAccess = await hasCapability(
- capabilityUser,
+ const held = await getHeldCapabilities(capabilityUser, [
SystemCapabilities.ACCESS_ADMIN,
- );
- if (hasAdminAccess) {
- canManageLangfuse = await hasConfigCapability(capabilityUser, 'langfuse');
- }
+ SystemCapabilities.MANAGE_CONFIGS,
+ configCapability('langfuse'),
+ SystemCapabilities.MANAGE_PROMPTS,
+ ]);
+ const hasAdminAccess = held.has(SystemCapabilities.ACCESS_ADMIN);
+ canManageLangfuse =
+ hasAdminAccess &&
+ (held.has(SystemCapabilities.MANAGE_CONFIGS) || held.has(configCapability('langfuse')));
+ langfusePromptManageAccess =
+ canManageLangfuse && held.has(SystemCapabilities.MANAGE_PROMPTS);
}
} catch (err) {
logger.warn(`[config] Langfuse capability check failed: ${err.message}`);
@@ -331,6 +338,7 @@ router.get('/', async function (req, res) {
: 0,
langfuseFanoutEnabled,
...getLangfuseSettingsAccess(canManageLangfuse),
+ langfusePromptManageAccess,
insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS),
/** Lets the client skip the pull request lookup entirely when the feature is off. */
...resolvePullRequestCapabilities(appConfig, process.env),
diff --git a/api/server/services/LangfusePromptSync.js b/api/server/services/LangfusePromptSync.js
new file mode 100644
index 00000000000..b672cec7303
--- /dev/null
+++ b/api/server/services/LangfusePromptSync.js
@@ -0,0 +1,17 @@
+const { createLangfusePromptSyncRunner } = require('@librechat/api');
+const { logger } = require('@librechat/data-schemas');
+const { distributedLock } = require('~/cache');
+const { grantPermission } = require('~/server/services/PermissionService');
+const db = require('~/models');
+
+/** The one Langfuse prompt-sync runner for this process, bound to the shared `distributedLock`. */
+const syncLangfusePromptCatalog = createLangfusePromptSyncRunner({
+ lock: distributedLock,
+ findLangfusePromptGroup: db.findLangfusePromptGroup,
+ createLangfusePromptGroup: db.createLangfusePromptGroup,
+ deletePromptGroup: db.deletePromptGroup,
+ grantPermission,
+ logger,
+});
+
+module.exports = { syncLangfusePromptCatalog };
diff --git a/api/server/services/LangfusePromptSync.spec.js b/api/server/services/LangfusePromptSync.spec.js
new file mode 100644
index 00000000000..c899fa66239
--- /dev/null
+++ b/api/server/services/LangfusePromptSync.spec.js
@@ -0,0 +1,45 @@
+const mockLock = { acquire: jest.fn(), extend: jest.fn(), release: jest.fn() };
+const mockSyncPromptCatalog = jest.fn();
+const mockCreateLangfusePromptSyncRunner = jest.fn(() => mockSyncPromptCatalog);
+const mockGrantPermission = jest.fn();
+const mockLogger = { warn: jest.fn(), error: jest.fn() };
+
+jest.mock('@librechat/api', () => ({
+ createLangfusePromptSyncRunner: mockCreateLangfusePromptSyncRunner,
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ logger: mockLogger,
+}));
+
+jest.mock('~/cache', () => ({
+ distributedLock: mockLock,
+}));
+
+jest.mock('~/server/services/PermissionService', () => ({
+ grantPermission: mockGrantPermission,
+}));
+
+jest.mock('~/models', () => ({
+ findLangfusePromptGroup: jest.fn(),
+ createLangfusePromptGroup: jest.fn(),
+ deletePromptGroup: jest.fn(),
+}));
+
+describe('LangfusePromptSync', () => {
+ it('composes the sync runner once with the shared lock and the db methods', () => {
+ const db = require('~/models');
+ const { syncLangfusePromptCatalog } = require('./LangfusePromptSync');
+
+ expect(mockCreateLangfusePromptSyncRunner).toHaveBeenCalledTimes(1);
+ expect(mockCreateLangfusePromptSyncRunner).toHaveBeenCalledWith({
+ lock: mockLock,
+ findLangfusePromptGroup: db.findLangfusePromptGroup,
+ createLangfusePromptGroup: db.createLangfusePromptGroup,
+ deletePromptGroup: db.deletePromptGroup,
+ grantPermission: mockGrantPermission,
+ logger: mockLogger,
+ });
+ expect(syncLangfusePromptCatalog).toBe(mockSyncPromptCatalog);
+ });
+});
diff --git a/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx b/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx
index ca0117e842c..09026fbbc48 100644
--- a/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx
+++ b/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx
@@ -18,12 +18,15 @@ import type {
} from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
+ useGetStartupConfig,
useGetLangfuseConnectionQuery,
useUpdateLangfuseConnectionMutation,
useTestLangfuseConnectionMutation,
useUpdateLangfusePromptSyncMutation,
+ useSyncLangfusePromptsMutation,
} from '~/data-provider';
import LangfusePromptsDialog from './LangfusePromptsDialog';
+import { getPromptErrorMessage } from './promptErrors';
import { useLocalize } from '~/hooks';
type ConnectionTestState = 'idle' | 'unverified' | 'checking' | 'connected' | 'failed';
@@ -104,6 +107,11 @@ function getDisplayPublicKey(publicKey: string): string {
export default function LangfuseConnection() {
const localize = useLocalize();
const { showToast } = useToastContext();
+ const { data: startupConfig } = useGetStartupConfig();
+ // The server's `POST /prompts/sync` route requires `manage:prompts` in addition to the
+ // `langfuse` config capability that gates this whole tab, so an admin can see the
+ // connection without being able to run the sync.
+ const canManagePrompts = startupConfig?.langfusePromptManageAccess === true;
const {
data: status,
isLoading: isConnectionLoading,
@@ -114,6 +122,7 @@ export default function LangfuseConnection() {
const updateMutation = useUpdateLangfuseConnectionMutation();
const testMutation = useTestLangfuseConnectionMutation();
const promptSyncMutation = useUpdateLangfusePromptSyncMutation();
+ const syncPromptsMutation = useSyncLangfusePromptsMutation();
const [connectionStatus, setConnectionStatus] = useState();
const [destination, setDestination] = useState('');
@@ -197,7 +206,7 @@ export default function LangfuseConnection() {
trimmedPublicKey !== '' &&
((!connectionCredentialsChanged && secretConfigured) || trimmedSecretKey !== '');
const isSavingStatus = updateMutation.isLoading || promptSyncMutation.isLoading;
- const busy = testMutation.isLoading || isSavingStatus;
+ const busy = testMutation.isLoading || isSavingStatus || syncPromptsMutation.isLoading;
useEffect(() => {
const storedConnectionTestKey = getStoredConnectionTestKey(connectionStatus);
@@ -391,6 +400,35 @@ export default function LangfuseConnection() {
);
};
+ const handleSyncPrompts = () => {
+ syncPromptsMutation.mutate(undefined, {
+ onSuccess: (result) => {
+ if (result.complete === false) {
+ showToast({
+ message: localize('com_ui_langfuse_prompt_sync_incomplete', {
+ 0: result.created,
+ 1: result.unchanged,
+ 2: result.skipped,
+ }),
+ status: 'warning',
+ });
+ return;
+ }
+ showToast({
+ message: localize('com_ui_langfuse_prompt_sync_result', {
+ 0: result.created,
+ 1: result.unchanged,
+ 2: result.skipped,
+ }),
+ status: 'success',
+ });
+ },
+ onError: (error) => {
+ showToast({ message: getPromptErrorMessage(error, localize), status: 'error' });
+ },
+ });
+ };
+
if (isConnectionLoading && connectionStatus == null) {
return (
@@ -604,8 +642,25 @@ export default function LangfuseConnection() {
)}
{connectionStatus.promptSync.enabled && (
-
-