🪢 feat: Langfuse Fanout Connection Setting (#14108)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* feat: encrypt tenant Langfuse secret in admin config

Add generic per-field secret encryption to the admin config layer: registered
secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a
non-secret fingerprint companion is stored. Admin config reads (base + per
principal) redact registered secrets so they are never returned; the fingerprint
is kept so the UI can show which key is configured.

The Langfuse fanout read path decrypts the tenant secret before export. Adds
secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact
policy.

* fix(api): secure admin config secret handling

* fix(api): preserve encrypted langfuse config secrets

* fix(api): couple config secret fingerprint deletion

* fix(api): read langfuse fanout collector url from env

* fix(api): display langfuse secret key hint

* fix(api): remove langfuse secret fingerprint breadcrumbs

* fix(api): use langfuse destination keys for tenant config

* fix(api): remove langfuse config compatibility fallbacks

* refactor(api): simplify langfuse secret helpers

* refactor(api): simplify langfuse config secret handling

* feat: in-app Langfuse connection settings panel

Add a discoverable, admin-gated Langfuse connection panel inside LibreChat
Settings (Dify-style): enable toggle, host, public key, masked write-only secret,
configured-key fingerprint, and a test-connection action. Backed by a dedicated
/api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns
metadata plus fingerprint on read, and validates credentials. Builds on the
per-field encryption and fanout decrypt from the langfuse-config-encryption branch.

* refactor: align Langfuse secret field to CustomUserVars pattern

Use the established SecretInput plus Set/Unset state pill (com_ui_set/com_ui_unset)
from the MCP CustomUserVars UI for the saved-secret state, instead of a bespoke
masked input.

* fix: drop em dash from saved-secret placeholder

* feat: show loading state on Langfuse test connection button

* feat: gate in-app Langfuse settings on fanout config and admin role

* test: align Langfuse connection spec with SecretInput refactor

* feat(langfuse): refine tenant connection controls

* fix(admin): refine Langfuse connection verification

* fix(langfuse): refine tenant connection settings

* fix(langfuse): simplify export enablement controls

* fix(langfuse): validate tenant export configuration

* fix(langfuse): align startup fanout gate

* fix(admin): time out Langfuse verification

* fix(ui): rename Langfuse connection setting

* fix(admin): enforce Langfuse config capability

* feat(langfuse): require explicit tenant export activation

* feat(langfuse): support single-tenant connection settings

* fix(i18n): remove obsolete integrations label

* fix(langfuse): authenticate ingestion verification

* fix(langfuse): validate public key independently

* fix(langfuse): localize connection errors

* perf(config): skip Langfuse checks for non-admins

* fix(langfuse): preserve trace sampling for feedback

* test(langfuse): fix feedback sampling fixture

* fix(langfuse): align secret preview field

* fix(langfuse): harden connection settings state

* fix(langfuse): preserve trace destination state

* fix(langfuse): enforce tenant-wide routing invariants

* fix(langfuse): preserve verified connection invariants

* fix(langfuse): preserve stable project identity

* fix(langfuse): warm project identity asynchronously

---------

Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2026-07-29 15:33:10 -07:00 committed by GitHub
parent 91adcf3f2c
commit af795be0c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 4356 additions and 527 deletions

View file

@ -144,14 +144,23 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# LANGFUSE_PUBLIC_KEY=
# LANGFUSE_SECRET_KEY=
# LANGFUSE_BASE_URL=
# Optional stable project ID. When omitted, LibreChat discovers it from Langfuse in the background.
# LANGFUSE_PROJECT_ID=
# Set false to disable Langfuse traces and feedback scores.
# LANGFUSE_TRACING_ENABLED=true
# Trace-level sample rate from 0 to 1. Sampled-out traces do not receive scores.
# LANGFUSE_SAMPLE_RATE=1
# In single-tenant deployments without environment credentials, an admin can
# configure one encrypted Langfuse connection in the application settings.
# Complete environment credentials take precedence and hide those settings.
# Optional Langfuse fanout for tenant-scoped Langfuse projects.
# The fanout gateway is opt-in: add docker-compose.langfuse-fanout.yml,
# deploy-compose.langfuse-fanout.yml, or enable helm langfuseFanout.
# Tenant public/secret keys are read from LibreChat tenant app configuration.
# Tenant Langfuse base URLs must be set in tenant app configuration and match
# one of the known startup destinations. Tenant API keys can be added or changed
# at runtime through tenant app configuration.
# Tenant public/secret keys and a destination key are read from LibreChat tenant
# app configuration. Destination keys resolve against known startup URLs. Tenant
# API keys can be added or changed at runtime through tenant app configuration.
# See otel/langfuse-fanout/README.md.
# LANGFUSE_FANOUT_ENABLED=false
# LANGFUSE_FANOUT_COLLECTOR_URL=http://langfuse-fanout-collector:4318

View file

@ -12,6 +12,9 @@ const {
encodeAndFormatAudios,
encodeAndFormatVideos,
encodeAndFormatDocuments,
getLangfuseTraceDestinationIds,
isLangfuseTraceSampled,
traceIdForMessage,
} = require('@librechat/api');
const {
Constants,
@ -712,12 +715,25 @@ class BaseClient {
this.abortController.requestCompleted = true;
}
const isAgentResponse = isAgentsEndpoint(this.options.endpoint);
const langfuseTraceId = isAgentResponse ? traceIdForMessage(responseMessageId) : undefined;
const langfuseSampled =
langfuseTraceId != null ? isLangfuseTraceSampled(langfuseTraceId) : undefined;
/** @type {TMessage} */
const responseMessage = {
messageId: responseMessageId,
conversationId,
parentMessageId: userMessage.messageId,
isCreatedByUser: false,
...(isAgentResponse && {
langfuseSampled,
langfuseDestinationIds: await getLangfuseTraceDestinationIds(
appConfig,
langfuseTraceId,
langfuseSampled,
),
}),
isEdited,
model: this.getResponseModel(),
sender: this.sender,

View file

@ -754,6 +754,78 @@ describe('BaseClient', () => {
);
});
test('persists the generation-time Langfuse sampling decision for agent responses', async () => {
const previousSampleRate = process.env.LANGFUSE_SAMPLE_RATE;
process.env.LANGFUSE_SAMPLE_RATE = '0';
TestClient.options.endpoint = 'agents';
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
try {
const response = await TestClient.sendMessage('Hello, world!', { user: {} });
expect(response.langfuseSampled).toBe(false);
expect(response.langfuseDestinationIds).toEqual([]);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
langfuseSampled: false,
langfuseDestinationIds: [],
}),
expect.any(Object),
expect.any(Object),
);
} finally {
if (previousSampleRate == null) {
delete process.env.LANGFUSE_SAMPLE_RATE;
} else {
process.env.LANGFUSE_SAMPLE_RATE = previousSampleRate;
}
}
});
test('persists no Langfuse destination when a sampled trace has no configured export', async () => {
const envKeys = [
'LANGFUSE_PUBLIC_KEY',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_FANOUT_ENABLED',
'LANGFUSE_FANOUT_COLLECTOR_URL',
'TENANT_ISOLATION_STRICT',
];
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
const previousSampleRate = process.env.LANGFUSE_SAMPLE_RATE;
envKeys.forEach((key) => delete process.env[key]);
process.env.LANGFUSE_SAMPLE_RATE = '1';
TestClient.options.endpoint = 'agents';
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
try {
const response = await TestClient.sendMessage('Hello, world!', { user: {} });
expect(response.langfuseSampled).toBe(true);
expect(response.langfuseDestinationIds).toEqual([]);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
langfuseSampled: true,
langfuseDestinationIds: [],
}),
expect.any(Object),
expect.any(Object),
);
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (previousSampleRate == null) {
delete process.env.LANGFUSE_SAMPLE_RATE;
} else {
process.env.LANGFUSE_SAMPLE_RATE = previousSampleRate;
}
}
});
test('should handle existing conversation when getConvo retrieves one', async () => {
const existingConvo = {
conversationId: 'existing-convo-id',

View file

@ -268,6 +268,7 @@ const startServer = async () => {
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
app.use('/api/admin/langfuse', routes.adminLangfuse);
app.use('/api/admin/grants', routes.adminGrants);
app.use('/api/admin/groups', routes.adminGroups);
app.use('/api/admin/roles', routes.adminRoles);

View file

@ -10,8 +10,10 @@ jest.mock('~/server/services/Config/ldap', () => ({
}));
const mockHasCapability = jest.fn();
const mockHasConfigCapability = jest.fn();
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
hasConfigCapability: (...args) => mockHasConfigCapability(...args),
}));
const mockGetTenantId = jest.fn(() => undefined);
@ -104,6 +106,14 @@ afterEach(() => {
delete process.env.ANALYTICS_GTM_ID;
delete process.env.CUSTOM_FOOTER;
delete process.env.HELP_AND_FAQ_URL;
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_TRACING_ENABLED;
delete process.env.LANGFUSE_SAMPLE_RATE;
delete process.env.TENANT_ISOLATION_STRICT;
});
describe('GET /api/config', () => {
@ -385,6 +395,119 @@ describe('GET /api/config', () => {
expect(response.body.conversationImportMaxFileSize).toBe(5000000);
});
it('should advertise Langfuse fanout only when the toggle and collector URL are configured', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);
mockHasConfigCapability.mockResolvedValue(true);
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
const app = createApp(mockUser);
let response = await request(app).get('/api/config');
expect(response.body.langfuseFanoutEnabled).toBe(false);
expect(response.body.langfuseConnectionAccess).toBe(true);
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = ' ';
response = await request(app).get('/api/config');
expect(response.body.langfuseFanoutEnabled).toBe(false);
expect(response.body.langfuseConnectionAccess).toBe(true);
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
response = await request(app).get('/api/config');
expect(response.body.langfuseFanoutEnabled).toBe(true);
expect(response.body.langfuseConnectionAccess).toBe(true);
});
it('hides Langfuse connection access when tenant export is emergency-disabled', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);
mockHasConfigCapability.mockResolvedValue(true);
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.langfuseFanoutEnabled).toBe(true);
expect(response.body.langfuseConnectionAccess).toBe(false);
expect(mockHasCapability).not.toHaveBeenCalled();
expect(mockHasConfigCapability).not.toHaveBeenCalled();
});
it('advertises Langfuse connection access from capabilities rather than the user role', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.TENANT_ISOLATION_STRICT = 'true';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
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',
);
mockHasConfigCapability.mockResolvedValue(true);
let response = await request(app).get('/api/config');
expect(response.body.langfuseConnectionAccess).toBe(true);
mockHasConfigCapability.mockResolvedValue(false);
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 () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(false);
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);
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.langfuseFanoutEnabled).toBe(false);
expect(response.body.langfuseConnectionAccess).toBe(true);
});
it('hides single-tenant connection settings when environment credentials are configured', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);
mockHasConfigCapability.mockResolvedValue(true);
process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
process.env.LANGFUSE_SECRET_KEY = 'sk-env';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.langfuseConnectionAccess).toBe(false);
expect(mockHasCapability).not.toHaveBeenCalled();
expect(mockHasConfigCapability).not.toHaveBeenCalled();
});
it.each([
['LANGFUSE_TRACING_ENABLED', 'false'],
['LANGFUSE_SAMPLE_RATE', '0'],
])('hides Langfuse connection settings when %s=%s', async (key, value) => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);
mockHasConfigCapability.mockResolvedValue(true);
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();
});
it('should include post-login informational fields', async () => {
process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
process.env.CUSTOM_FOOTER = 'authenticated footer text';
@ -495,6 +618,7 @@ describe('GET /api/config', () => {
it('should not call hasCapability when allowAccountDeletion is already true', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.LANGFUSE_TRACING_ENABLED = 'false';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');

View file

@ -83,6 +83,8 @@ describe('PUT /:conversationId/:messageId/feedback', () => {
messageId,
conversationId: 'conversation-1',
endpoint: 'openAI',
langfuseSampled: true,
langfuseDestinationIds: ['destination-1'],
feedback,
}),
);
@ -115,6 +117,8 @@ describe('PUT /:conversationId/:messageId/feedback', () => {
);
expect(sendFeedbackScore).toHaveBeenCalledWith(
expect.objectContaining({
sampled: true,
destinationIds: ['destination-1'],
feedback: {
rating: 'thumbsDown',
tag: 'inaccurate',

View file

@ -0,0 +1,50 @@
const express = require('express');
const { createAdminLangfuseHandlers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const {
hasConfigCapability,
requireCapability,
} = require('~/server/middleware/roles/capabilities');
const { invalidateConfigCaches } = require('~/server/services/Config');
const { requireJwtAuth } = require('~/server/middleware');
const db = require('~/models');
const router = express.Router();
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
async function requireLangfuseManage(req, res, next) {
try {
const id = req.user?.id ?? req.user?._id?.toString();
if (!id) {
return res.status(401).json({ message: 'Authentication required' });
}
const user = {
id,
role: req.user.role ?? '',
tenantId: req.user.tenantId,
idOnTheSource: req.user.idOnTheSource ?? null,
};
if (await hasConfigCapability(user, 'langfuse')) {
return next();
}
return res.status(403).json({ message: 'Forbidden' });
} catch (_err) {
return res.status(500).json({ message: 'Internal Server Error' });
}
}
const handlers = createAdminLangfuseHandlers({
findConfigByPrincipal: db.findConfigByPrincipal,
patchConfigFields: db.patchConfigFields,
toggleConfigActive: db.toggleConfigActive,
invalidateConfigCaches,
});
router.use(requireJwtAuth, requireAdminAccess, requireLangfuseManage);
router.get('/connection', handlers.getConnection);
router.put('/connection', handlers.updateConnection);
router.post('/connection/test', handlers.testConnection);
module.exports = router;

View file

@ -0,0 +1,108 @@
const express = require('express');
const request = require('supertest');
let deniedCapability;
let canManageLangfuse;
const middlewareCalls = [];
const mockHasConfigCapability = jest.fn(() => Promise.resolve(canManageLangfuse));
const mockRequireJwtAuth = jest.fn((req, _res, next) => {
req.user = { id: 'user-1', role: 'DELEGATED_ADMIN', tenantId: 'tenant-a' };
middlewareCalls.push('jwt');
next();
});
const mockRequireCapability = jest.fn((capability) => (req, res, next) => {
middlewareCalls.push(capability);
if (deniedCapability === capability) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
});
const mockHandlers = {
getConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'get' })),
updateConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'update' })),
testConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'test' })),
};
jest.mock('@librechat/data-schemas', () => ({
SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
}));
jest.mock('@librechat/api', () => ({
createAdminLangfuseHandlers: jest.fn(() => mockHandlers),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: mockRequireCapability,
hasConfigCapability: mockHasConfigCapability,
}));
jest.mock('~/server/middleware', () => ({
requireJwtAuth: mockRequireJwtAuth,
}));
jest.mock('~/server/services/Config', () => ({
invalidateConfigCaches: jest.fn(),
}));
jest.mock('~/models', () => ({
findConfigByPrincipal: jest.fn(),
patchConfigFields: jest.fn(),
toggleConfigActive: jest.fn(),
}));
describe('admin Langfuse routes', () => {
function createApp() {
delete require.cache[require.resolve('./langfuse')];
const router = require('./langfuse');
const app = express();
app.use(express.json());
app.use('/api/admin/langfuse', router);
return app;
}
beforeEach(() => {
deniedCapability = undefined;
canManageLangfuse = true;
middlewareCalls.length = 0;
jest.clearAllMocks();
});
it('requires admin access and Langfuse manage access for connection reads', async () => {
const response = await request(createApp()).get('/api/admin/langfuse/connection').expect(200);
expect(response.body).toEqual({ handler: 'get' });
expect(middlewareCalls).toEqual(['jwt', 'access:admin']);
expect(mockHasConfigCapability).toHaveBeenCalledWith(
{
id: 'user-1',
role: 'DELEGATED_ADMIN',
tenantId: 'tenant-a',
idOnTheSource: null,
},
'langfuse',
);
expect(mockHandlers.getConnection).toHaveBeenCalledTimes(1);
});
it.each([
['PUT', '/api/admin/langfuse/connection', 'updateConnection'],
['POST', '/api/admin/langfuse/connection/test', 'testConnection'],
])('requires Langfuse manage access for %s %s', async (method, path, handlerName) => {
const app = createApp();
const response = await request(app)[method.toLowerCase()](path).send({}).expect(200);
expect(response.body).toEqual({
handler: handlerName === 'updateConnection' ? 'update' : 'test',
});
expect(middlewareCalls).toEqual(['jwt', 'access:admin']);
expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1);
});
it('blocks updates when the user lacks Langfuse manage access', async () => {
canManageLangfuse = false;
await request(createApp()).put('/api/admin/langfuse/connection').send({}).expect(403);
expect(mockHandlers.updateConnection).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,8 @@
const express = require('express');
const {
isEnabled,
isLangfuseConnectionAvailable,
isLangfuseFanoutEnabled,
getBalanceConfig,
getCloudFrontConfig,
getAppConfigOptionsFromUser,
@ -12,7 +14,7 @@ const {
} = require('@librechat/api');
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
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');
@ -249,6 +251,32 @@ router.get('/', async function (req, res) {
const balanceConfig = getBalanceConfig(appConfig);
const cloudFront = buildCloudFrontStartupConfig();
const langfuseFanoutEnabled = isLangfuseFanoutEnabled();
const langfuseConnectionAvailable = isLangfuseConnectionAvailable();
let langfuseConnectionAccess = false;
if (langfuseConnectionAvailable) {
try {
const userId = req.user.id ?? req.user._id?.toString();
if (userId) {
const capabilityUser = {
id: userId,
role: req.user.role ?? '',
tenantId: req.user.tenantId,
idOnTheSource: req.user.idOnTheSource ?? null,
};
const hasAdminAccess = await hasCapability(
capabilityUser,
SystemCapabilities.ACCESS_ADMIN,
);
if (hasAdminAccess) {
langfuseConnectionAccess = await hasConfigCapability(capabilityUser, 'langfuse');
}
}
} catch (err) {
logger.warn(`[config] Langfuse capability check failed: ${err.message}`);
}
}
/** @type {TStartupConfig} */
const payload = {
@ -274,6 +302,8 @@ router.get('/', async function (req, res) {
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
langfuseFanoutEnabled,
langfuseConnectionAccess,
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),

View file

@ -3,6 +3,7 @@ const assistants = require('./assistants');
const categories = require('./categories');
const adminAuth = require('./admin/auth');
const adminConfig = require('./admin/config');
const adminLangfuse = require('./admin/langfuse');
const adminGrants = require('./admin/grants');
const adminGroups = require('./admin/groups');
const adminRoles = require('./admin/roles');
@ -43,6 +44,7 @@ module.exports = {
auth,
adminAuth,
adminConfig,
adminLangfuse,
adminGrants,
adminGroups,
adminRoles,

View file

@ -450,6 +450,8 @@ router.put(
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
sampled: updatedMessage.langfuseSampled,
destinationIds: updatedMessage.langfuseDestinationIds,
feedback: updatedMessage.feedback,
appConfig: req.config,
metadata: {

View file

@ -0,0 +1,3 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M318.645 135.713C324.19 134.927 335.859 136.439 341.271 138.266C357.915 143.886 373.708 155.313 386.944 166.742C390.717 170.001 394.121 173.741 397.477 177.267C403.722 172.469 410.688 166.353 416.534 161.128C420.699 157.403 426.139 152.467 430.026 148.462C432.443 151.311 437.916 156.283 440.769 159.135C443.789 162.156 448.423 167.097 451.5 169.763C449.402 171.778 447.447 173.915 445.275 175.969C435.518 185.19 425.314 193.924 414.699 202.142C416.131 206.071 418.146 209.973 419.706 214.37C430.353 244.871 428.629 278.335 414.908 307.579C419.795 310.665 426.215 315.536 430.819 319.022C437.477 324.065 444.53 329.464 450.667 335.103C448.921 337.113 447.252 339.188 445.487 341.185C440.687 346.619 435.313 352.246 430.732 357.836C427.49 354.691 422.914 351.171 419.367 348.395C412.435 342.887 405.296 337.649 397.965 332.687C385.625 346.319 365.124 362.891 348.068 370.251C342.596 372.66 336.808 374.279 330.882 375.064C302.031 378.828 277.622 361.934 255.928 345.279C252.583 348.26 247.732 351.864 244.093 354.533C229.07 365.547 212.408 375.109 193.414 376.34C191.874 376.511 189.464 376.521 187.906 376.48C181.035 376.28 174.239 374.992 167.77 372.666C160.959 370.213 156.624 367.662 150.563 364.002C136.762 355.669 126.116 347.071 115.035 335.379C112.216 337.285 108.693 340.189 106.015 342.334C97.6272 349.096 89.5081 356.186 81.6766 363.588C75.4441 356.739 67.1357 348.545 60.5 341.889C65.3867 337.837 70.0365 332.931 75.0089 328.771C82.5137 322.495 89.7878 316.218 97.7676 310.514C96.259 307.413 94.477 303.658 93.2329 300.425C84.1343 276.778 83.1019 248.442 89.5507 223.953C91.4597 216.704 93.7938 211.293 96.7651 204.579C85.0205 196.793 71.6203 185.95 61.0223 176.738C67.4755 169.509 75.0828 161.522 81.2234 154.18C91.5695 163.24 102.421 171.705 113.725 179.534L113.802 179.43C115.004 177.825 117.411 175.371 118.839 173.917C128.242 164.342 139.107 156.117 150.461 149.002C154.334 146.575 158.028 144.265 162.19 142.334C167.97 139.624 174.121 137.789 180.44 136.891C210.216 132.858 233.606 150.368 255.762 167.202C258.762 164.539 264.323 160.312 267.535 157.949C283.73 146.037 298.243 137.367 318.645 135.713ZM218.248 317.376C206.132 309.795 193.482 303.647 178.778 304.192C164.821 305.027 152.411 311.148 140.526 318.189C146.179 324.001 153.002 329.474 159.774 333.99C168.428 339.762 177.876 346.066 188.622 346.218C189.974 346.237 191.326 346.175 192.671 346.034C203.988 344.75 216.171 337.48 225.147 330.827C226.887 329.537 229.514 327.7 231.115 326.319C227.291 323.216 222.446 320.002 218.248 317.376ZM371.741 316.575C358.979 309.931 341.321 301.687 326.711 303.34C311.052 304.592 296.858 313.6 284.385 322.596C283.031 323.573 281.725 324.597 280.508 325.746C288.772 332.599 303.008 341.451 313.363 344.113C317.33 345.167 321.449 345.538 325.543 345.211C328.331 344.867 331.96 344.176 334.525 343.039C347.004 337.499 362.29 326.174 371.741 316.575ZM255.812 206.33C237.456 220.819 219.789 233.787 196.191 238.412C169.477 243.646 145.637 233.457 122.679 220.934C113.944 240.121 113.528 268.308 120.906 288.014C121.574 289.812 122.3 291.588 123.082 293.34C146.169 279.069 171.93 269.159 199.388 276.14C219.572 281.272 237.634 292.985 254.053 305.524C254.867 306.146 255.186 306.717 256.11 306.276C278.685 287.401 307.351 270.114 337.691 273.233C355.593 275.072 373.509 283.016 389.103 291.635C390.849 287.216 392.45 283.09 393.56 278.437C398.085 259.475 396.981 238.05 389.47 220.018C383.257 223.589 376.51 227.596 369.945 230.53C350.207 239.353 331.482 242.628 310.265 236.779C293.962 232.284 280.778 224.155 267.135 214.546C265.743 213.566 256.639 206.288 255.812 206.33ZM231.563 186.81C218.569 176.952 202.985 165.548 185.98 166.721C182.759 167.106 178.545 168.087 175.654 169.518C164.876 174.849 147.642 186.944 139.853 195.777C148.947 200.835 158.643 205.167 168.7 207.892C174.445 209.448 180.011 209.765 185.929 209.303C201.698 207.877 219.405 196.835 231.563 186.81ZM321.91 165.701C313.482 166.157 305.902 169.192 298.763 173.559C292.309 177.508 286.018 181.624 280.188 186.46C294.39 197.487 312.553 209.177 330.955 209.414C346.475 209.234 359.256 202.585 372.307 194.769C366.402 187.14 354.741 179.907 346.693 174.55C339.178 169.549 331.17 165.476 321.91 165.701Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -64,7 +64,12 @@ export default function Content({ activeTab, query, ctx }: ContentProps) {
return null;
}
return (
<Section key={section.id} heading={localize(section.labelKey)} danger={section.danger}>
<Section
key={section.id}
heading={localize(section.labelKey)}
icon={section.icon}
danger={section.danger}
>
{entries.map((e) => {
const Cmp = e.Component;
return (

View file

@ -3,19 +3,21 @@ import { cn } from '~/utils';
interface SectionProps {
heading: string;
icon?: ReactNode;
danger?: boolean;
children: ReactNode;
}
export default function Section({ heading, danger, children }: SectionProps) {
export default function Section({ heading, icon, danger, children }: SectionProps) {
return (
<section className="mb-7">
<h3
className={cn(
'mb-2 px-1 text-xs font-semibold uppercase tracking-wide',
'mb-2 flex items-center gap-1.5 px-1 text-xs font-semibold uppercase tracking-wide',
danger ? 'text-red-500' : 'text-text-secondary',
)}
>
{icon}
{heading}
</h3>
<div

View file

@ -18,6 +18,7 @@ const ctx: SettingsContextValue = {
allowAccountDeletion: true,
aboutEnabled: false,
engineTTS: 'browser',
langfuseConnectionAccess: false,
};
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {
@ -46,6 +47,16 @@ describe('Sidebar', () => {
expect(screen.getByText('About')).toBeInTheDocument();
});
it('shows the Langfuse tab when Langfuse is available to the user', () => {
setup({ langfuseConnectionAccess: true });
expect(screen.getByText('Langfuse')).toBeInTheDocument();
});
it('hides the Langfuse tab without Langfuse connection access', () => {
setup({ langfuseConnectionAccess: false });
expect(screen.queryByText('Langfuse')).not.toBeInTheDocument();
});
it('forwards typing to onQueryChange', async () => {
const { onQueryChange } = setup();
await userEvent.type(screen.getByRole('textbox'), 'theme');

View file

@ -1,10 +1,28 @@
import { isValidElementType } from 'react-is';
import { SettingsTabValues } from 'librechat-data-provider';
import type { SettingsContextValue } from '../types';
import en from '~/locales/en/translation.json';
import { registry } from '../registry';
import { TABS } from '../types';
const validTabSections = new Map(TABS.map((t) => [t.id, new Set(t.sections.map((s) => s.id))]));
const settingsContext: SettingsContextValue = {
balanceEnabled: false,
hasAnyPersonalizationFeature: false,
hasMemoryOptOut: false,
hasRemoteAgents: false,
hasUserProvidedEndpoints: false,
hasMultiConvo: false,
hasPrompts: false,
isLocalProvider: true,
twoFactorEnabled: false,
allowAccountDeletion: true,
aboutEnabled: false,
engineTTS: 'browser',
langfuseConnectionAccess: false,
};
describe('settings registry', () => {
it('has unique ids', () => {
const ids = registry.map((e) => e.id);
@ -30,4 +48,42 @@ describe('settings registry', () => {
expect(isValidElementType(entry.Component)).toBe(true);
}
});
describe('Langfuse connection visibility', () => {
const langfuseEntry = registry.find((entry) => entry.id === 'langfuseConnection');
it('places the connection in the Langfuse tab', () => {
expect(langfuseEntry).toMatchObject({
tab: SettingsTabValues.LANGFUSE,
section: 'langfuse',
});
});
it('shows the connection when the user can manage it', () => {
expect(
langfuseEntry?.show?.({
...settingsContext,
langfuseConnectionAccess: true,
}),
).toBe(true);
});
it('hides the connection without Langfuse config access', () => {
expect(
langfuseEntry?.show?.({
...settingsContext,
langfuseConnectionAccess: false,
}),
).toBe(false);
});
it('shows the connection in single-tenant mode without fanout', () => {
expect(
langfuseEntry?.show?.({
...settingsContext,
langfuseConnectionAccess: true,
}),
).toBe(true);
});
});
});

View file

@ -27,6 +27,7 @@ export function useSettingsContext(): SettingsContextValue {
});
const balanceEnabled = startupConfig?.balance?.enabled === true;
const langfuseConnectionAccess = startupConfig?.langfuseConnectionAccess === true;
const isLocalProvider = user?.provider === 'local';
const twoFactorEnabled = user?.twoFactorEnabled === true;
const allowAccountDeletion = startupConfig?.allowAccountDeletion !== false;
@ -51,6 +52,7 @@ export function useSettingsContext(): SettingsContextValue {
allowAccountDeletion,
aboutEnabled,
engineTTS,
langfuseConnectionAccess,
}),
[
balanceEnabled,
@ -65,6 +67,7 @@ export function useSettingsContext(): SettingsContextValue {
allowAccountDeletion,
aboutEnabled,
engineTTS,
langfuseConnectionAccess,
],
);
}

View file

@ -18,6 +18,7 @@ import {
import DisplayUsernameMessages from '../SettingsTabs/Account/DisplayUsernameMessages';
import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitch';
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
import LangfuseConnection from '../SettingsTabs/Integrations/LangfuseConnection';
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
import { toggleControl, ThemeSetting, LangSetting } from './controls';
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
@ -500,6 +501,16 @@ export const registry: SettingEntry[] = [
labelKey: 'com_ui_settings_label_revoke_keys',
Component: RevokeKeys,
},
// Langfuse
{
id: 'langfuseConnection',
tab: SettingsTabValues.LANGFUSE,
section: 'langfuse',
labelKey: 'com_ui_langfuse_title',
keywords: ['langfuse', 'observability', 'tracing', 'telemetry', 'traces'],
show: (ctx) => ctx.langfuseConnectionAccess,
Component: LangfuseConnection,
},
// Data controls · Danger zone
{
id: 'deleteCache',

View file

@ -9,6 +9,7 @@ export type SettingsTab =
| SettingsTabValues.GENERAL
| SettingsTabValues.CHAT
| SettingsTabValues.SPEECH
| SettingsTabValues.LANGFUSE
| SettingsTabValues.DATA
| SettingsTabValues.ACCOUNT
| SettingsTabValues.ABOUT;
@ -27,6 +28,7 @@ export type SectionId =
| 'memory'
| 'data'
| 'apiKeys'
| 'langfuse'
| 'danger'
| 'profile'
| 'security'
@ -46,6 +48,7 @@ export interface SettingsContextValue {
allowAccountDeletion: boolean;
aboutEnabled: boolean;
engineTTS: string;
langfuseConnectionAccess: boolean;
}
export interface SettingEntry {
@ -61,6 +64,7 @@ export interface SettingEntry {
export interface SectionMeta {
id: SectionId;
labelKey: TranslationKeys;
icon?: ReactNode;
danger?: boolean;
}
@ -72,6 +76,17 @@ export interface TabMeta {
show?: (ctx: SettingsContextValue) => boolean;
}
function createLangfuseIcon(className: string): ReactNode {
return createElement('span', {
className: `${className} inline-block shrink-0 bg-current`,
'aria-hidden': true,
style: {
WebkitMask: 'url(/assets/langfuse-icon-monochrome.svg) center / contain no-repeat',
mask: 'url(/assets/langfuse-icon-monochrome.svg) center / contain no-repeat',
},
});
}
export const TABS: TabMeta[] = [
{
id: SettingsTabValues.GENERAL,
@ -104,6 +119,19 @@ export const TABS: TabMeta[] = [
{ id: 'tts', labelKey: 'com_ui_settings_section_tts' },
],
},
{
id: SettingsTabValues.LANGFUSE,
labelKey: 'com_ui_settings_tab_langfuse',
icon: createLangfuseIcon('h-4 w-4'),
sections: [
{
id: 'langfuse',
labelKey: 'com_ui_settings_section_langfuse',
icon: createLangfuseIcon('h-3.5 w-3.5'),
},
],
show: (ctx) => ctx.langfuseConnectionAccess,
},
{
id: SettingsTabValues.DATA,
labelKey: 'com_ui_settings_tab_data',

View file

@ -0,0 +1,613 @@
import { useState, useEffect, useRef } from 'react';
import {
Button,
CircleHelpIcon,
Dropdown,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
Input,
Label,
SecretInput,
Spinner,
useToastContext,
} from '@librechat/client';
import type {
TLangfuseConnectionStatus,
TLangfuseConnectionTestErrorCode,
} from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
useGetLangfuseConnectionQuery,
useUpdateLangfuseConnectionMutation,
useTestLangfuseConnectionMutation,
} from '~/data-provider';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
type ConnectionTestState = 'idle' | 'unverified' | 'checking' | 'connected' | 'failed';
function getStoredConnectionTestKey(status?: TLangfuseConnectionStatus): string | undefined {
if (status?.configured !== true || !status.destination || !status.publicKey) {
return undefined;
}
return [status.destination, status.publicKey].join('\u0000');
}
function getConnectionStatusLabelKey(state: ConnectionTestState): TranslationKeys {
switch (state) {
case 'checking':
return 'com_ui_langfuse_status_checking';
case 'connected':
return 'com_ui_langfuse_status_connected';
case 'failed':
return 'com_ui_langfuse_status_failed';
case 'unverified':
return 'com_ui_langfuse_status_not_verified';
case 'idle':
default:
return 'com_ui_langfuse_status_not_configured';
}
}
function getConnectionTestErrorLabelKey(
errorCode?: TLangfuseConnectionTestErrorCode,
): TranslationKeys {
switch (errorCode) {
case 'invalid_credentials':
return 'com_ui_langfuse_test_invalid_credentials';
case 'access_denied':
return 'com_ui_langfuse_test_access_denied';
case 'rate_limited':
return 'com_ui_langfuse_test_rate_limited';
case 'server_error':
return 'com_ui_langfuse_test_server_error';
case 'timeout':
return 'com_ui_langfuse_test_timeout';
case 'missing_secret':
return 'com_ui_langfuse_test_missing_secret';
case 'stored_secret_unavailable':
return 'com_ui_langfuse_test_stored_secret_unavailable';
case 'unexpected_response':
return 'com_ui_langfuse_test_unexpected_response';
case 'unreachable':
default:
return 'com_ui_langfuse_test_error';
}
}
function getConnectionStatusDotClass(state: ConnectionTestState): string {
switch (state) {
case 'connected':
return 'bg-green-500';
case 'failed':
return 'bg-red-500';
case 'checking':
return 'bg-yellow-500';
case 'idle':
default:
return 'border border-border-medium';
}
}
function getDisplayPublicKey(publicKey: string): string {
const trimmedPublicKey = publicKey.trim();
if (trimmedPublicKey.length <= 12) {
return trimmedPublicKey;
}
return `${trimmedPublicKey.slice(0, 6)}...${trimmedPublicKey.slice(-4)}`;
}
export default function LangfuseConnection() {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
data: status,
isLoading: isConnectionLoading,
isError: isConnectionError,
isFetching: isConnectionFetching,
refetch: refetchConnection,
} = useGetLangfuseConnectionQuery();
const updateMutation = useUpdateLangfuseConnectionMutation();
const testMutation = useTestLangfuseConnectionMutation();
const [connectionStatus, setConnectionStatus] = useState<TLangfuseConnectionStatus>();
const [destination, setDestination] = useState('');
const [publicKey, setPublicKey] = useState('');
const [secretKey, setSecretKey] = useState('');
const [isEditingPublicKey, setIsEditingPublicKey] = useState(false);
const [isEditingSecretKey, setIsEditingSecretKey] = useState(false);
const [connectionTestState, setConnectionTestState] = useState<ConnectionTestState>('idle');
const [connectionTestMessage, setConnectionTestMessage] = useState('');
const autoTestedConnectionRef = useRef<string>();
const connectionTestRequestRef = useRef(0);
const publicKeyInputRef = useRef<HTMLInputElement>(null);
const secretKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditingPublicKey) {
publicKeyInputRef.current?.focus();
}
}, [isEditingPublicKey]);
useEffect(() => {
if (isEditingSecretKey) {
secretKeyInputRef.current?.focus();
}
}, [isEditingSecretKey]);
useEffect(() => {
if (!status) {
return;
}
setConnectionStatus(status);
}, [status]);
useEffect(() => {
if (!connectionStatus) {
return;
}
setDestination(connectionStatus.destination ?? '');
setPublicKey(connectionStatus.publicKey ?? '');
}, [connectionStatus]);
const secretConfigured = connectionStatus?.configured === true;
const destinations = connectionStatus?.destinations ?? [];
const connectionDestinationAvailable = destinations.some(
({ key }) => key === connectionStatus?.destination,
);
const storedDestinationUnavailable =
secretConfigured && Boolean(connectionStatus?.destination) && !connectionDestinationAvailable;
const destinationOptions = [
...(storedDestinationUnavailable && connectionStatus?.destination
? [
{
value: connectionStatus.destination,
label: `${connectionStatus.destination} - ${localize(
'com_ui_langfuse_destination_unavailable',
)}`,
},
]
: []),
...destinations.map(({ key, baseUrl }) => ({
value: key,
label: `${key} - ${baseUrl}`,
})),
];
const trimmedPublicKey = publicKey.trim();
const trimmedSecretKey = secretKey.trim();
const publicKeyInputVisible = !secretConfigured || isEditingPublicKey;
const secretInputVisible = !secretConfigured || isEditingSecretKey;
const displayPublicKey = getDisplayPublicKey(publicKey);
const connectionCredentialsChanged =
destination !== (connectionStatus?.destination ?? '') ||
trimmedPublicKey !== (connectionStatus?.publicKey ?? '');
const hasUnsavedChanges = connectionCredentialsChanged || trimmedSecretKey !== '';
const isEditing =
!secretConfigured || isEditingPublicKey || isEditingSecretKey || hasUnsavedChanges;
const canSubmit =
destination !== '' &&
trimmedPublicKey !== '' &&
((!connectionCredentialsChanged && secretConfigured) || trimmedSecretKey !== '');
const busy = testMutation.isLoading || updateMutation.isLoading;
useEffect(() => {
const storedConnectionTestKey = getStoredConnectionTestKey(connectionStatus);
if (!connectionStatus) {
return;
}
if (!storedConnectionTestKey) {
return;
}
if (!connectionStatus.destinations?.some(({ key }) => key === connectionStatus.destination)) {
connectionTestRequestRef.current += 1;
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_destination_removed'));
return;
}
if (autoTestedConnectionRef.current === storedConnectionTestKey) {
return;
}
autoTestedConnectionRef.current = storedConnectionTestKey;
const requestId = ++connectionTestRequestRef.current;
setConnectionTestState('checking');
testMutation.mutate(
{
destination: connectionStatus.destination ?? '',
publicKey: connectionStatus.publicKey ?? '',
},
{
onSuccess: (result) => {
if (requestId !== connectionTestRequestRef.current) {
return;
}
setConnectionTestState(result.success ? 'connected' : 'failed');
setConnectionTestMessage(
result.success ? '' : localize(getConnectionTestErrorLabelKey(result.errorCode)),
);
},
onError: () => {
if (requestId !== connectionTestRequestRef.current) {
return;
}
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
},
},
);
}, [connectionStatus, localize, testMutation]);
const connectionStatusLabel =
connectionTestState === 'failed' && connectionTestMessage !== ''
? connectionTestMessage
: localize(getConnectionStatusLabelKey(connectionTestState));
const connectionStatusDotClass = getConnectionStatusDotClass(connectionTestState);
const connectionStatusTextClass =
connectionTestState === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-text-secondary';
const connectionStatusTitle =
connectionTestState === 'failed' ? localize('com_ui_langfuse_status_failed_hover') : undefined;
const handleSave = () => {
const payload = {
enabled: true,
destination,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
};
connectionTestRequestRef.current += 1;
updateMutation.mutate(payload, {
onSuccess: (nextStatus) => {
autoTestedConnectionRef.current = getStoredConnectionTestKey(nextStatus);
setConnectionStatus(nextStatus);
setConnectionTestState('connected');
setConnectionTestMessage('');
setSecretKey('');
setIsEditingPublicKey(false);
setIsEditingSecretKey(false);
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
},
onError: () => {
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_save_error'));
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' });
},
});
};
const handleCancel = () => {
const storedDestination = connectionStatus?.destination;
setDestination(storedDestination ?? '');
setPublicKey(connectionStatus?.publicKey ?? '');
setSecretKey('');
setIsEditingPublicKey(false);
setIsEditingSecretKey(false);
if (!storedDestination || !connectionStatus?.publicKey) {
setConnectionTestState('idle');
setConnectionTestMessage('');
return;
}
if (!connectionStatus.destinations?.some(({ key }) => key === storedDestination)) {
connectionTestRequestRef.current += 1;
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_destination_removed'));
return;
}
const requestId = ++connectionTestRequestRef.current;
setConnectionTestState('checking');
setConnectionTestMessage('');
testMutation.mutate(
{ destination: storedDestination, publicKey: connectionStatus.publicKey },
{
onSuccess: (result) => {
if (requestId !== connectionTestRequestRef.current) return;
setConnectionTestState(result.success ? 'connected' : 'failed');
setConnectionTestMessage(
result.success ? '' : localize(getConnectionTestErrorLabelKey(result.errorCode)),
);
},
onError: () => {
if (requestId !== connectionTestRequestRef.current) return;
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
},
},
);
};
const handleDestinationChange = (nextDestination: string) => {
setDestination(nextDestination);
const requestId = ++connectionTestRequestRef.current;
const credentialsChanged =
nextDestination !== (connectionStatus?.destination ?? '') ||
trimmedPublicKey !== (connectionStatus?.publicKey ?? '');
if (secretConfigured && credentialsChanged) {
setIsEditingSecretKey(true);
}
if (
nextDestination === '' ||
trimmedPublicKey === '' ||
((!secretConfigured || credentialsChanged) && trimmedSecretKey === '')
) {
setConnectionTestState(credentialsChanged ? 'unverified' : 'idle');
setConnectionTestMessage('');
return;
}
setConnectionTestState('checking');
setConnectionTestMessage('');
testMutation.mutate(
{
destination: nextDestination,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
},
{
onSuccess: (result) => {
if (requestId !== connectionTestRequestRef.current) {
return;
}
setConnectionTestState(result.success ? 'connected' : 'failed');
setConnectionTestMessage(
result.success ? '' : localize(getConnectionTestErrorLabelKey(result.errorCode)),
);
},
onError: () => {
if (requestId !== connectionTestRequestRef.current) {
return;
}
setConnectionTestState('failed');
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
},
},
);
};
const handleEnabledChange = () => {
if (!secretConfigured || !connectionStatus?.destination || !connectionStatus.publicKey) {
return;
}
const nextEnabled = connectionStatus.enabled !== true;
const requestId = ++connectionTestRequestRef.current;
const saveEnabledState = () => {
updateMutation.mutate(
{
enabled: nextEnabled,
destination: connectionStatus.destination ?? '',
publicKey: connectionStatus.publicKey ?? '',
},
{
onSuccess: (nextStatus) => {
if (requestId !== connectionTestRequestRef.current) {
return;
}
autoTestedConnectionRef.current = getStoredConnectionTestKey(nextStatus);
setConnectionStatus(nextStatus);
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
},
onError: () => {
if (requestId !== connectionTestRequestRef.current) {
return;
}
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' });
},
},
);
};
saveEnabledState();
};
if (isConnectionLoading && connectionStatus == null) {
return (
<div
data-testid="langfuse-connection-loading"
className="flex items-center justify-center rounded-xl border border-border-light py-12"
>
<Spinner className="h-6 w-6 text-text-secondary" />
<span className="sr-only">{localize('com_ui_loading')}</span>
</div>
);
}
if (isConnectionError && connectionStatus == null) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-border-light px-6 py-10 text-center">
<p className="text-sm text-text-secondary">{localize('com_ui_langfuse_load_error')}</p>
<Button
variant="outline"
size="sm"
onClick={() => refetchConnection()}
disabled={isConnectionFetching}
>
{localize('com_ui_retry')}
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<HoverCard openDelay={50}>
<div className="flex flex-col gap-2">
<div className="flex items-start gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{localize('com_ui_langfuse_title')}</div>
<div className="rounded-full border border-purple-600/40 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-700 hover:bg-purple-700/10 dark:text-purple-400">
{localize('com_ui_beta')}
</div>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<div className="mt-1 max-w-md text-xs text-text-secondary">
{localize('com_ui_langfuse_description')}
</div>
</div>
</div>
<div
className={`flex items-center gap-1.5 text-xs ${connectionStatusTextClass}`}
aria-live="polite"
title={connectionStatusTitle}
>
{connectionTestState === 'checking' ? (
<Spinner className="h-3 w-3" />
) : (
<span className={`h-2 w-2 rounded-full ${connectionStatusDotClass}`} />
)}
<span>{connectionStatusLabel}</span>
</div>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<p className="text-sm text-text-secondary">{localize('com_ui_langfuse_beta_info')}</p>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
<div className="flex flex-col gap-1.5">
<Label id="langfuse-destination-label">{localize('com_ui_langfuse_destination')}</Label>
<Dropdown
value={destination}
label={destination === '' ? localize('com_ui_select') : ''}
onChange={handleDestinationChange}
options={destinationOptions}
disabled={destinations.length === 0 || busy}
className="w-full"
sizeClasses="z-50 w-[var(--popover-anchor-width)]"
testId="langfuse-destination"
aria-labelledby="langfuse-destination-label"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-public-token">{localize('com_ui_langfuse_public_key')}</Label>
{secretConfigured && !isEditingPublicKey && (
<button
type="button"
className="w-full rounded-lg border border-border-light px-3 py-2 text-left hover:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_public_key')}`}
disabled={busy}
onClick={() => setIsEditingPublicKey(true)}
>
<code className="block min-w-0 truncate font-mono text-sm text-text-primary">
{displayPublicKey}
</code>
</button>
)}
{publicKeyInputVisible && (
<Input
ref={publicKeyInputRef}
id="langfuse-public-token"
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
value={publicKey}
disabled={busy}
placeholder="pk-lf-..."
onChange={(e) => {
connectionTestRequestRef.current += 1;
const nextPublicKey = e.target.value;
setPublicKey(nextPublicKey);
if (
secretConfigured &&
nextPublicKey.trim() !== (connectionStatus?.publicKey ?? '')
) {
setIsEditingSecretKey(true);
}
setConnectionTestState('unverified');
setConnectionTestMessage('');
}}
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-private-token">{localize('com_ui_langfuse_secret_key')}</Label>
{secretConfigured && !isEditingSecretKey && (
<button
type="button"
className="w-full rounded-lg border border-border-light px-3 py-2 text-left hover:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_secret_key')}`}
disabled={busy}
onClick={() => setIsEditingSecretKey(true)}
>
<code className="block min-w-0 truncate font-mono text-sm text-text-primary">
{connectionStatus?.secretKeyPreview}
</code>
</button>
)}
{secretInputVisible && (
<SecretInput
ref={secretKeyInputRef}
id="langfuse-private-token"
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
value={secretKey}
disabled={busy}
placeholder="sk-lf-..."
onChange={(e) => {
connectionTestRequestRef.current += 1;
setSecretKey(e.target.value);
setConnectionTestState('unverified');
setConnectionTestMessage('');
}}
/>
)}
</div>
<div className="flex min-h-9 items-center justify-end gap-2">
{isEditing ? (
<>
<Button variant="outline" disabled={busy} onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
<Button disabled={!canSubmit || busy} onClick={handleSave}>
{testMutation.isLoading ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4" />
{localize('com_ui_langfuse_testing')}
</span>
) : (
localize('com_ui_langfuse_save_and_enable')
)}
</Button>
</>
) : (
<Button
variant={connectionStatus?.enabled === true ? 'outline' : 'submit'}
disabled={
busy || (connectionStatus?.enabled !== true && !connectionDestinationAvailable)
}
onClick={handleEnabledChange}
>
{localize(
connectionStatus?.enabled === true
? 'com_ui_langfuse_disable'
: 'com_ui_langfuse_enable',
)}
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,619 @@
import userEvent from '@testing-library/user-event';
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import LangfuseConnection from '../LangfuseConnection';
const mockGet = jest.fn();
const mockUpdate = jest.fn();
const mockTest = jest.fn();
const mockRefetch = jest.fn();
const destinationLabels = {
eu: 'eu - https://cloud.langfuse.com',
us: 'us - https://us.cloud.langfuse.com',
};
async function selectDestination(destination: keyof typeof destinationLabels) {
await userEvent.click(screen.getByTestId('langfuse-destination'));
await userEvent.click(screen.getByRole('option', { name: destinationLabels[destination] }));
}
jest.mock('~/data-provider', () => ({
useGetLangfuseConnectionQuery: () => mockGet(),
useUpdateLangfuseConnectionMutation: () => ({ mutate: mockUpdate, isLoading: false }),
useTestLangfuseConnectionMutation: () => ({ mutate: mockTest, isLoading: false }),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useToastContext: () => ({ showToast: jest.fn() }),
}));
beforeEach(() => {
global.ResizeObserver = class MockedResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
mockGet.mockReset();
mockUpdate.mockReset();
mockTest.mockReset();
mockRefetch.mockReset();
mockTest.mockImplementation((_payload, options) => {
options?.onSuccess?.({ success: true });
});
mockGet.mockReturnValue({
isLoading: false,
isError: false,
isFetching: false,
refetch: mockRefetch,
data: {
configured: false,
enabled: false,
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
},
});
});
describe('LangfuseConnection', () => {
it('renders the connection form fields', () => {
render(<LangfuseConnection />);
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent('com_ui_select');
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
'data-lpignore',
'true',
);
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
'data-1p-ignore',
'true',
);
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
'data-form-type',
'other',
);
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
'data-bwignore',
'true',
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
'data-lpignore',
'true',
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
'data-1p-ignore',
'true',
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
'data-form-type',
'other',
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
'data-bwignore',
'true',
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
'autocomplete',
'off',
);
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute('type', 'password');
expect(screen.getByRole('button', { name: 'Show secret' })).toBeInTheDocument();
expect(screen.queryByText('com_ui_langfuse_test')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_langfuse_status_not_configured')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'com_ui_cancel' })).toBeVisible();
expect(screen.getByRole('button', { name: 'com_ui_langfuse_save_and_enable' })).toBeVisible();
expect(
screen.queryByRole('button', { name: 'com_ui_langfuse_enable' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'com_ui_langfuse_disable' }),
).not.toBeInTheDocument();
expect(mockTest).not.toHaveBeenCalled();
});
it('renders a loading state while the stored connection is loading', () => {
mockGet.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
isFetching: true,
refetch: mockRefetch,
});
render(<LangfuseConnection />);
expect(screen.getByTestId('langfuse-connection-loading')).toBeVisible();
expect(screen.getByText('com_ui_loading')).toBeInTheDocument();
expect(screen.queryByText('com_ui_langfuse_status_not_configured')).not.toBeInTheDocument();
});
it('renders a retryable error when the stored connection cannot be loaded', async () => {
mockGet.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
isFetching: false,
refetch: mockRefetch,
});
render(<LangfuseConnection />);
expect(screen.getByText('com_ui_langfuse_load_error')).toBeVisible();
expect(screen.queryByText('com_ui_langfuse_status_not_configured')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
it.each([
{
credential: 'public key',
editButton: 'com_ui_edit com_ui_langfuse_public_key',
inputLabel: 'com_ui_langfuse_public_key',
value: 'pk-lf-updated',
},
{
credential: 'secret key',
editButton: 'com_ui_edit com_ui_langfuse_secret_key',
inputLabel: 'com_ui_langfuse_secret_key',
value: 'sk-lf-updated',
},
])(
'keeps edited $credential unverified when an earlier automatic test completes',
async ({ editButton, inputLabel, value }) => {
let completeTest: ((result: { success: boolean }) => void) | undefined;
mockTest.mockImplementation((_payload, options) => {
completeTest = options?.onSuccess;
});
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-original',
secretKeyPreview: 'sk-lf-...inal',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
await userEvent.click(screen.getByRole('button', { name: editButton }));
fireEvent.change(screen.getByLabelText(inputLabel), { target: { value } });
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
act(() => completeTest?.({ success: true }));
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
expect(screen.queryByText('com_ui_langfuse_status_connected')).not.toBeInTheDocument();
},
);
it('prefills stored values, tests on load, and keeps destination editable', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
destination: 'us',
publicKey: 'pk-lf-12345678-515f',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent(destinationLabels.us);
expect(screen.queryByLabelText('com_ui_langfuse_public_key')).not.toBeInTheDocument();
expect(screen.getByText('pk-lf-...515f')).toBeInTheDocument();
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
expect(screen.getByText('sk-lf-...515f')).toBeInTheDocument();
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
expect(screen.getByTestId('langfuse-destination')).toBeEnabled();
expect(screen.getByRole('button', { name: 'com_ui_langfuse_disable' })).toBeEnabled();
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
expect(mockTest.mock.calls[0][0]).toEqual({
destination: 'us',
publicKey: 'pk-lf-12345678-515f',
});
expect(screen.getByText('com_ui_langfuse_status_connected')).toBeInTheDocument();
});
it('shows a failed saved-connection status when the load-time test fails', async () => {
mockTest.mockImplementation((_payload, options) => {
options?.onSuccess?.({ success: false, errorCode: 'invalid_credentials' });
});
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() =>
expect(screen.getByText('com_ui_langfuse_test_invalid_credentials')).toBeInTheDocument(),
);
expect(
screen.getByText('com_ui_langfuse_test_invalid_credentials').closest('div'),
).toHaveAttribute('title', 'com_ui_langfuse_status_failed_hover');
});
it('saves the typed secret key without a duplicate preflight test', async () => {
render(<LangfuseConnection />);
await selectDestination('us');
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-1' },
});
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-secret' },
});
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toEqual({
enabled: true,
destination: 'us',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
});
});
it('shows the display secret key immediately after saving a new connection', async () => {
mockUpdate.mockImplementation((_payload, options) => {
options?.onSuccess?.({
configured: true,
enabled: true,
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
destination: 'us',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...cret',
});
});
render(<LangfuseConnection />);
await selectDestination('us');
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-1' },
});
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-secret' },
});
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
expect(mockTest).not.toHaveBeenCalled();
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
expect(screen.getByText('sk-lf-...cret')).toBeInTheDocument();
});
it('requires secret re-entry before saving a destination change', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
await selectDestination('us');
expect(mockTest).not.toHaveBeenCalled();
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toBeVisible();
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeInTheDocument();
expect(screen.getByText('com_ui_langfuse_save_and_enable')).toBeDisabled();
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-replacement' },
});
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
destination: 'us',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-replacement',
});
});
it('opens each configured key independently when its masked value is clicked', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', {
name: 'com_ui_edit com_ui_langfuse_public_key',
}),
);
expect(screen.getByRole('button', { name: 'com_ui_cancel' })).toBeVisible();
expect(screen.getByRole('button', { name: 'com_ui_langfuse_save_and_enable' })).toBeVisible();
expect(
screen.queryByRole('button', { name: 'com_ui_langfuse_disable' }),
).not.toBeInTheDocument();
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-1');
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveFocus();
expect(
screen.queryByLabelText(/com_ui_langfuse_secret_key/, { selector: 'input' }),
).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', {
name: 'com_ui_edit com_ui_langfuse_secret_key',
}),
);
const secretKeyInput = screen.getByLabelText(/com_ui_langfuse_secret_key/);
expect(secretKeyInput).toHaveValue('');
expect(secretKeyInput).toHaveClass('w-full');
expect(secretKeyInput).toHaveFocus();
fireEvent.change(secretKeyInput, {
target: { value: 'sk-lf-replacement' },
});
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-replacement',
});
});
it('restores the stored connection when editing is cancelled', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
],
destination: 'eu',
publicKey: 'pk-lf-original',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
await selectDestination('us');
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
await userEvent.click(
screen.getByRole('button', {
name: 'com_ui_edit com_ui_langfuse_public_key',
}),
);
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-edited' },
});
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-edited' },
});
mockTest.mockImplementationOnce((_payload, options) => {
options?.onSuccess?.({ success: true });
});
await userEvent.click(screen.getByRole('button', { name: 'com_ui_cancel' }));
expect(await screen.findByText('com_ui_langfuse_status_connected')).toBeVisible();
expect(mockTest.mock.calls.at(-1)?.[0]).toEqual({
destination: 'eu',
publicKey: 'pk-lf-original',
});
expect(screen.getByRole('button', { name: 'com_ui_langfuse_disable' })).toBeEnabled();
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent(destinationLabels.eu);
expect(screen.getByText('pk-lf-...inal')).toBeInTheDocument();
expect(screen.getByText('sk-lf-...515f')).toBeInTheDocument();
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
expect(mockUpdate).not.toHaveBeenCalled();
});
it('shows a save failure when mandatory server verification rejects the connection', async () => {
mockUpdate.mockImplementation((_payload, options) => {
options?.onError?.();
});
render(<LangfuseConnection />);
await selectDestination('us');
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-1' },
});
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-secret' },
});
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(screen.getByText('com_ui_langfuse_save_error')).toBeVisible();
});
it('replaces a connected status with a failure when an edited public key is rejected', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-valid',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(screen.getByText('com_ui_langfuse_status_connected')).toBeVisible());
mockTest.mockClear();
mockUpdate.mockImplementation((_payload, options) => {
options?.onError?.();
});
await userEvent.click(
screen.getByRole('button', {
name: 'com_ui_edit com_ui_langfuse_public_key',
}),
);
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
target: { value: 'pk-lf-mangled' },
});
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
target: { value: 'sk-lf-replacement' },
});
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
publicKey: 'pk-lf-mangled',
secretKey: 'sk-lf-replacement',
}),
expect.any(Object),
);
expect(screen.getByText('com_ui_langfuse_save_error')).toBeVisible();
});
it('saves immediately without testing when disabling a configured connection', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
mockUpdate.mockImplementation((_payload, options) => {
options?.onSuccess?.({
configured: true,
enabled: false,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
updatedAt: '2026-07-10T15:30:00.000Z',
});
});
await userEvent.click(screen.getByRole('button', { name: 'com_ui_langfuse_disable' }));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
enabled: false,
destination: 'eu',
publicKey: 'pk-lf-1',
});
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'com_ui_langfuse_enable' })).toBeEnabled();
});
it('allows disabling a connection whose saved destination was removed', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'removed-destination',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
},
});
render(<LangfuseConnection />);
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent(
'removed-destination - com_ui_langfuse_destination_unavailable',
);
expect(screen.getByText('com_ui_langfuse_destination_removed')).toBeVisible();
expect(mockTest).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole('button', { name: 'com_ui_langfuse_disable' }));
expect(mockUpdate).toHaveBeenCalledWith(
{
enabled: false,
destination: 'removed-destination',
publicKey: 'pk-lf-1',
},
expect.any(Object),
);
});
it('saves immediately without testing when enabling a configured connection', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: false,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
},
});
mockUpdate.mockImplementation((_payload, options) => {
options?.onSuccess?.({
configured: true,
enabled: true,
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf-...515f',
updatedAt: '2026-07-10T15:31:00.000Z',
});
});
render(<LangfuseConnection />);
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
mockTest.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'com_ui_langfuse_enable' }));
expect(mockTest).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith(
{ enabled: true, destination: 'eu', publicKey: 'pk-lf-1' },
expect.any(Object),
);
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'com_ui_langfuse_disable' })).toBeEnabled();
});
});

View file

@ -0,0 +1,45 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys, MutationKeys } from 'librechat-data-provider';
import type {
TLangfuseConnectionStatus,
TUpdateLangfuseConnectionRequest,
TLangfuseConnectionTestRequest,
TLangfuseConnectionTestResponse,
} from 'librechat-data-provider';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
export const useGetLangfuseConnectionQuery = (
enabled = true,
): UseQueryResult<TLangfuseConnectionStatus> =>
useQuery<TLangfuseConnectionStatus>(
[QueryKeys.langfuseConnection],
() => dataService.getLangfuseConnection(),
{ enabled, refetchOnWindowFocus: false },
);
export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
TLangfuseConnectionStatus,
unknown,
TUpdateLangfuseConnectionRequest
> => {
const queryClient = useQueryClient();
return useMutation(
(payload: TUpdateLangfuseConnectionRequest) => dataService.updateLangfuseConnection(payload),
{
mutationKey: [MutationKeys.updateLangfuseConnection],
onSuccess: (data) => {
queryClient.setQueryData([QueryKeys.langfuseConnection], data);
},
},
);
};
export const useTestLangfuseConnectionMutation = (): UseMutationResult<
TLangfuseConnectionTestResponse,
unknown,
TLangfuseConnectionTestRequest
> =>
useMutation(
(payload: TLangfuseConnectionTestRequest) => dataService.testLangfuseConnection(payload),
{ mutationKey: [MutationKeys.testLangfuseConnection] },
);

View file

@ -3,6 +3,7 @@ export * from './Agents';
export * from './Endpoints';
export * from './Skills';
export * from './Files';
export * from './Langfuse';
/* Memories */
export * from './Memories';
export * from './Messages';

View file

@ -1730,6 +1730,36 @@
"com_ui_settings_search_placeholder": "Search settings",
"com_ui_settings_section_accessibility": "Accessibility",
"com_ui_settings_section_api_keys": "API keys",
"com_ui_langfuse_title": "Langfuse connection",
"com_ui_langfuse_description": "Send this organization's traces and feedback scores to your own Langfuse project.",
"com_ui_langfuse_beta_info": "This feature is in beta. Enabling this connection will send traces from all agents in your org to Langfuse.",
"com_ui_langfuse_destination": "Destination",
"com_ui_langfuse_destination_unavailable": "Unavailable",
"com_ui_langfuse_destination_removed": "The saved Langfuse destination is no longer available. Disable the connection or select another destination.",
"com_ui_langfuse_public_key": "Public key",
"com_ui_langfuse_secret_key": "Secret key",
"com_ui_langfuse_status_checking": "Checking connection",
"com_ui_langfuse_status_connected": "Verified with Langfuse",
"com_ui_langfuse_status_failed": "Connection failed",
"com_ui_langfuse_status_failed_hover": "Check Langfuse to see if traces are still failing. A one-time ping with the keys just failed.",
"com_ui_langfuse_status_not_configured": "Not configured",
"com_ui_langfuse_status_not_verified": "Not verified",
"com_ui_langfuse_testing": "Testing connection",
"com_ui_langfuse_save_and_enable": "Save & enable",
"com_ui_langfuse_enable": "Enable",
"com_ui_langfuse_disable": "Disable",
"com_ui_langfuse_saved": "Langfuse connection saved",
"com_ui_langfuse_load_error": "Failed to load the Langfuse connection",
"com_ui_langfuse_save_error": "Failed to save the Langfuse connection",
"com_ui_langfuse_test_error": "Could not connect to Langfuse",
"com_ui_langfuse_test_invalid_credentials": "Langfuse rejected these keys. Check the destination and keys",
"com_ui_langfuse_test_access_denied": "Langfuse denied access. Check the API key type and project status.",
"com_ui_langfuse_test_rate_limited": "Langfuse is rate limiting verification. Try again later.",
"com_ui_langfuse_test_server_error": "Langfuse is returning server errors. This may be a Langfuse incident.",
"com_ui_langfuse_test_timeout": "Langfuse verification timed out",
"com_ui_langfuse_test_missing_secret": "A secret key is required to test the connection",
"com_ui_langfuse_test_stored_secret_unavailable": "The stored secret key could not be used",
"com_ui_langfuse_test_unexpected_response": "Langfuse returned an unexpected response",
"com_ui_settings_section_appearance": "Appearance",
"com_ui_settings_section_billing": "Billing",
"com_ui_settings_section_commands": "Commands",
@ -1745,6 +1775,8 @@
"com_ui_settings_section_sending": "Sending",
"com_ui_settings_section_stt": "Speech to text",
"com_ui_settings_section_tts": "Text to speech",
"com_ui_settings_section_langfuse": "Langfuse",
"com_ui_settings_tab_langfuse": "Langfuse",
"com_ui_settings_tab_data": "Data & Privacy",
"com_ui_share": "Share",
"com_ui_share_create_message": "Your name and any messages you add after sharing stay private.",

View file

@ -457,73 +457,17 @@ describe('createAdminConfigHandlers', () => {
expect(savedOverrides.interface).toEqual({ modelSelect: false });
});
it('encrypts Langfuse secret keys on full override writes', async () => {
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const { handlers, deps } = createHandlers({
upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({
_id: 'c1',
configVersion: 1,
overrides,
})),
});
it('does not allow tenant-wide Langfuse settings through the generic config API', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
params: { principalType: 'role', principalId: '__base__' },
body: {
overrides: {
langfuse: {
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
},
},
},
});
const res = mockRes();
await handlers.upsertConfigOverrides(req, res);
expect(res.statusCode).toBe(201);
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
expect(savedOverrides.langfuse.secretKey).toMatch(/^v3:/);
expect(savedOverrides.langfuse.secretKey).not.toBe('sk-lf-secret');
expect(savedOverrides.langfuse.secretKeyPreview).toBe('sk-lf-...cret');
const responseConfig = res.body!.config as {
overrides: { langfuse: Record<string, string> };
};
expect(responseConfig.overrides.langfuse).toEqual({
publicKey: 'pk-lf-1',
secretKeyPreview: savedOverrides.langfuse.secretKeyPreview,
});
});
it('preserves existing encrypted Langfuse secrets on full override writes when omitted', async () => {
const existing = {
_id: 'c1',
priority: 7,
overrides: {
langfuse: {
publicKey: 'pk-old',
secretKey: 'v3:test:sk-old',
secretKeyPreview: 'sk-old...-old',
},
},
};
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(existing),
upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({
_id: 'c1',
configVersion: 2,
overrides,
})),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
overrides: {
langfuse: {
publicKey: 'pk-new',
destination: 'eu',
enabled: false,
publicKey: 'pk-role',
},
'langfuse.secretKey': 'sk-role',
},
},
});
@ -532,33 +476,23 @@ describe('createAdminConfigHandlers', () => {
await handlers.upsertConfigOverrides(req, res);
expect(res.statusCode).toBe(200);
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
expect(savedOverrides.langfuse).toEqual({
publicKey: 'pk-new',
destination: 'eu',
secretKey: 'v3:test:sk-old',
secretKeyPreview: 'sk-old...-old',
});
const responseConfig = res.body!.config as {
overrides: { langfuse: Record<string, string> };
};
expect(responseConfig.overrides.langfuse).toEqual({
publicKey: 'pk-new',
destination: 'eu',
secretKeyPreview: 'sk-old...-old',
});
expect(res.body).toEqual({ message: 'No actionable override sections provided' });
expect(deps.upsertConfig).not.toHaveBeenCalled();
});
it('clears existing Langfuse secrets on full override writes when explicitly empty', async () => {
it('preserves stored Langfuse settings during a full base-config replacement', async () => {
const storedLangfuse = {
enabled: true,
destination: 'eu',
publicKey: 'pk-stored',
secretKey: 'v3:test:sk-stored',
secretKeyPreview: 'sk-sto...ored',
projectId: 'project-stored',
};
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue({
_id: 'c1',
overrides: {
langfuse: {
secretKey: 'v3:test:sk-old',
secretKeyPreview: 'sk-old...-old',
},
},
overrides: { langfuse: storedLangfuse },
}),
upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({
_id: 'c1',
@ -567,12 +501,14 @@ describe('createAdminConfigHandlers', () => {
})),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
params: { principalType: 'role', principalId: '__base__' },
body: {
overrides: {
interface: { modelSelect: false },
langfuse: {
publicKey: 'pk-new',
secretKey: '',
enabled: false,
publicKey: 'pk-caller',
projectId: 'project-caller',
},
},
},
@ -583,66 +519,9 @@ describe('createAdminConfigHandlers', () => {
expect(res.statusCode).toBe(200);
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
expect(savedOverrides.langfuse).toEqual({
publicKey: 'pk-new',
secretKey: '',
secretKeyPreview: '',
});
});
it('rejects encrypted Langfuse secret values on full override writes', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
overrides: {
langfuse: {
publicKey: 'pk-lf-1',
secretKey: 'v3:attacker-controlled',
},
},
},
});
const res = mockRes();
await handlers.upsertConfigOverrides(req, res);
expect(res.statusCode).toBe(400);
expect(deps.upsertConfig).not.toHaveBeenCalled();
});
it('does not persist literal dotted Langfuse secret keys on full override writes', async () => {
const { handlers, deps } = createHandlers({
upsertConfig: jest.fn(async (_type, _id, _model, overrides) => ({
_id: 'c1',
configVersion: 1,
overrides,
})),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
overrides: {
'langfuse.secretKey': 'sk-lf-secret',
'langfuse.secretKeyPreview': 'spoofed',
langfuse: { publicKey: 'pk-lf-1' },
},
},
});
const res = mockRes();
await handlers.upsertConfigOverrides(req, res);
expect(res.statusCode).toBe(201);
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
expect(savedOverrides).not.toHaveProperty('langfuse.secretKey');
expect(savedOverrides).not.toHaveProperty('langfuse.secretKeyPreview');
expect(savedOverrides.langfuse).toEqual({ publicKey: 'pk-lf-1' });
const responseConfig = res.body!.config as {
overrides: { langfuse: Record<string, string> };
};
expect(responseConfig.overrides).toEqual({
langfuse: { publicKey: 'pk-lf-1' },
expect(savedOverrides).toEqual({
interface: { modelSelect: false },
langfuse: storedLangfuse,
});
});
@ -885,23 +764,19 @@ describe('createAdminConfigHandlers', () => {
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect');
});
it('also deletes the display secret key companion when deleting a secret field', async () => {
it('ignores tenant-wide Langfuse deletes through the generic config API', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
query: { fieldPath: 'langfuse.secretKey' },
params: { principalType: 'role', principalId: '__base__' },
query: { fieldPath: 'langfuse.enabled' },
});
const res = mockRes();
await handlers.deleteConfigField(req, res);
expect(res.statusCode).toBe(200);
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'langfuse.secretKey');
expect(deps.unsetConfigField).toHaveBeenCalledWith(
'role',
'admin',
'langfuse.secretKeyPreview',
);
expect(res.body).toEqual({ message: 'No actionable field path provided' });
expect(deps.unsetConfigField).not.toHaveBeenCalled();
});
it('rejects deletes of the displayed secret key', async () => {
@ -988,31 +863,19 @@ describe('createAdminConfigHandlers', () => {
);
});
it('also tombstones the display secret key companion when tombstoning a secret field', async () => {
it('ignores tenant-wide Langfuse tombstones through the generic config API', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'langfuse.secretKey' },
params: { principalType: 'role', principalId: '__base__' },
body: { fieldPath: 'langfuse.enabled' },
});
const res = mockRes();
await handlers.tombstoneConfigField(req, res);
expect(res.statusCode).toBe(200);
expect(deps.tombstoneConfigField).toHaveBeenCalledWith(
'role',
'admin',
expect.anything(),
'langfuse.secretKey',
10,
);
expect(deps.tombstoneConfigField).toHaveBeenCalledWith(
'role',
'admin',
expect.anything(),
'langfuse.secretKeyPreview',
10,
);
expect(res.body).toEqual({ message: 'No actionable field path provided' });
expect(deps.tombstoneConfigField).not.toHaveBeenCalled();
});
it('rejects tombstones of the displayed secret key', async () => {
@ -1117,135 +980,6 @@ describe('createAdminConfigHandlers', () => {
expect(patchedFields['interface.modelSelect']).toBe(false);
});
it('clears stale Langfuse secret previews when clearing a secret', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [{ fieldPath: 'langfuse.secretKey', value: '' }],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(200);
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
expect(patchedFields['langfuse.secretKey']).toBe('');
expect(patchedFields['langfuse.secretKeyPreview']).toBe('');
});
it('encrypts Langfuse secret keys inside object-valued patch entries', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [
{
fieldPath: 'langfuse',
value: {
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
},
},
],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(200);
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
expect(patchedFields.langfuse.secretKey).toMatch(/^v3:/);
expect(patchedFields.langfuse.secretKey).not.toBe('sk-lf-secret');
expect(patchedFields.langfuse.secretKeyPreview).toBe('sk-lf-...cret');
});
it('preserves existing encrypted Langfuse secrets on object-valued patch entries when omitted', async () => {
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue({
_id: 'c1',
priority: 7,
overrides: {
langfuse: {
publicKey: 'pk-old',
secretKey: 'v3:test:sk-old',
secretKeyPreview: 'sk-old...-old',
},
},
}),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
priority: 12,
entries: [
{
fieldPath: 'langfuse',
value: {
publicKey: 'pk-new',
destination: 'eu',
},
},
],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(200);
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
expect(patchedFields.langfuse).toEqual({
publicKey: 'pk-new',
destination: 'eu',
secretKey: 'v3:test:sk-old',
secretKeyPreview: 'sk-old...-old',
});
expect(deps.findConfigByPrincipal).toHaveBeenCalled();
});
it('clears existing Langfuse secrets on object-valued patch entries when explicitly empty', async () => {
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue({
_id: 'c1',
priority: 7,
overrides: {
langfuse: {
secretKey: 'v3:test:sk-old',
secretKeyPreview: 'sk-old...-old',
},
},
}),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [
{
fieldPath: 'langfuse',
value: {
publicKey: 'pk-new',
secretKey: '',
},
},
],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(200);
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
expect(patchedFields.langfuse).toEqual({
publicKey: 'pk-new',
secretKey: '',
secretKeyPreview: '',
});
});
it('rejects array-valued Langfuse secret ancestors', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
@ -1267,12 +1001,12 @@ describe('createAdminConfigHandlers', () => {
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('does not store non-string values at Langfuse secret paths', async () => {
it('does not allow tenant-wide Langfuse patches through the generic config API', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
params: { principalType: 'role', principalId: '__base__' },
body: {
entries: [{ fieldPath: 'langfuse.secretKey', value: { hidden: 'sk-lf-secret' } }],
entries: [{ fieldPath: 'langfuse.enabled', value: false }],
},
});
const res = mockRes();
@ -1280,9 +1014,8 @@ describe('createAdminConfigHandlers', () => {
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(200);
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
expect(patchedFields['langfuse.secretKey']).toBe('');
expect(patchedFields['langfuse.secretKeyPreview']).toBe('');
expect(res.body).toEqual({ message: 'No actionable field entries provided' });
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects direct display secret key patch entries', async () => {

View file

@ -1,5 +1,6 @@
import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
import {
BASE_PRINCIPAL_CONFIG_SECTIONS,
BASE_ONLY_CONFIG_SECTIONS,
PrincipalType,
PrincipalModel,
@ -29,6 +30,7 @@ const UNSAFE_SEGMENTS = /(?:^|\.)(__[\w]*|constructor|prototype)(?:\.|$)/;
const MAX_PATCH_ENTRIES = 100;
const DEFAULT_PRIORITY = 10;
const BASE_ONLY_OVERRIDE_SECTIONS = new Set<string>(BASE_ONLY_CONFIG_SECTIONS);
const BASE_PRINCIPAL_OVERRIDE_SECTIONS = new Set<string>(BASE_PRINCIPAL_CONFIG_SECTIONS);
export function isValidFieldPath(path: string): boolean {
return (
@ -533,6 +535,15 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
);
}
}
for (const key of Object.keys(filteredOverrides)) {
const section = getTopLevelSection(key);
if (BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(section)) {
delete (filteredOverrides as Record<string, unknown>)[key];
logger.warn(
`[adminConfig] Stripping dedicated tenant-wide config section "${key}" from the generic config API`,
);
}
}
const iface = (overrides as Record<string, unknown>).interface;
if (iface != null && typeof iface === 'object' && !Array.isArray(iface)) {
const filteredIface: Record<string, unknown> = {};
@ -600,18 +611,33 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
}
const encryptedOverrides = encryptConfigSecrets(filteredOverrides);
const existingForSecrets = getConfigSecretSections().some((section) =>
const needsExistingSecrets = getConfigSecretSections().some((section) =>
isConfigSecretPreservablePatch(
section,
(filteredOverrides as Record<string, unknown>)[section],
),
)
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
: null;
);
const needsProtectedBaseSections =
principalId === BASE_CONFIG_PRINCIPAL_ID &&
(overrideSections.length > 0 || priority != null);
const existingConfig =
needsExistingSecrets || needsProtectedBaseSections
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
: null;
const preservedOverrides = preserveConfigSecrets(
encryptedOverrides,
existingForSecrets?.overrides,
existingConfig?.overrides,
);
if (needsProtectedBaseSections) {
for (const section of BASE_PRINCIPAL_OVERRIDE_SECTIONS) {
const storedSection = (
existingConfig?.overrides as Record<string, unknown> | undefined
)?.[section];
if (storedSection !== undefined) {
(preservedOverrides as Record<string, unknown>)[section] = storedSection;
}
}
}
const config = await upsertConfig(
principalType,
principalId,
@ -704,6 +730,12 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
);
return false;
}
if (BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(getTopLevelSection(entry.fieldPath))) {
logger.warn(
`[adminConfig] Stripping dedicated tenant-wide config field "${entry.fieldPath}" from the generic config API`,
);
return false;
}
if (isInterfacePermissionPath(entry.fieldPath)) {
logger.warn(
`[adminConfig] Stripping interface permission field "${entry.fieldPath}" — use role permissions instead`,
@ -841,6 +873,12 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
);
return res.status(200).json({ message: 'No actionable field path provided' });
}
if (BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(section)) {
logger.warn(
`[adminConfig] Ignoring dedicated tenant-wide config tombstone "${fieldPath}" in the generic config API`,
);
return res.status(200).json({ message: 'No actionable field path provided' });
}
if (priority != null && !hasBroadManage) {
logger.warn(
@ -925,6 +963,13 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
return res.status(200).json({ message: 'No actionable field path provided' });
}
if (BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(section)) {
logger.warn(
`[adminConfig] Ignoring dedicated tenant-wide config delete "${fieldPath}" in the generic config API`,
);
return res.status(200).json({ message: 'No actionable field path provided' });
}
if (isInterfacePermissionPath(fieldPath)) {
logger.warn(
`[adminConfig] Ignoring delete for interface permission field "${fieldPath}" — use role permissions instead`,

View file

@ -1,4 +1,5 @@
export { createAdminConfigHandlers } from './config';
export { createAdminLangfuseHandlers } from './langfuse';
export { createAdminGrantsHandlers } from './grants';
export { createAdminGroupsHandlers } from './groups';
export { createAdminRolesHandlers } from './roles';
@ -7,6 +8,7 @@ export { createAdminUsersHandlers } from './users';
export { createAdminAuditLogHandlers } from './auditLog';
export { resolveConfigSecret } from './secrets';
export type { AdminConfigDeps } from './config';
export type { AdminLangfuseDeps } from './langfuse';
export type { AdminGrantsDeps, GrantPrincipalType } from './grants';
export type { AdminGroupsDeps } from './groups';
export type { AdminRolesDeps } from './roles';

View file

@ -0,0 +1,803 @@
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
// Loaded via dynamic import in beforeAll so the crypto module initializes
// after CREDS_KEY is set above (encryptV3 reads the key at module load).
let encryptV3: typeof import('@librechat/data-schemas').encryptV3;
let createAdminLangfuseHandlers: typeof import('./langfuse').createAdminLangfuseHandlers;
const realFetch = global.fetch;
function projectResponse(projectId = 'project-1') {
return {
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ data: [{ id: projectId, name: 'Project' }] }),
};
}
beforeAll(async () => {
({ encryptV3 } = await import('@librechat/data-schemas'));
({ createAdminLangfuseHandlers } = await import('./langfuse'));
});
beforeEach(() => {
process.env.TENANT_ISOLATION_STRICT = 'true';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
global.fetch = jest.fn().mockResolvedValue(projectResponse()) as unknown as typeof fetch;
});
afterEach(() => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_TRACING_ENABLED;
delete process.env.LANGFUSE_SAMPLE_RATE;
delete process.env.TENANT_ISOLATION_STRICT;
global.fetch = realFetch;
});
function mockReq(overrides = {}) {
return {
user: { id: 'u1', role: 'ADMIN', tenantId: 't1' },
params: {},
body: {},
query: {},
...overrides,
} as Partial<ServerRequest> as ServerRequest;
}
interface MockRes {
statusCode: number;
body: undefined | Record<string, unknown>;
status: jest.Mock;
json: jest.Mock;
}
function mockRes() {
const res: MockRes = {
statusCode: 200,
body: undefined,
status: jest.fn((code: number) => {
res.statusCode = code;
return res;
}),
json: jest.fn((data: MockRes['body']) => {
res.body = data;
return res;
}),
};
return res as Partial<Response> as Response & MockRes;
}
function baseConfigDoc(langfuse: Record<string, unknown>) {
return {
_id: 'cfg1',
principalType: 'role',
principalId: '__base__',
priority: 10,
isActive: true,
overrides: { langfuse },
updatedAt: new Date('2026-06-29T00:00:00.000Z'),
};
}
function createHandlers(overrides = {}) {
const deps = {
findConfigByPrincipal: jest.fn().mockResolvedValue(null),
patchConfigFields: jest
.fn()
.mockImplementation((_pt, _pid, _pm, fields) =>
Promise.resolve(baseConfigDoc(rehydrate(fields))),
),
toggleConfigActive: jest.fn().mockImplementation((_pt, _pid, isActive) =>
Promise.resolve({
...baseConfigDoc({}),
isActive,
}),
),
invalidateConfigCaches: jest.fn().mockResolvedValue(undefined),
...overrides,
};
const handlers = createAdminLangfuseHandlers(deps);
return { handlers, deps };
}
/** Turn dot-path field entries into a nested langfuse object for the fake DB. */
function rehydrate(fields: Record<string, unknown>): Record<string, unknown> {
const langfuse: Record<string, unknown> = {};
for (const [path, value] of Object.entries(fields)) {
langfuse[path.replace(/^langfuse\./, '')] = value;
}
return langfuse;
}
describe('createAdminLangfuseHandlers', () => {
describe('connection availability gate', () => {
it('rejects connection reads when deployment fanout is disabled', async () => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse connection settings are not available' });
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
});
it('rejects connection updates when deployment fanout is disabled', async () => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse connection settings are not available' });
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects connection settings when the fanout collector URL is missing', async () => {
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse connection settings are not available' });
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
});
it('rejects connection tests when deployment fanout is disabled', async () => {
delete process.env.LANGFUSE_FANOUT_ENABLED;
global.fetch = jest.fn() as unknown as typeof fetch;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse connection settings are not available' });
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
it('rejects connection settings when tenant fanout export is emergency-disabled', async () => {
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Langfuse connection settings are not available' });
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
});
it('allows connection settings without fanout in single-tenant mode', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(200);
});
it('rejects single-tenant settings when environment credentials are configured', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
process.env.LANGFUSE_SECRET_KEY = 'sk-env';
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(404);
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
});
it('rejects settings when tracing is disabled', async () => {
process.env.LANGFUSE_TRACING_ENABLED = 'false';
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(404);
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
});
});
describe('getConnection', () => {
it('reports not configured when no base config exists', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({ configured: false, enabled: false });
expect(res.body?.secretKey).toBeUndefined();
});
it('returns metadata only and never the secret key', async () => {
const { handlers } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(
baseConfigDoc({
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
secretKeyPreview: 'sk-lf...cret',
}),
),
});
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.body).toMatchObject({
configured: true,
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKeyPreview: 'sk-lf...cret',
});
expect(res.body?.destinations).toEqual(
expect.arrayContaining([{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }]),
);
expect(res.body?.secretKey).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('sk-lf-secret');
expect(JSON.stringify(res.body)).not.toContain('v3:');
});
it('reports configured connections without an enabled field as disabled', async () => {
const { handlers } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(
baseConfigDoc({
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
}),
),
});
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.body).toMatchObject({ configured: true, enabled: false });
});
it('reads only active base configs', async () => {
const findConfigByPrincipal = jest.fn().mockResolvedValue(null);
const { handlers } = createHandlers({ findConfigByPrincipal });
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(findConfigByPrincipal).toHaveBeenCalledWith('role', '__base__');
});
});
describe('updateConnection', () => {
it('requires destination', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(mockReq({ body: { publicKey: 'pk' } }), res);
expect(res.statusCode).toBe(400);
});
it('requires publicKey', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(mockReq({ body: { destination: 'eu' } }), res);
expect(res.statusCode).toBe(400);
});
it('rejects an unknown destination', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { destination: 'mars', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('rejects encrypted secret values from clients', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: encryptV3('sk') } }),
res,
);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('requires a secret key on first-time configuration', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk' } }),
res,
);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('stores the secret through the shared config secret helper and never returns the secret', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
},
}),
res,
);
expect(res.statusCode).toBe(200);
const fields = deps.patchConfigFields.mock.calls[0][3];
expect(fields['langfuse.secretKey']).toMatch(/^v3:/);
expect(fields['langfuse.secretKey']).not.toContain('sk-lf-secret');
expect(fields['langfuse.secretKeyPreview']).toBe('sk-lf-...cret');
expect(fields['langfuse.enabled']).toBe(true);
expect(fields['langfuse.destination']).toBe('eu');
expect(fields['langfuse.publicKey']).toBe('pk-lf-1');
expect(fields['langfuse.projectId']).toBe('project-1');
expect(res.body?.secretKey).toBeUndefined();
expect(deps.invalidateConfigCaches).toHaveBeenCalledWith('t1');
});
it('requires a new secret when connection fields change', async () => {
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest
.fn()
.mockResolvedValue(baseConfigDoc({ secretKey: encryptV3('sk-lf-secret') })),
});
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: { enabled: false, destination: 'us', publicKey: 'pk-2' },
}),
res,
);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({
error: 'secretKey is required when changing the destination or publicKey',
});
expect(global.fetch).not.toHaveBeenCalled();
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('verifies changed connection fields with the submitted secret', async () => {
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(
baseConfigDoc({
destination: 'eu',
publicKey: 'pk-1',
secretKey: encryptV3('sk-lf-secret'),
}),
),
});
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: true,
destination: 'us',
publicKey: 'pk-2',
secretKey: 'sk-lf-replacement',
},
}),
res,
);
expect(res.statusCode).toBe(200);
const fields = deps.patchConfigFields.mock.calls[0][3];
expect(fields['langfuse.destination']).toBe('us');
expect(fields['langfuse.publicKey']).toBe('pk-2');
expect(fields['langfuse.projectId']).toBe('project-1');
expect(global.fetch).toHaveBeenCalledTimes(2);
const [url, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];
expect(url).toBe('https://us.cloud.langfuse.com/api/public/projects');
expect(
Buffer.from(init.headers.Authorization.replace('Basic ', ''), 'base64').toString(),
).toBe('pk-2:sk-lf-replacement');
});
it('rejects changed credentials before persisting when Langfuse verification fails', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: false, status: 401 }) as unknown as typeof fetch;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: true,
destination: 'eu',
publicKey: 'pk-invalid',
secretKey: 'sk-invalid',
},
}),
res,
);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({
error: 'Langfuse rejected these keys. Check the destination and keys',
});
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects credentials when Langfuse does not return a stable project identity', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ data: [] }),
}) as unknown as typeof fetch;
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
},
}),
res,
);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Langfuse did not return a project identity' });
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('does not re-verify a pure enable or disable update', async () => {
const stored = {
enabled: false,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
projectId: 'project-1',
};
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(stored)),
});
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { enabled: true, destination: 'eu', publicKey: 'pk-lf-1' } }),
res,
);
expect(res.statusCode).toBe(200);
expect(global.fetch).not.toHaveBeenCalled();
expect(deps.patchConfigFields).toHaveBeenCalledTimes(1);
expect(deps.patchConfigFields.mock.calls[0][3]['langfuse.enabled']).toBe(true);
expect(deps.patchConfigFields.mock.calls[0][3]['langfuse.projectId']).toBe('project-1');
});
it('allows an existing connection to be disabled after its destination is removed', async () => {
const stored = {
enabled: true,
destination: 'removed-destination',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
};
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(stored)),
});
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: false,
destination: 'removed-destination',
publicKey: 'pk-lf-1',
},
}),
res,
);
expect(res.statusCode).toBe(200);
expect(global.fetch).not.toHaveBeenCalled();
expect(deps.patchConfigFields).toHaveBeenCalledTimes(1);
expect(deps.patchConfigFields.mock.calls[0][3]).toMatchObject({
'langfuse.enabled': false,
'langfuse.destination': 'removed-destination',
'langfuse.publicKey': 'pk-lf-1',
});
});
it('reactivates an inactive base config updated by the field patch', async () => {
const inactiveUpdated = {
...baseConfigDoc({
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
}),
isActive: false,
};
const activeUpdated = { ...inactiveUpdated, isActive: true };
const inactiveExisting = {
...inactiveUpdated,
priority: 42,
};
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(inactiveExisting),
patchConfigFields: jest.fn().mockResolvedValue(inactiveUpdated),
toggleConfigActive: jest.fn().mockResolvedValue(activeUpdated),
});
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
},
}),
res,
);
expect(res.statusCode).toBe(200);
expect(deps.findConfigByPrincipal).toHaveBeenCalledWith('role', '__base__', {
includeInactive: true,
});
expect(deps.patchConfigFields.mock.calls[0][4]).toBe(42);
expect(deps.toggleConfigActive).toHaveBeenCalledWith('role', '__base__', true);
expect(res.body).toMatchObject({ configured: true, enabled: true });
});
});
describe('testConnection', () => {
it('requires destination and publicKey', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(mockReq({ body: { destination: 'eu' } }), res);
expect(res.statusCode).toBe(400);
});
it('rejects an unknown destination', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { destination: 'mars', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('rejects encrypted secret values from clients', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: encryptV3('sk') } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('returns success when Langfuse responds ok', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce(projectResponse())
.mockResolvedValueOnce({ ok: true, status: 207 }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body).toEqual({ success: true });
const [url, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];
expect(url).toBe('https://cloud.langfuse.com/api/public/projects');
expect(init.headers.Authorization).toMatch(/^Basic /);
expect(init.signal).toBeInstanceOf(AbortSignal);
const [publicUrl, publicInit] = (global.fetch as unknown as jest.Mock).mock.calls[1];
expect(publicUrl).toBe('https://cloud.langfuse.com/api/public/ingestion');
expect(publicInit.method).toBe('POST');
expect(publicInit.headers.Authorization).toBe('Bearer pk');
expect(publicInit.headers['X-Langfuse-Public-Key']).toBe('pk');
expect(publicInit.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(publicInit.body)).toEqual({ batch: [] });
expect(publicInit.signal).toBe(init.signal);
});
it('returns a timeout failure when Langfuse verification exceeds its deadline', async () => {
const timeoutError = new Error('The operation was aborted due to timeout');
timeoutError.name = 'TimeoutError';
global.fetch = jest
.fn()
.mockResolvedValueOnce(projectResponse())
.mockRejectedValueOnce(timeoutError) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body).toEqual({
success: false,
errorCode: 'timeout',
});
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('rejects an invalid public key even when the secret key is valid', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce(projectResponse())
.mockResolvedValueOnce({ ok: false, status: 401 }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk-invalid', secretKey: 'sk-valid' },
}),
res,
);
expect(res.body).toEqual({
success: false,
errorCode: 'invalid_credentials',
});
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('returns a key-specific failure when Langfuse rejects the credentials', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: false, status: 401 }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body).toEqual({
success: false,
errorCode: 'invalid_credentials',
});
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('returns an incident-oriented failure when Langfuse returns a server error', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: false, status: 503 }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body).toEqual({
success: false,
errorCode: 'server_error',
});
});
it.each([
[403, 'access_denied'],
[429, 'rate_limited'],
[400, 'unexpected_response'],
])('maps Langfuse status %i to %s', async (status, errorCode) => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body).toEqual({ success: false, errorCode });
});
it('falls back to the stored secret only for the unchanged connection', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce(projectResponse())
.mockResolvedValueOnce({ ok: true, status: 207 }) as unknown as typeof fetch;
const { handlers } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(
baseConfigDoc({
destination: 'eu',
publicKey: 'pk',
secretKey: encryptV3('sk-stored'),
}),
),
});
const res = mockRes();
await handlers.testConnection(mockReq({ body: { destination: 'eu', publicKey: 'pk' } }), res);
expect(res.body).toEqual({ success: true });
const [, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];
const decoded = Buffer.from(
init.headers.Authorization.replace('Basic ', ''),
'base64',
).toString();
expect(decoded).toBe('pk:sk-stored');
});
it('does not reuse the stored secret for a changed connection test', async () => {
const { handlers } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue(
baseConfigDoc({
destination: 'eu',
publicKey: 'pk-old',
secretKey: encryptV3('sk-stored'),
}),
),
});
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { destination: 'us', publicKey: 'pk-new' } }),
res,
);
expect(res.body).toEqual({ success: false, errorCode: 'missing_secret' });
expect(global.fetch).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,422 @@
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
import type {
TCustomConfig,
LangfuseConfig,
TLangfuseConnectionStatus,
TUpdateLangfuseConnectionRequest,
TLangfuseConnectionTestErrorCode,
TLangfuseConnectionTestRequest,
TLangfuseConnectionTestResponse,
} from 'librechat-data-provider';
import type { IConfig } from '@librechat/data-schemas';
import type { Types, ClientSession } from 'mongoose';
import type { Response } from 'express';
import type { LangfuseTenantDestination } from '~/langfuse/tenantDestinations';
import type { ServerRequest } from '~/types/http';
import {
getLangfuseTenantDestinations,
resolveLangfuseTenantDestination,
} from '~/langfuse/tenantDestinations';
import { decryptConfigSecret, encryptConfigSecretFields } from './secrets';
import { isLangfuseConnectionAvailable } from '~/langfuse/policy';
const DEFAULT_PRIORITY = 10;
const ENCRYPTED_PREFIX = 'v3:';
const LANGFUSE_VERIFICATION_TIMEOUT_MS = 10_000;
export interface AdminLangfuseDeps {
findConfigByPrincipal: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
options?: { includeInactive?: boolean },
session?: ClientSession,
) => Promise<IConfig | null>;
patchConfigFields: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
principalModel: PrincipalModel,
fields: Record<string, unknown>,
priority: number,
session?: ClientSession,
) => Promise<IConfig | null>;
toggleConfigActive: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
isActive: boolean,
session?: ClientSession,
) => Promise<IConfig | null>;
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
}
function getTenantId(req: ServerRequest): string | undefined {
return (req.user as { tenantId?: string } | undefined)?.tenantId;
}
function readStoredLangfuse(config: IConfig | null): LangfuseConfig | undefined {
const overrides = config?.overrides as Partial<TCustomConfig> | undefined;
return overrides?.langfuse;
}
function buildStatus(config: IConfig | null): TLangfuseConnectionStatus {
const stored = readStoredLangfuse(config);
const configured = Boolean(stored?.publicKey && stored?.secretKey);
return {
configured,
enabled: configured && stored?.enabled === true,
destinations: getLangfuseTenantDestinations(),
destination: stored?.destination,
publicKey: stored?.publicKey,
secretKeyPreview: stored?.secretKeyPreview,
updatedAt: config?.updatedAt ? new Date(config.updatedAt).toISOString() : undefined,
};
}
function rejectWhenConnectionUnavailable(res: Response): Response | undefined {
if (isLangfuseConnectionAvailable()) {
return undefined;
}
return res.status(404).json({ error: 'Langfuse connection settings are not available' });
}
type LangfuseVerificationFailure = {
errorCode: TLangfuseConnectionTestErrorCode;
message: string;
};
function getLangfuseTestFailure(status: number): LangfuseVerificationFailure {
if (status === 401) {
return {
errorCode: 'invalid_credentials',
message: 'Langfuse rejected these keys. Check the destination and keys',
};
}
if (status === 403) {
return {
errorCode: 'access_denied',
message: 'Langfuse denied access. Check the API key type and project status.',
};
}
if (status === 429) {
return {
errorCode: 'rate_limited',
message: 'Langfuse is rate limiting verification. Try again later.',
};
}
if (status >= 500) {
return {
errorCode: 'server_error',
message: 'Langfuse is returning server errors. This may be a Langfuse incident.',
};
}
return {
errorCode: 'unexpected_response',
message: `Langfuse responded with status ${status}`,
};
}
type LangfuseVerificationResult =
| { success: true; projectId: string }
| {
success: false;
errorCode: TLangfuseConnectionTestErrorCode;
message: string;
responseStatus?: number;
};
async function verifyLangfuseCredentials(
destination: LangfuseTenantDestination,
publicKey: string,
secretKey: string,
): Promise<LangfuseVerificationResult> {
try {
const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64');
const signal = AbortSignal.timeout(LANGFUSE_VERIFICATION_TIMEOUT_MS);
const secretResponse = await fetch(`${destination.baseUrl}/api/public/projects`, {
headers: { Authorization: `Basic ${auth}` },
signal,
});
if (!secretResponse.ok) {
return {
success: false,
...getLangfuseTestFailure(secretResponse.status),
responseStatus: secretResponse.status >= 500 ? 502 : 400,
};
}
let projects: unknown;
try {
projects = await secretResponse.json();
} catch {
return {
success: false,
errorCode: 'unexpected_response',
message: 'Langfuse returned an invalid project response',
responseStatus: 400,
};
}
const projectId =
projects != null &&
typeof projects === 'object' &&
Array.isArray((projects as { data?: unknown }).data) &&
(projects as { data: unknown[] }).data.length === 1 &&
typeof (projects as { data: Array<{ id?: unknown }> }).data[0]?.id === 'string'
? (projects as { data: Array<{ id: string }> }).data[0].id.trim()
: '';
if (!projectId) {
return {
success: false,
errorCode: 'unexpected_response',
message: 'Langfuse did not return a project identity',
responseStatus: 400,
};
}
const publicResponse = await fetch(`${destination.baseUrl}/api/public/ingestion`, {
method: 'POST',
headers: {
Authorization: `Bearer ${publicKey}`,
'X-Langfuse-Public-Key': publicKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ batch: [] }),
signal,
});
if (!publicResponse.ok) {
return {
success: false,
...getLangfuseTestFailure(publicResponse.status),
responseStatus: publicResponse.status >= 500 ? 502 : 400,
};
}
return { success: true, projectId };
} catch (error) {
logger.error('[adminLangfuse] connection verification error:', error);
if (error instanceof Error && error.name === 'TimeoutError') {
return {
success: false,
errorCode: 'timeout',
message: 'Langfuse verification timed out',
responseStatus: 502,
};
}
return {
success: false,
errorCode: 'unreachable',
message: 'Could not reach the Langfuse host',
responseStatus: 502,
};
}
}
/**
* Admin handlers for the per-tenant Langfuse connection.
*
* The connection is stored as a `langfuse` override on the base config so it is
* resolved for every user in the tenant. The secret key is encrypted at rest and
* never returned by read endpoints; reads expose only non-secret metadata.
*/
export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
getConnection: (req: ServerRequest, res: Response) => Promise<Response>;
updateConnection: (req: ServerRequest, res: Response) => Promise<Response>;
testConnection: (req: ServerRequest, res: Response) => Promise<Response>;
} {
const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } =
deps;
function findBaseConfig(options?: { includeInactive?: boolean }): Promise<IConfig | null> {
return options
? findConfigByPrincipal(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID, options)
: findConfigByPrincipal(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID);
}
async function getConnection(req: ServerRequest, res: Response): Promise<Response> {
const disabledResponse = rejectWhenConnectionUnavailable(res);
if (disabledResponse) {
return disabledResponse;
}
try {
const config = await findBaseConfig();
return res.status(200).json(buildStatus(config));
} catch (error) {
logger.error('[adminLangfuse] getConnection error:', error);
return res.status(500).json({ error: 'Failed to read Langfuse connection' });
}
}
async function updateConnection(req: ServerRequest, res: Response): Promise<Response> {
const disabledResponse = rejectWhenConnectionUnavailable(res);
if (disabledResponse) {
return disabledResponse;
}
try {
const body = (req.body ?? {}) as TUpdateLangfuseConnectionRequest;
const enabled = body.enabled === true;
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
const secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
if (!destination) {
return res.status(400).json({ error: 'destination is required' });
}
if (!publicKey) {
return res.status(400).json({ error: 'publicKey is required' });
}
if (secretKey.startsWith(ENCRYPTED_PREFIX)) {
return res.status(400).json({ error: 'Encrypted secretKey values cannot be submitted' });
}
const existing = await findBaseConfig({ includeInactive: true });
const stored = readStoredLangfuse(existing);
const hasStoredSecret = Boolean(stored?.secretKey);
const tenantDestination = resolveLangfuseTenantDestination(destination);
const isPureDisableOfStoredConnection =
!enabled &&
secretKey === '' &&
hasStoredSecret &&
stored?.destination === destination &&
stored.publicKey === publicKey;
if (!tenantDestination && !isPureDisableOfStoredConnection) {
return res.status(400).json({ error: 'destination is not configured' });
}
if (!secretKey && !hasStoredSecret) {
return res
.status(400)
.json({ error: 'secretKey is required for first-time configuration' });
}
const persistedDestination = tenantDestination?.key ?? destination;
const connectionChanged =
secretKey !== '' ||
stored?.destination !== persistedDestination ||
stored?.publicKey !== publicKey;
let verifiedProjectId = stored?.projectId;
if (connectionChanged) {
if (!tenantDestination) {
return res.status(400).json({ error: 'destination is not configured' });
}
if (!secretKey) {
return res
.status(400)
.json({ error: 'secretKey is required when changing the destination or publicKey' });
}
const verification = await verifyLangfuseCredentials(
tenantDestination,
publicKey,
secretKey,
);
if (!verification.success) {
return res
.status(verification.responseStatus ?? 400)
.json({ error: verification.message });
}
verifiedProjectId = verification.projectId;
}
const fields: Record<string, unknown> = {
'langfuse.enabled': enabled,
'langfuse.destination': persistedDestination,
'langfuse.publicKey': publicKey,
};
if (verifiedProjectId) {
fields['langfuse.projectId'] = verifiedProjectId;
}
if (secretKey) {
fields['langfuse.secretKey'] = secretKey;
}
let updated = await patchConfigFields(
PrincipalType.ROLE,
BASE_CONFIG_PRINCIPAL_ID,
PrincipalModel.ROLE,
encryptConfigSecretFields(fields),
existing?.priority ?? DEFAULT_PRIORITY,
);
if (updated?.isActive === false) {
updated = await toggleConfigActive(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID, true);
}
invalidateConfigCaches?.(getTenantId(req))?.catch((err) =>
logger.error('[adminLangfuse] Cache invalidation failed after update:', err),
);
return res.status(200).json(buildStatus(updated ?? existing));
} catch (error) {
logger.error('[adminLangfuse] updateConnection error:', error);
return res.status(500).json({ error: 'Failed to update Langfuse connection' });
}
}
async function testConnection(req: ServerRequest, res: Response): Promise<Response> {
const disabledResponse = rejectWhenConnectionUnavailable(res);
if (disabledResponse) {
return disabledResponse;
}
try {
const body = (req.body ?? {}) as TLangfuseConnectionTestRequest;
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
let secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
const tenantDestination = resolveLangfuseTenantDestination(destination);
if (!destination || !publicKey) {
return res.status(400).json({ error: 'destination and publicKey are required' });
}
if (!tenantDestination) {
return res.status(400).json({ error: 'destination is not configured' });
}
if (secretKey.startsWith(ENCRYPTED_PREFIX)) {
return res.status(400).json({ error: 'Encrypted secretKey values cannot be submitted' });
}
if (!secretKey) {
const existing = await findBaseConfig();
const stored = readStoredLangfuse(existing);
const unchangedConnection =
stored?.destination === tenantDestination.key && stored.publicKey === publicKey;
if (unchangedConnection && stored.secretKey) {
secretKey = decryptConfigSecret(stored.secretKey) ?? '';
if (!secretKey) {
const failed: TLangfuseConnectionTestResponse = {
success: false,
errorCode: 'stored_secret_unavailable',
};
return res.status(200).json(failed);
}
}
}
if (!secretKey) {
const failed: TLangfuseConnectionTestResponse = {
success: false,
errorCode: 'missing_secret',
};
return res.status(200).json(failed);
}
const result = await verifyLangfuseCredentials(tenantDestination, publicKey, secretKey);
const response: TLangfuseConnectionTestResponse = result.success
? { success: true }
: { success: false, errorCode: result.errorCode };
return res.status(200).json(response);
} catch (error) {
logger.error('[adminLangfuse] testConnection error:', error);
const result: TLangfuseConnectionTestResponse = {
success: false,
errorCode: 'unreachable',
};
return res.status(200).json(result);
}
}
return { getConnection, updateConnection, testConnection };
}

View file

@ -224,6 +224,13 @@ beforeEach(() => {
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
delete process.env.LANGFUSE_TRACING_ENABLED;
delete process.env.LANGFUSE_SAMPLE_RATE;
process.env.TENANT_ISOLATION_STRICT = 'true';
});
afterAll(() => {
delete process.env.TENANT_ISOLATION_STRICT;
});
// ---------------------------------------------------------------------------
@ -1244,18 +1251,17 @@ describe('Langfuse run config', () => {
});
it('adds tenant Langfuse credentials from tenant-scoped app config', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: true,
},
},
} as unknown as AppConfig,
});
@ -1283,6 +1289,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1311,6 +1318,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
},
@ -1333,6 +1341,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
@ -1360,6 +1369,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1382,6 +1392,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
@ -1414,6 +1425,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1443,6 +1455,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1471,6 +1484,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1500,6 +1514,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'unconfigured',
@ -1525,7 +1540,7 @@ describe('Langfuse run config', () => {
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {},
langfuse: { enabled: true },
} as AppConfig,
});
@ -1568,6 +1583,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
},
@ -1593,6 +1609,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1627,6 +1644,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1656,6 +1674,7 @@ describe('Langfuse run config', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
@ -1678,69 +1697,10 @@ describe('Langfuse run config', () => {
},
);
it('uses central env Langfuse config when tenant fanout.enabled=false overrides deployment fanout env', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
it('keeps central collector tracing when tenant Langfuse export is disabled', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: false,
},
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses central env Langfuse config when tenant fanout.enabled is the string false', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: 'false',
},
},
} as unknown as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('honors tenant Langfuse enabled=false as a tracing opt-out', async () => {
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
@ -1754,13 +1714,16 @@ describe('Langfuse run config', () => {
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
enabled: false,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('honors tenant Langfuse enabled as the string false', async () => {
it('keeps central collector tracing when tenant Langfuse enabled is the string false', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
@ -1774,7 +1737,7 @@ describe('Langfuse run config', () => {
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
enabled: false,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});

View file

@ -1571,6 +1571,7 @@ export async function createRun({
// tracing is enabled. Requires @librechat/agents >= 3.2.21.
langfuse: buildLangfuseConfig({
appConfig,
runId,
tenantId: tenantId ?? user?.tenantId,
centralTraceExportEnabled,
}),

View file

@ -14,6 +14,9 @@ const envKeys = [
'LANGFUSE_FANOUT_COLLECTOR_URL',
'LANGFUSE_FANOUT_TENANT_DESTINATIONS',
'LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED',
'LANGFUSE_TRACING_ENABLED',
'LANGFUSE_SAMPLE_RATE',
'TENANT_ISOLATION_STRICT',
];
function clearEnv() {
@ -25,13 +28,136 @@ function clearEnv() {
describe('buildLangfuseConfig', () => {
beforeEach(() => {
clearEnv();
process.env.TENANT_ISOLATION_STRICT = 'true';
});
afterEach(() => {
clearEnv();
});
it('enables fanout only when both the toggle and collector URL are configured', async () => {
const { isLangfuseFanoutEnabled } = await import('./config');
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
expect(isLangfuseFanoutEnabled()).toBe(false);
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = ' ';
expect(isLangfuseFanoutEnabled()).toBe(false);
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318';
expect(isLangfuseFanoutEnabled()).toBe(true);
});
it('uses a stored connection directly for every run in single-tenant mode', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
runId: 'run-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-stored',
secretKey: encryptV3('sk-stored'),
destination: 'us',
},
} as unknown as AppConfig,
}),
).toEqual({
deterministicTraceId: true,
publicKey: 'pk-stored',
secretKey: 'sk-stored',
baseUrl: 'https://us.cloud.langfuse.com',
});
});
it('prefers environment credentials in single-tenant mode', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
process.env.LANGFUSE_SECRET_KEY = 'sk-env';
process.env.LANGFUSE_BASE_URL = 'https://env.langfuse.example';
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
runId: 'run-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-stored',
secretKey: encryptV3('sk-stored'),
destination: 'us',
},
} as unknown as AppConfig,
}),
).toEqual({
deterministicTraceId: true,
publicKey: 'pk-env',
secretKey: 'sk-env',
baseUrl: 'https://env.langfuse.example',
});
});
it('does not trace a disabled stored connection in single-tenant mode', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
runId: 'run-1',
appConfig: {
langfuse: {
enabled: false,
publicKey: 'pk-stored',
secretKey: encryptV3('sk-stored'),
destination: 'us',
},
} as unknown as AppConfig,
}),
).toEqual({
deterministicTraceId: true,
enabled: false,
});
});
it.each(['false', '0', 'no', 'off'])(
'disables traces when LANGFUSE_TRACING_ENABLED is %s',
async (value) => {
process.env.LANGFUSE_TRACING_ENABLED = value;
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
const { buildLangfuseConfig } = await import('./config');
expect(buildLangfuseConfig({ runId: 'run-1' })).toEqual({
deterministicTraceId: true,
enabled: false,
});
},
);
it('applies fractional sampling to deterministic run trace IDs', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0.5';
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
const { buildLangfuseConfig } = await import('./config');
expect(buildLangfuseConfig({ runId: 'sampled-run' })).toEqual({
deterministicTraceId: true,
enabled: false,
});
expect(buildLangfuseConfig({ runId: 'unsampled-run' })).toMatchObject({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
});
});
it('decrypts encrypted tenant secrets for tenant trace export', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const { encryptV3 } = await import('@librechat/data-schemas');
@ -41,12 +167,10 @@ describe('buildLangfuseConfig', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: true,
},
},
} as unknown as AppConfig,
});
@ -71,6 +195,7 @@ describe('buildLangfuseConfig', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: 'v3:not-valid-ciphertext',
destination: 'eu',
@ -95,6 +220,7 @@ describe('buildLangfuseConfig', () => {
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
destination: 'eu',
@ -183,6 +309,7 @@ describe('buildLangfuseConfig', () => {
centralTraceExportEnabled: false,
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
@ -217,6 +344,7 @@ describe('buildLangfuseConfig', () => {
centralTraceExportEnabled: false,
appConfig: {
langfuse: {
enabled: true,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
@ -234,7 +362,60 @@ describe('buildLangfuseConfig', () => {
});
});
it('honors tenant Langfuse enabled=false before adding routing attributes', async () => {
it('keeps central collector export when the tenant connection is disabled', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: false,
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
},
} as unknown as AppConfig,
}),
).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('keeps central collector export when tenant enabled is missing', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
},
} as unknown as AppConfig,
}),
).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('does not emit central-suppressed traces when the tenant connection is disabled', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const { buildLangfuseConfig } = await import('./config');
expect(
@ -251,6 +432,9 @@ describe('buildLangfuseConfig', () => {
deterministicTraceId: true,
metadata: { 'librechat.tenant.id': 'tenant-1' },
enabled: false,
librechatTraceAttributes: {
[CENTRAL_EXPORT_ATTRIBUTE]: 'false',
},
tags: ['tenant:tenant-1'],
});
});

View file

@ -1,12 +1,19 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { RunConfig } from '@librechat/agents';
import { isTrueEnv, normalizeBoolean, resolveTenantCredentials } from './utils';
import {
hasLangfuseEnvCredentials,
isLangfuseFanoutEnabled,
isLangfuseTenantExportEnabled,
isLangfuseTraceSampled,
isLangfuseTracingEnabled,
usesLangfuseMultiTenantRouting,
} from './policy';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { normalizeBoolean, resolveTenantCredentials } from './utils';
import { normalizeString } from '~/utils/text';
import { traceIdForMessage } from './trace';
type LangfuseRunConfig = NonNullable<RunConfig['langfuse']>;
type LangfuseAppConfig = NonNullable<AppConfig['langfuse']>;
export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'];
type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & {
librechatTraceAttributes?: Record<string, string | number | boolean | null | undefined>;
};
@ -32,14 +39,7 @@ function appendPath(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/+$/, '')}${path}`;
}
export function isLangfuseTenantExportEnabled(): boolean {
return !isTrueEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED);
}
export function isLangfuseFanoutEnabled(fanout?: LangfuseFanoutConfig): boolean {
const enabled = normalizeBoolean(fanout?.enabled);
return enabled !== false && (enabled === true || isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED));
}
export { isLangfuseFanoutEnabled, isLangfuseTenantExportEnabled } from './policy';
function mergeTraceMetadata(
base: LangfuseRunConfig['metadata'],
@ -127,10 +127,12 @@ function resolveLangfuseExportPlan({
export function buildLangfuseConfig({
appConfig,
runId,
tenantId,
centralTraceExportEnabled = true,
}: {
appConfig?: AppConfig;
runId?: string;
tenantId?: string;
/**
* Defaults to true. Set false to suppress central Langfuse export for this
@ -154,28 +156,47 @@ export function buildLangfuseConfig({
langfuse.tags = tags;
}
if (normalizeBoolean(config?.enabled) === false) {
return {
...langfuse,
enabled: false,
};
if (
!isLangfuseTracingEnabled() ||
(runId != null && !isLangfuseTraceSampled(traceIdForMessage(runId)))
) {
langfuse.enabled = false;
return langfuse;
}
const tenantLangfuseEnabled = normalizeBoolean(config?.enabled) === true;
if (!centralTraceExportEnabled) {
disableCentralExport(langfuse);
}
const tenantCredentials = resolveTenantCredentials(config);
const hasTenantCredentials = Boolean(tenantCredentials);
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
const fanoutEnabled = isLangfuseFanoutEnabled(fanout);
const fanoutEnabled = isLangfuseFanoutEnabled();
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const tenantDestination = resolveLangfuseTenantDestination(config?.destination);
const tenantExportEmergencyEnabled = isLangfuseTenantExportEnabled();
if (!usesLangfuseMultiTenantRouting()) {
if (!centralTraceExportEnabled) {
langfuse.enabled = false;
} else if (hasLangfuseEnvCredentials()) {
applyCentralEnvConfig(langfuse);
} else if (tenantLangfuseEnabled && tenantCredentials != null && tenantDestination != null) {
langfuse.publicKey = tenantCredentials.publicKey;
langfuse.secretKey = tenantCredentials.secretKey;
langfuse.baseUrl = tenantDestination.baseUrl;
} else if (config != null) {
langfuse.enabled = false;
}
return langfuse;
}
const exportPlan = resolveLangfuseExportPlan({
centralTraceExportEnabled,
fanoutEnabled,
fanoutCollectorUrl,
tenantExportEnabled: hasTenantCredentials && tenantExportEmergencyEnabled,
tenantExportEnabled:
tenantLangfuseEnabled && hasTenantCredentials && tenantExportEmergencyEnabled,
publicKey: tenantCredentials?.publicKey,
secretKey: tenantCredentials?.secretKey,
tenantDestination,

View file

@ -1,36 +1,38 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { LangfuseFanoutConfig } from './config';
import { createHash } from 'node:crypto';
import { logger, type AppConfig } from '@librechat/data-schemas';
import {
isFalseEnv,
normalizeBoolean,
resolveTenantCredentials,
toBasicAuthorization,
} from './utils';
import { isLangfuseFanoutEnabled, isLangfuseTenantExportEnabled } from './config';
hasLangfuseEnvCredentials,
isLangfuseFanoutEnabled,
isLangfuseTenantExportEnabled,
isLangfuseTracingEnabled,
isLangfuseTraceSampled,
usesLangfuseMultiTenantRouting,
} from './policy';
import { normalizeBoolean, resolveTenantCredentials, toBasicAuthorization } from './utils';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { normalizeString } from '~/utils/text';
const DEFAULT_BASE_URL = 'https://cloud.langfuse.com';
const PROJECT_LOOKUP_TIMEOUT_MS = 10_000;
const PROJECT_LOOKUP_RETRY_MS = 30_000;
type CentralProjectIdCacheEntry = {
projectId?: string;
lookup?: Promise<string | undefined>;
retryAt: number;
};
const centralProjectIdCache = new Map<string, CentralProjectIdCacheEntry>();
export type LangfuseScoreDestination = {
name: 'central' | 'tenant';
id?: string;
name: 'central' | 'tenant' | 'connection';
baseUrl: string;
authorization: string;
};
function isSampleRateEnabled(value?: string): boolean {
if (value == null || value.trim() === '') {
return true;
}
const parsed = Number(value);
return !Number.isFinite(parsed) || parsed !== 0;
}
function isTracingEnabled(): boolean {
return (
!isFalseEnv(process.env.LANGFUSE_TRACING_ENABLED) &&
isSampleRateEnabled(process.env.LANGFUSE_SAMPLE_RATE)
);
function getDestinationId(baseUrl: string, projectId: string): string {
return createHash('sha256')
.update(`${baseUrl.replace(/\/+$/, '')}\n${projectId}`)
.digest('hex');
}
function getCentralEnvBaseUrl(): string {
@ -42,11 +44,77 @@ function getCentralEnvBaseUrl(): string {
);
}
function getCentralScoreDestination(): LangfuseScoreDestination | undefined {
if (!isTracingEnabled()) {
return undefined;
async function resolveCentralProjectId(
baseUrl: string,
publicKey: string,
secretKey: string,
waitForLookup: boolean,
): Promise<string | undefined> {
const configuredProjectId = normalizeString(process.env.LANGFUSE_PROJECT_ID);
if (configuredProjectId) {
return configuredProjectId;
}
const cacheKey = createHash('sha256')
.update(`${baseUrl}\n${publicKey}\n${secretKey}`)
.digest('hex');
const cached = centralProjectIdCache.get(cacheKey) ?? { retryAt: 0 };
centralProjectIdCache.set(cacheKey, cached);
if (cached.projectId) {
return cached.projectId;
}
if (!cached.lookup && Date.now() >= cached.retryAt) {
cached.lookup = (async () => {
try {
const response = await fetch(`${baseUrl}/api/public/projects`, {
headers: { Authorization: toBasicAuthorization(publicKey, secretKey) },
signal: AbortSignal.timeout(PROJECT_LOOKUP_TIMEOUT_MS),
});
if (!response.ok) {
logger.warn(
`[langfuse] Could not resolve central project identity: Langfuse responded with ${response.status}`,
);
return undefined;
}
const projects: unknown = await response.json();
const projectId =
projects != null &&
typeof projects === 'object' &&
Array.isArray((projects as { data?: unknown }).data) &&
(projects as { data: unknown[] }).data.length === 1 &&
typeof (projects as { data: Array<{ id?: unknown }> }).data[0]?.id === 'string'
? (projects as { data: Array<{ id: string }> }).data[0].id.trim()
: '';
if (!projectId) {
logger.warn(
'[langfuse] Could not resolve central project identity from Langfuse response',
);
return undefined;
}
return projectId;
} catch (error) {
logger.warn('[langfuse] Could not resolve central project identity:', error);
return undefined;
}
})().then((projectId) => {
cached.lookup = undefined;
if (projectId) {
cached.projectId = projectId;
} else {
cached.retryAt = Date.now() + PROJECT_LOOKUP_RETRY_MS;
}
return projectId;
});
}
return waitForLookup && cached.lookup ? cached.lookup : undefined;
}
async function getCentralScoreDestination(
waitForProjectId: boolean,
): Promise<LangfuseScoreDestination | undefined> {
// Central feedback scores are sent directly by the app, not through the
// collector, so they use LibreChat's normal central Langfuse credentials.
// LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER is intentionally collector-only.
@ -56,27 +124,26 @@ function getCentralScoreDestination(): LangfuseScoreDestination | undefined {
return undefined;
}
const baseUrl = getCentralEnvBaseUrl();
const projectId = await resolveCentralProjectId(baseUrl, publicKey, secretKey, waitForProjectId);
return {
id: projectId ? getDestinationId(baseUrl, projectId) : undefined,
name: 'central',
baseUrl: getCentralEnvBaseUrl(),
baseUrl,
authorization: toBasicAuthorization(publicKey, secretKey),
};
}
function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestination | undefined {
if (!isTracingEnabled()) {
return undefined;
}
if (!isLangfuseTenantExportEnabled()) {
return undefined;
}
const config = appConfig?.langfuse;
if (normalizeBoolean(config?.enabled) === false) {
if (normalizeBoolean(config?.enabled) !== true) {
return undefined;
}
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
if (!isLangfuseFanoutEnabled(fanout)) {
if (!isLangfuseFanoutEnabled()) {
return undefined;
}
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
@ -94,27 +161,103 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
}
return {
id: config?.projectId ? getDestinationId(destination.baseUrl, config.projectId) : undefined,
name: 'tenant',
baseUrl: destination.baseUrl,
authorization: toBasicAuthorization(tenantCredentials.publicKey, tenantCredentials.secretKey),
};
}
/**
* Score fanout uses Langfuse's direct REST API. The deployment-level collector
* URL is still required so tenant score fanout follows trace fanout availability.
*/
export function getScoreDestinations(appConfig?: AppConfig): LangfuseScoreDestination[] {
const destinations = [getCentralScoreDestination(), getTenantScoreDestination(appConfig)].filter(
(destination): destination is LangfuseScoreDestination => Boolean(destination),
);
const seen = new Set<string>();
return destinations.filter((destination) => {
const key = `${destination.baseUrl}\n${destination.authorization}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
function getConfiguredScoreDestination(
appConfig?: AppConfig,
): LangfuseScoreDestination | undefined {
const config = appConfig?.langfuse;
if (normalizeBoolean(config?.enabled) !== true) {
return undefined;
}
const credentials = resolveTenantCredentials(config);
const destination = resolveLangfuseTenantDestination(config?.destination);
if (!credentials || !destination) {
return undefined;
}
return {
id: config?.projectId ? getDestinationId(destination.baseUrl, config.projectId) : undefined,
name: 'connection',
baseUrl: destination.baseUrl,
authorization: toBasicAuthorization(credentials.publicKey, credentials.secretKey),
};
}
/**
* Scores use Langfuse's direct REST API. Multi-tenant score fanout follows the
* collector availability gate used by traces; single-tenant connections send
* directly to their configured destination.
*/
export async function getScoreDestinations(
appConfig: AppConfig | undefined,
traceId: string,
sampled?: boolean,
options?: { waitForCentralProjectId?: boolean },
): Promise<LangfuseScoreDestination[]> {
if (
!isLangfuseTracingEnabled() ||
sampled === false ||
(sampled == null && !isLangfuseTraceSampled(traceId))
) {
return [];
}
if (!usesLangfuseMultiTenantRouting()) {
return hasLangfuseEnvCredentials()
? [await getCentralScoreDestination(options?.waitForCentralProjectId !== false)].filter(
(destination): destination is LangfuseScoreDestination => Boolean(destination),
)
: [getConfiguredScoreDestination(appConfig)].filter(
(destination): destination is LangfuseScoreDestination => Boolean(destination),
);
}
const destinations = [
await getCentralScoreDestination(options?.waitForCentralProjectId !== false),
getTenantScoreDestination(appConfig),
].filter((destination): destination is LangfuseScoreDestination => Boolean(destination));
const unique = new Map<string, LangfuseScoreDestination>();
for (const destination of destinations) {
const deduplicationKey = `${destination.baseUrl}\n${destination.authorization}`;
const existing = unique.get(deduplicationKey);
if (
existing == null ||
(existing.name === 'central' && destination.name !== 'central' && destination.id != null)
) {
unique.set(deduplicationKey, destination);
}
}
return [...unique.values()];
}
/**
* Captures the concrete Langfuse projects eligible to receive a generated
* trace. The opaque IDs let later feedback avoid newly configured or replaced
* destinations without persisting credentials on the message.
*/
export async function getLangfuseTraceDestinationIds(
appConfig: AppConfig | undefined,
traceId: string,
sampled?: boolean,
): Promise<string[] | undefined> {
const destinations = await getScoreDestinations(appConfig, traceId, sampled, {
waitForCentralProjectId: false,
});
if (destinations.some(({ id }) => id == null)) {
return undefined;
}
return destinations.map(({ id }) => id as string);
}
const centralPublicKey = normalizeString(process.env.LANGFUSE_PUBLIC_KEY);
const centralSecretKey = normalizeString(process.env.LANGFUSE_SECRET_KEY);
if (centralPublicKey && centralSecretKey) {
void resolveCentralProjectId(getCentralEnvBaseUrl(), centralPublicKey, centralSecretKey, false);
}

View file

@ -31,6 +31,7 @@ jest.mock('~/admin/secrets', () => ({
const langfuseEnvKeys = [
'LANGFUSE_PUBLIC_KEY',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_PROJECT_ID',
'LANGFUSE_BASE_URL',
'LANGFUSE_HOST',
'LANGFUSE_BASEURL',
@ -44,6 +45,7 @@ const langfuseEnvKeys = [
'LANGFUSE_FANOUT_TENANT_US_BASE_URL',
'LANGFUSE_FANOUT_TENANT_JP_BASE_URL',
'LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED',
'TENANT_ISOLATION_STRICT',
];
let fetchMock: jest.SpiedFunction<typeof fetch>;
@ -56,9 +58,11 @@ function clearLangfuseEnv() {
function setLangfuseCredentials() {
process.env.LANGFUSE_PUBLIC_KEY = 'public-key';
process.env.LANGFUSE_SECRET_KEY = 'secret-key';
process.env.LANGFUSE_PROJECT_ID = 'central-project-id';
}
function enableTenantFanout() {
process.env.TENANT_ISOLATION_STRICT = 'true';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
}
@ -88,7 +92,13 @@ function getCentralAuthorization(): string {
}
function appConfigWithLangfuse(langfuse: AppConfig['langfuse']): AppConfig {
return { langfuse } as AppConfig;
return {
langfuse: {
enabled: true,
projectId: 'tenant-project-id',
...langfuse,
},
} as AppConfig;
}
describe('Langfuse feedback scores', () => {
@ -171,8 +181,113 @@ describe('Langfuse feedback scores', () => {
);
});
it('posts scores only to the stored connection in single-tenant mode without env credentials', async () => {
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'us=https://us.cloud.langfuse.example';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '86d413435f8b0d7f32d4d010ce769e2e',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'us',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://us.cloud.langfuse.example/api/public/scores',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: getTenantAuthorization() }),
}),
);
});
it('keeps scores on environment credentials in single-tenant mode', async () => {
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '86d413435f8b0d7f32d4d010ce769e2e',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'us',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://cloud.langfuse.com/api/public/scores',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('does not send scores for a disabled stored connection in single-tenant mode', async () => {
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '86d413435f8b0d7f32d4d010ce769e2e',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
enabled: false,
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'us',
}),
});
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('does not send a score for a trace excluded by fractional sampling', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0.5';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '658f74b0a232417fc3e6e4d9ef5f563a',
feedback: { rating: 'thumbsUp' },
});
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('preserves a sampled trace when the sample rate decreases', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0.1';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '658f74b0a232417fc3e6e4d9ef5f563a',
sampled: true,
feedback: { rating: 'thumbsUp' },
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
});
it('preserves an excluded trace when the sample rate increases', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '1';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: '86d413435f8b0d7f32d4d010ce769e2e',
sampled: false,
feedback: { rating: 'thumbsUp' },
});
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('posts feedback scores to central fanout and tenant Langfuse projects', async () => {
enableTenantFanout();
delete process.env.TENANT_ISOLATION_STRICT;
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
@ -183,6 +298,7 @@ describe('Langfuse feedback scores', () => {
metadata: { tenantId: 'tenant-a' },
appConfig: {
langfuse: {
enabled: true,
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
@ -225,6 +341,226 @@ describe('Langfuse feedback scores', () => {
});
});
it('does not send feedback to a destination that did not receive the original trace', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
sampled: true,
destinationIds: ['original-destination-id'],
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'new-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('keeps the destination identity stable when project credentials rotate', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
const { sendFeedbackScore } = await loadFeedback();
const { getLangfuseTraceDestinationIds } = await import('./destinations');
const originalConfig = appConfigWithLangfuse({
projectId: 'stable-project-id',
publicKey: 'old-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
});
const destinationIds = await getLangfuseTraceDestinationIds(originalConfig, 'trace-id', true);
await sendFeedbackScore({
traceId: 'trace-id',
sampled: true,
destinationIds,
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
projectId: 'stable-project-id',
publicKey: 'new-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
expect(destinationIds).toHaveLength(1);
expect(getFetchMock()).toHaveBeenCalledTimes(1);
});
it('keeps the central destination identity stable when credentials rotate', async () => {
const { sendFeedbackScore } = await loadFeedback();
const { getLangfuseTraceDestinationIds } = await import('./destinations');
const destinationIds = await getLangfuseTraceDestinationIds(undefined, 'trace-id', true);
process.env.LANGFUSE_PUBLIC_KEY = 'rotated-public-key';
process.env.LANGFUSE_SECRET_KEY = 'rotated-secret-key';
await sendFeedbackScore({
traceId: 'trace-id',
sampled: true,
destinationIds,
feedback: { rating: 'thumbsUp' },
});
expect(destinationIds).toHaveLength(1);
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://cloud.langfuse.com/api/public/scores',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: getTenantAuthorization('rotated-public-key', 'rotated-secret-key'),
}),
}),
);
});
it('does not reroute central feedback after a project replacement on the same host', async () => {
const { sendFeedbackScore } = await loadFeedback();
const { getLangfuseTraceDestinationIds } = await import('./destinations');
const destinationIds = await getLangfuseTraceDestinationIds(undefined, 'trace-id', true);
process.env.LANGFUSE_PROJECT_ID = 'replacement-project-id';
process.env.LANGFUSE_PUBLIC_KEY = 'replacement-public-key';
process.env.LANGFUSE_SECRET_KEY = 'replacement-secret-key';
await sendFeedbackScore({
traceId: 'trace-id',
sampled: true,
destinationIds,
feedback: { rating: 'thumbsUp' },
});
expect(destinationIds).toHaveLength(1);
expect(getFetchMock()).not.toHaveBeenCalled();
});
it('discovers and caches the central project identity when it is not configured', async () => {
delete process.env.LANGFUSE_PROJECT_ID;
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: [{ id: 'discovered-project-id' }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const { sendFeedbackScore } = await loadFeedback();
const { getLangfuseTraceDestinationIds } = await import('./destinations');
await new Promise((resolve) => setImmediate(resolve));
const destinationIds = await getLangfuseTraceDestinationIds(undefined, 'trace-id', true);
await sendFeedbackScore({
traceId: 'trace-id',
sampled: true,
destinationIds,
feedback: { rating: 'thumbsUp' },
});
expect(destinationIds).toHaveLength(1);
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenNthCalledWith(
1,
'https://cloud.langfuse.com/api/public/projects',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'https://cloud.langfuse.com/api/public/scores',
expect.any(Object),
);
});
it('does not block trace completion while central project discovery is pending', async () => {
delete process.env.LANGFUSE_PROJECT_ID;
let resolveLookup!: (response: Response) => void;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveLookup = resolve;
}),
);
await loadFeedback();
const { getLangfuseTraceDestinationIds } = await import('./destinations');
const pendingDestinationIds = await getLangfuseTraceDestinationIds(undefined, 'trace-id', true);
expect(pendingDestinationIds).toBeUndefined();
expect(getFetchMock()).toHaveBeenCalledTimes(1);
resolveLookup(
new Response(JSON.stringify({ data: [{ id: 'background-project-id' }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
await new Promise((resolve) => setImmediate(resolve));
await expect(getLangfuseTraceDestinationIds(undefined, 'trace-id', true)).resolves.toHaveLength(
1,
);
});
it('retries a failed central project lookup after the cooldown', async () => {
delete process.env.LANGFUSE_PROJECT_ID;
let now = 1_000;
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(
new Response(JSON.stringify({ data: [{ id: 'recovered-project-id' }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
try {
await loadFeedback();
const { getScoreDestinations } = await import('./destinations');
await new Promise((resolve) => setImmediate(resolve));
expect(await getScoreDestinations(undefined, 'trace-id', true)).toEqual([
expect.objectContaining({ id: undefined, name: 'central' }),
]);
now += 30_001;
expect(await getScoreDestinations(undefined, 'trace-id', true)).toEqual([
expect.objectContaining({ id: expect.any(String), name: 'central' }),
]);
expect(getFetchMock()).toHaveBeenCalledTimes(2);
} finally {
nowSpy.mockRestore();
}
});
it('preserves legacy feedback behavior when a connection has no project identity', async () => {
delete process.env.TENANT_ISOLATION_STRICT;
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
const { sendFeedbackScore } = await loadFeedback();
const { getLangfuseTraceDestinationIds } = await import('./destinations');
const legacyConfig = appConfigWithLangfuse({
projectId: undefined,
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
});
const destinationIds = await getLangfuseTraceDestinationIds(legacyConfig, 'trace-id', true);
await sendFeedbackScore({
traceId: 'trace-id',
sampled: true,
destinationIds,
feedback: { rating: 'thumbsUp' },
appConfig: legacyConfig,
});
expect(destinationIds).toBeUndefined();
expect(getFetchMock()).toHaveBeenCalledTimes(1);
});
it('decrypts encrypted tenant secrets before sending tenant feedback scores', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
@ -370,6 +706,7 @@ describe('Langfuse feedback scores', () => {
feedback: null,
appConfig: {
langfuse: {
enabled: true,
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
@ -450,6 +787,33 @@ describe('Langfuse feedback scores', () => {
);
});
it('skips tenant scores when tenant enabled is missing', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
},
} as AppConfig,
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant Langfuse enabled is the string false', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
@ -617,58 +981,6 @@ describe('Langfuse feedback scores', () => {
);
});
it('skips tenant scores when tenant fanout is disabled in app config', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
fanout: { enabled: false },
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant fanout enabled is the string false', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
fanout: { enabled: 'false' },
} as unknown as AppConfig['langfuse']),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when fanout has no collector URL', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
@ -823,6 +1135,7 @@ describe('Langfuse feedback scores', () => {
it.each(['true', '1', 'yes', 'on'])(
'enables tenant scores when global fanout is %s',
async (value) => {
process.env.TENANT_ISOLATION_STRICT = 'true';
process.env.LANGFUSE_FANOUT_ENABLED = value;
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
@ -852,6 +1165,7 @@ describe('Langfuse feedback scores', () => {
it.each(['false', '0', 'no', 'off'])(
'keeps tenant scores disabled when global fanout is %s',
async (value) => {
process.env.TENANT_ISOLATION_STRICT = 'true';
process.env.LANGFUSE_FANOUT_ENABLED = value;
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';

View file

@ -12,6 +12,8 @@ export type LangfuseFeedbackMetadata = Record<string, string | number | boolean
export type SendFeedbackScoreParams = {
traceId: string;
sampled?: boolean;
destinationIds?: string[];
feedback?: LangfuseFeedback | null;
metadata?: LangfuseFeedbackMetadata;
observationId?: string;
@ -102,6 +104,8 @@ function buildScorePayload({
export async function sendFeedbackScore({
traceId,
sampled,
destinationIds,
feedback,
metadata = {},
observationId,
@ -111,7 +115,10 @@ export async function sendFeedbackScore({
return;
}
const destinations = getScoreDestinations(appConfig);
const destinationIdSet = destinationIds == null ? undefined : new Set(destinationIds);
const destinations = (await getScoreDestinations(appConfig, traceId, sampled)).filter(
({ id }) => destinationIdSet == null || (id != null && destinationIdSet.has(id)),
);
if (destinations.length === 0) {
return;
}

View file

@ -1,2 +1,4 @@
export * from './destinations';
export * from './feedback';
export * from './policy';
export * from './trace';

View file

@ -0,0 +1,121 @@
const envKeys = [
'LANGFUSE_PUBLIC_KEY',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_TRACING_ENABLED',
'LANGFUSE_SAMPLE_RATE',
'LANGFUSE_FANOUT_ENABLED',
'LANGFUSE_FANOUT_COLLECTOR_URL',
'LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED',
'TENANT_ISOLATION_STRICT',
];
function clearEnv() {
for (const key of envKeys) {
delete process.env[key];
}
}
describe('Langfuse policy', () => {
beforeEach(clearEnv);
afterEach(clearEnv);
it('offers connection settings by default in single-tenant deployments', async () => {
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(true);
});
it('hides single-tenant settings when environment credentials own the connection', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
process.env.LANGFUSE_SECRET_KEY = 'sk-env';
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(false);
});
it('does not hide settings for incomplete environment credentials', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(true);
});
it('requires fanout in strict multi-tenant deployments', async () => {
process.env.TENANT_ISOLATION_STRICT = 'true';
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(false);
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
expect(isLangfuseConnectionAvailable()).toBe(true);
});
it('uses explicit fanout routing without requiring strict tenant isolation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
const { isLangfuseConnectionAvailable, usesLangfuseMultiTenantRouting } = await import(
'./policy'
);
expect(usesLangfuseMultiTenantRouting()).toBe(true);
expect(isLangfuseConnectionAvailable()).toBe(true);
});
it('hides fanout connection settings when tenant export is emergency-disabled', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const { isLangfuseConnectionAvailable, isLangfuseFanoutEnabled } = await import('./policy');
expect(isLangfuseFanoutEnabled()).toBe(true);
expect(isLangfuseConnectionAvailable()).toBe(false);
});
it('does not apply the fanout emergency switch to single-tenant connections', async () => {
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(true);
});
it.each(['false', '0', 'no', 'off'])(
'hides settings when tracing is disabled with %s',
async (value) => {
process.env.LANGFUSE_TRACING_ENABLED = value;
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(false);
},
);
it('hides settings when the sample rate is zero', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0';
const { isLangfuseConnectionAvailable } = await import('./policy');
expect(isLangfuseConnectionAvailable()).toBe(false);
});
it('samples traces deterministically at fractional sample rates', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0.5';
const { isLangfuseTraceSampled } = await import('./policy');
expect(isLangfuseTraceSampled('86d413435f8b0d7f32d4d010ce769e2e')).toBe(true);
expect(isLangfuseTraceSampled('658f74b0a232417fc3e6e4d9ef5f563a')).toBe(false);
});
it('clamps numeric sample rates and preserves tracing for invalid values', async () => {
const { getLangfuseSampleRate } = await import('./policy');
process.env.LANGFUSE_SAMPLE_RATE = '-1';
expect(getLangfuseSampleRate()).toBe(0);
process.env.LANGFUSE_SAMPLE_RATE = '2';
expect(getLangfuseSampleRate()).toBe(1);
process.env.LANGFUSE_SAMPLE_RATE = 'invalid';
expect(getLangfuseSampleRate()).toBe(1);
});
});

View file

@ -0,0 +1,81 @@
import { isFalseEnv, isTrueEnv } from './utils';
import { normalizeString } from '~/utils/text';
const DEFAULT_SAMPLE_RATE = 1;
const MAX_TRACE_ID_ACCUMULATION = 0xffffffff;
export function isLangfuseTenantExportEnabled(): boolean {
return !isTrueEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED);
}
export function isLangfuseFanoutEnabled(): boolean {
return (
isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED) &&
normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL) != null
);
}
export function hasLangfuseEnvCredentials(): boolean {
return (
normalizeString(process.env.LANGFUSE_PUBLIC_KEY) != null &&
normalizeString(process.env.LANGFUSE_SECRET_KEY) != null
);
}
export function usesLangfuseMultiTenantRouting(): boolean {
return process.env.TENANT_ISOLATION_STRICT === 'true' || isLangfuseFanoutEnabled();
}
export function getLangfuseSampleRate(): number {
const value = normalizeString(process.env.LANGFUSE_SAMPLE_RATE);
if (value == null) {
return DEFAULT_SAMPLE_RATE;
}
const sampleRate = Number(value);
if (!Number.isFinite(sampleRate)) {
return DEFAULT_SAMPLE_RATE;
}
return Math.min(1, Math.max(0, sampleRate));
}
export function isLangfuseTracingEnabled(): boolean {
return !isFalseEnv(process.env.LANGFUSE_TRACING_ENABLED) && getLangfuseSampleRate() > 0;
}
function traceIdAccumulation(traceId: string): number {
// Match OpenTelemetry's TraceIdRatioBasedSampler so one trace has one stable
// sampling decision across trace export and later feedback scores.
let accumulation = 0;
for (let offset = 0; offset < 32; offset += 8) {
const part = Number.parseInt(traceId.slice(offset, offset + 8), 16);
accumulation = (accumulation ^ part) >>> 0;
}
return accumulation;
}
export function isLangfuseTraceSampled(traceId: string): boolean {
if (!isLangfuseTracingEnabled()) {
return false;
}
const sampleRate = getLangfuseSampleRate();
if (sampleRate >= 1) {
return true;
}
if (!/^[0-9a-f]{32}$/i.test(traceId)) {
return false;
}
return traceIdAccumulation(traceId) < Math.floor(sampleRate * MAX_TRACE_ID_ACCUMULATION);
}
export function isLangfuseConnectionAvailable(): boolean {
if (!isLangfuseTracingEnabled()) {
return false;
}
if (usesLangfuseMultiTenantRouting()) {
return isLangfuseFanoutEnabled() && isLangfuseTenantExportEnabled();
}
return !hasLangfuseEnvCredentials();
}

View file

@ -1210,16 +1210,14 @@ describe('specsConfigSchema', () => {
});
describe('configSchema langfuse', () => {
it('accepts tenant Langfuse fanout config', () => {
it('accepts tenant Langfuse connection config', () => {
const result = configSchema.safeParse({
version: '1.3.7',
langfuse: {
enabled: true,
publicKey: 'pk-lf-tenant',
secretKey: 'sk-lf-tenant',
fanout: {
enabled: true,
collectorUrl: 'http://langfuse-fanout-collector:4318',
},
destination: 'eu',
},
});

View file

@ -440,6 +440,10 @@ export const skillTree = ({ skillId, path = '' }: { skillId: string; path?: stri
/* Skill active states (per-user overrides) */
export const skillStates = () => `${BASE_URL}/api/user/settings/skills/active`;
/* Langfuse connection (admin) */
export const adminLangfuseConnection = () => `${BASE_URL}/api/admin/langfuse/connection`;
export const adminLangfuseConnectionTest = () => `${adminLangfuseConnection()}/test`;
/* Tool favorites (starred marketplace items) */
export const toolFavorites = () => `${BASE_URL}/api/user/settings/favorites/tools`;
export const toolFavorite = (itemType: string, itemId: string) =>

View file

@ -20,6 +20,9 @@ export { MAX_SUBAGENTS } from './limits';
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml'];
export const BASE_ONLY_CONFIG_SECTIONS = [] as const;
/** Sections that may be stored in the tenant's base config document but must
* not be overridden or tombstoned by role, group, or user config documents. */
export const BASE_PRINCIPAL_CONFIG_SECTIONS = ['langfuse'] as const;
export const defaultRetrievalModels = [
'gpt-4o',
@ -1543,6 +1546,8 @@ export type StartupConfigContext = 'share';
export type TStartupConfig = {
appTitle: string;
socialLogins?: string[];
langfuseFanoutEnabled?: boolean;
langfuseConnectionAccess?: boolean;
interface?: TInterfaceConfig;
turnstile?: TTurnstileConfig;
balance?: TBalanceConfig;
@ -1905,16 +1910,13 @@ export const langfuseConfigSchema = z.object({
enabled: z.boolean().optional(),
publicKey: z.string().optional(),
secretKey: z.string().optional(),
/** Stable Langfuse project identity returned when credentials are verified. */
projectId: z.string().optional(),
/** Masked preview of the secret key, stored at write time so
* admin reads can show which secret key is configured without returning the secret. */
secretKeyPreview: z.string().optional(),
/** Routing key for one of the deployment-configured tenant Langfuse destinations. */
destination: z.string().optional(),
fanout: z
.object({
enabled: z.boolean().optional(),
})
.optional(),
});
export type LangfuseConfig = z.infer<typeof langfuseConfigSchema>;
@ -2693,6 +2695,10 @@ export enum SettingsTabValues {
* Tab for Speech Settings
*/
SPEECH = 'speech',
/**
* Tab for Langfuse Settings
*/
LANGFUSE = 'langfuse',
/**
* Tab for Beta Features
*/

View file

@ -16,6 +16,22 @@ import request from './request';
import * as s from './schemas';
import * as r from './roles';
export function getLangfuseConnection(): Promise<t.TLangfuseConnectionStatus> {
return request.get(endpoints.adminLangfuseConnection());
}
export function updateLangfuseConnection(
payload: t.TUpdateLangfuseConnectionRequest,
): Promise<t.TLangfuseConnectionStatus> {
return request.put(endpoints.adminLangfuseConnection(), payload);
}
export function testLangfuseConnection(
payload: t.TLangfuseConnectionTestRequest,
): Promise<t.TLangfuseConnectionTestResponse> {
return request.post(endpoints.adminLangfuseConnectionTest(), payload);
}
export function revokeUserKey(name: string): Promise<unknown> {
return request.delete(endpoints.revokeUserKey(name));
}

View file

@ -8,6 +8,7 @@ export enum QueryKeys {
searchConversations = 'searchConversations',
conversation = 'conversation',
searchEnabled = 'searchEnabled',
langfuseConnection = 'langfuseConnection',
user = 'user',
name = 'name', // user key name
models = 'models',
@ -93,6 +94,8 @@ export const DynamicQueryKeys = {
} as const;
export enum MutationKeys {
updateLangfuseConnection = 'updateLangfuseConnection',
testLangfuseConnection = 'testLangfuseConnection',
createAgentApiKey = 'createAgentApiKey',
deleteAgentApiKey = 'deleteAgentApiKey',
fileUpload = 'fileUpload',

View file

@ -879,3 +879,46 @@ export type TUpdateSkillNodeRequest = {
parentId?: string | null;
order?: number;
};
export type TLangfuseConnectionStatus = {
configured: boolean;
enabled: boolean;
destinations: TLangfuseDestinationOption[];
destination?: string;
publicKey?: string;
secretKeyPreview?: string;
updatedAt?: string;
};
export type TLangfuseDestinationOption = {
key: string;
baseUrl: string;
};
export type TUpdateLangfuseConnectionRequest = {
enabled: boolean;
destination: string;
publicKey: string;
secretKey?: string;
};
export type TLangfuseConnectionTestRequest = {
destination: string;
publicKey: string;
secretKey?: string;
};
export type TLangfuseConnectionTestErrorCode =
| 'invalid_credentials'
| 'access_denied'
| 'rate_limited'
| 'server_error'
| 'timeout'
| 'unreachable'
| 'missing_secret'
| 'stored_secret_unavailable'
| 'unexpected_response';
export type TLangfuseConnectionTestResponse =
| { success: true }
| { success: false; errorCode: TLangfuseConnectionTestErrorCode };

View file

@ -1,16 +1,18 @@
import { INTERFACE_PERMISSION_FIELDS, PermissionTypes } from 'librechat-data-provider';
import type { AppConfig, IConfig } from '~/types';
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
import { mergeConfigOverrides } from './resolution';
function fakeConfig(
overrides: Record<string, unknown>,
priority: number,
tombstones?: string[],
principalId = 'test',
): IConfig {
return {
_id: 'fake',
principalType: 'role',
principalId: 'test',
principalId,
principalModel: 'Role',
priority,
overrides,
@ -36,6 +38,37 @@ describe('mergeConfigOverrides', () => {
expect(mergeConfigOverrides(baseConfig, undefined as unknown as IConfig[])).toBe(baseConfig);
});
it('applies tenant-wide Langfuse settings only from the base principal', () => {
const configs = [
fakeConfig(
{ langfuse: { enabled: true, destination: 'eu', publicKey: 'pk-base' } },
10,
undefined,
BASE_CONFIG_PRINCIPAL_ID,
),
fakeConfig({ langfuse: { enabled: false, publicKey: 'pk-role' } }, 100),
];
const result = mergeConfigOverrides(baseConfig, configs);
expect(result.langfuse).toMatchObject({
enabled: true,
destination: 'eu',
publicKey: 'pk-base',
});
});
it('ignores tenant-wide Langfuse tombstones outside the base principal', () => {
const base = {
...baseConfig,
langfuse: { enabled: true, destination: 'eu', publicKey: 'pk-base' },
} as AppConfig;
const result = mergeConfigOverrides(base, [fakeConfig({}, 100, ['langfuse'])]);
expect(result.langfuse).toEqual(base.langfuse);
});
it('deep merges interface UI fields into interfaceConfig', () => {
const configs = [fakeConfig({ interface: { modelSelect: false } }, 10)];
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;

View file

@ -1,16 +1,19 @@
import {
BASE_PRINCIPAL_CONFIG_SECTIONS,
BASE_ONLY_CONFIG_SECTIONS,
INTERFACE_PERMISSION_FIELDS,
PERMISSION_SUB_KEYS,
} from 'librechat-data-provider';
import type { TCustomConfig } from 'librechat-data-provider';
import type { AppConfig, IConfig } from '~/types';
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
type AnyObject = { [key: string]: unknown };
const MAX_MERGE_DEPTH = 10;
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
const BASE_ONLY_OVERRIDE_SECTIONS = new Set<string>(BASE_ONLY_CONFIG_SECTIONS);
const BASE_PRINCIPAL_OVERRIDE_SECTIONS = new Set<string>(BASE_PRINCIPAL_CONFIG_SECTIONS);
/**
* Paths within the config tree where arrays of objects should be merged by
@ -193,9 +196,13 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]):
let merged = { ...baseConfig };
for (const config of sorted) {
const isBasePrincipal = config.principalId?.toString() === BASE_CONFIG_PRINCIPAL_ID;
if (Array.isArray(config.tombstones)) {
for (const path of config.tombstones) {
if (typeof path === 'string') {
if (
typeof path === 'string' &&
(isBasePrincipal || !BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(path.split('.')[0]))
) {
merged = deletePath(merged, remapOverridePath(path));
}
}
@ -204,7 +211,10 @@ export function mergeConfigOverrides(baseConfig: AppConfig, configs: IConfig[]):
if (config.overrides && typeof config.overrides === 'object') {
const remapped: AnyObject = {};
for (const [key, value] of Object.entries(config.overrides)) {
if (BASE_ONLY_OVERRIDE_SECTIONS.has(key)) {
if (
BASE_ONLY_OVERRIDE_SECTIONS.has(key) ||
(!isBasePrincipal && BASE_PRINCIPAL_OVERRIDE_SECTIONS.has(key))
) {
continue;
}
const mappedKey = OVERRIDE_KEY_MAP[key as keyof typeof OVERRIDE_KEY_MAP] ?? key;

View file

@ -160,6 +160,22 @@ describe('Message Operations', () => {
expect(updatedMessage?.text).toBe('Updated text');
});
it('returns the generation-time Langfuse routing decisions with feedback updates', async () => {
await saveMessage(mockCtx, {
...mockMessageData,
langfuseSampled: true,
langfuseDestinationIds: ['destination-1'],
});
const result = await updateMessage(mockCtx.userId, {
messageId: 'msg123',
feedback: { rating: 'thumbsUp', tag: undefined },
});
expect(result?.langfuseSampled).toBe(true);
expect(result?.langfuseDestinationIds).toEqual(['destination-1']);
});
it('should throw an error if message is not found', async () => {
await expect(
updateMessage(mockCtx.userId, { messageId: 'nonexistent', text: 'Test' }),

View file

@ -456,6 +456,8 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
tokenCount: updatedMessage.tokenCount,
feedback: updatedMessage.feedback,
endpoint: updatedMessage.endpoint,
langfuseSampled: updatedMessage.langfuseSampled,
langfuseDestinationIds: updatedMessage.langfuseDestinationIds,
};
} catch (err) {
logger.error('Error updating message:', err);

View file

@ -97,6 +97,13 @@ const messageSchema: Schema<IMessage> = new Schema(
default: undefined,
required: false,
},
langfuseSampled: {
type: Boolean,
},
langfuseDestinationIds: {
type: [String],
default: undefined,
},
_meiliIndex: {
type: Boolean,
required: false,

View file

@ -27,6 +27,8 @@ export interface IMessage extends Document {
tag: TFeedbackTag | undefined;
text?: string;
};
langfuseSampled?: boolean;
langfuseDestinationIds?: string[];
_meiliIndex?: boolean;
files?: unknown[];
plugin?: {