From af795be0c2c1a8a17e6498980720144af03ddfb2 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:33:10 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A2=20feat:=20Langfuse=20Fanout=20Conn?= =?UTF-8?q?ection=20Setting=20(#14108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Danny Avila --- .env.example | 17 +- api/app/clients/BaseClient.js | 16 + api/app/clients/specs/BaseClient.test.js | 72 ++ api/server/index.js | 1 + api/server/routes/__tests__/config.spec.js | 124 +++ .../__tests__/messages-feedback.spec.js | 4 + api/server/routes/admin/langfuse.js | 50 ++ api/server/routes/admin/langfuse.test.js | 108 +++ api/server/routes/config.js | 32 +- api/server/routes/index.js | 2 + api/server/routes/messages.js | 2 + .../assets/langfuse-icon-monochrome.svg | 3 + .../src/components/Nav/Settings/Content.tsx | 7 +- .../src/components/Nav/Settings/Section.tsx | 6 +- .../Nav/Settings/__tests__/Sidebar.spec.tsx | 11 + .../Nav/Settings/__tests__/registry.spec.ts | 56 ++ .../src/components/Nav/Settings/context.tsx | 3 + .../src/components/Nav/Settings/registry.tsx | 11 + client/src/components/Nav/Settings/types.ts | 28 + .../Integrations/LangfuseConnection.tsx | 613 +++++++++++++ .../__tests__/LangfuseConnection.spec.tsx | 619 ++++++++++++++ client/src/data-provider/Langfuse/index.ts | 45 + client/src/data-provider/index.ts | 1 + client/src/locales/en/translation.json | 32 + packages/api/src/admin/config.handler.spec.ts | 349 +------- packages/api/src/admin/config.ts | 55 +- packages/api/src/admin/index.ts | 2 + .../api/src/admin/langfuse.handler.spec.ts | 803 ++++++++++++++++++ packages/api/src/admin/langfuse.ts | 422 +++++++++ .../__tests__/run-summarization.test.ts | 97 +-- packages/api/src/agents/run.ts | 1 + packages/api/src/langfuse/config.spec.ts | 192 ++++- packages/api/src/langfuse/config.ts | 59 +- packages/api/src/langfuse/destinations.ts | 241 ++++-- packages/api/src/langfuse/feedback.spec.ts | 420 +++++++-- packages/api/src/langfuse/feedback.ts | 9 +- packages/api/src/langfuse/index.ts | 2 + packages/api/src/langfuse/policy.spec.ts | 121 +++ packages/api/src/langfuse/policy.ts | 81 ++ .../specs/config-schemas.spec.ts | 8 +- packages/data-provider/src/api-endpoints.ts | 4 + packages/data-provider/src/config.ts | 16 +- packages/data-provider/src/data-service.ts | 16 + packages/data-provider/src/keys.ts | 3 + packages/data-provider/src/types.ts | 43 + .../data-schemas/src/app/resolution.spec.ts | 35 +- packages/data-schemas/src/app/resolution.ts | 14 +- .../data-schemas/src/methods/message.spec.ts | 16 + packages/data-schemas/src/methods/message.ts | 2 + packages/data-schemas/src/schema/message.ts | 7 + packages/data-schemas/src/types/message.ts | 2 + 51 files changed, 4356 insertions(+), 527 deletions(-) create mode 100644 api/server/routes/admin/langfuse.js create mode 100644 api/server/routes/admin/langfuse.test.js create mode 100644 client/public/assets/langfuse-icon-monochrome.svg create mode 100644 client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Integrations/__tests__/LangfuseConnection.spec.tsx create mode 100644 client/src/data-provider/Langfuse/index.ts create mode 100644 packages/api/src/admin/langfuse.handler.spec.ts create mode 100644 packages/api/src/admin/langfuse.ts create mode 100644 packages/api/src/langfuse/policy.spec.ts create mode 100644 packages/api/src/langfuse/policy.ts diff --git a/.env.example b/.env.example index a51cf71664..19570809a2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index c97dbb25b6..f85d53b1da 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -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, diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index da3f80bc0b..acf3c0c69d 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -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', diff --git a/api/server/index.js b/api/server/index.js index eca877e8de..a27cedbb50 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 369f968b5d..288d188d3d 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -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'); diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js index 2669ef67d0..6325e9bc14 100644 --- a/api/server/routes/__tests__/messages-feedback.spec.js +++ b/api/server/routes/__tests__/messages-feedback.spec.js @@ -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', diff --git a/api/server/routes/admin/langfuse.js b/api/server/routes/admin/langfuse.js new file mode 100644 index 0000000000..2508af2a7c --- /dev/null +++ b/api/server/routes/admin/langfuse.js @@ -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; diff --git a/api/server/routes/admin/langfuse.test.js b/api/server/routes/admin/langfuse.test.js new file mode 100644 index 0000000000..76221f05cd --- /dev/null +++ b/api/server/routes/admin/langfuse.test.js @@ -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(); + }); +}); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 03044f533e..a93ebad342 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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), diff --git a/api/server/routes/index.js b/api/server/routes/index.js index ac2b38f579..c2578e4353 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -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, diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index bb1e64ce57..1c4a8ddc74 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -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: { diff --git a/client/public/assets/langfuse-icon-monochrome.svg b/client/public/assets/langfuse-icon-monochrome.svg new file mode 100644 index 0000000000..b775c8768e --- /dev/null +++ b/client/public/assets/langfuse-icon-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/components/Nav/Settings/Content.tsx b/client/src/components/Nav/Settings/Content.tsx index dd8bc2c703..b91dc6dad7 100644 --- a/client/src/components/Nav/Settings/Content.tsx +++ b/client/src/components/Nav/Settings/Content.tsx @@ -64,7 +64,12 @@ export default function Content({ activeTab, query, ctx }: ContentProps) { return null; } return ( -
+
{entries.map((e) => { const Cmp = e.Component; return ( diff --git a/client/src/components/Nav/Settings/Section.tsx b/client/src/components/Nav/Settings/Section.tsx index 49c44db632..9803e348ec 100644 --- a/client/src/components/Nav/Settings/Section.tsx +++ b/client/src/components/Nav/Settings/Section.tsx @@ -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 (

+ {icon} {heading}

= {}, 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'); diff --git a/client/src/components/Nav/Settings/__tests__/registry.spec.ts b/client/src/components/Nav/Settings/__tests__/registry.spec.ts index 46a57908e8..c137b9cd48 100644 --- a/client/src/components/Nav/Settings/__tests__/registry.spec.ts +++ b/client/src/components/Nav/Settings/__tests__/registry.spec.ts @@ -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); + }); + }); }); diff --git a/client/src/components/Nav/Settings/context.tsx b/client/src/components/Nav/Settings/context.tsx index 1126a9096d..176b8b584b 100644 --- a/client/src/components/Nav/Settings/context.tsx +++ b/client/src/components/Nav/Settings/context.tsx @@ -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, ], ); } diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 195199b97e..3b66289a05 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -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', diff --git a/client/src/components/Nav/Settings/types.ts b/client/src/components/Nav/Settings/types.ts index 05a87c9c70..e54dcedd07 100644 --- a/client/src/components/Nav/Settings/types.ts +++ b/client/src/components/Nav/Settings/types.ts @@ -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', diff --git a/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx b/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx new file mode 100644 index 0000000000..63b5cebf70 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Integrations/LangfuseConnection.tsx @@ -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(); + 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('idle'); + const [connectionTestMessage, setConnectionTestMessage] = useState(''); + const autoTestedConnectionRef = useRef(); + const connectionTestRequestRef = useRef(0); + const publicKeyInputRef = useRef(null); + const secretKeyInputRef = useRef(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 ( +
+ + {localize('com_ui_loading')} +
+ ); + } + + if (isConnectionError && connectionStatus == null) { + return ( +
+

{localize('com_ui_langfuse_load_error')}

+ +
+ ); + } + + return ( +
+ +
+
+
+
+
{localize('com_ui_langfuse_title')}
+
+ {localize('com_ui_beta')} +
+ + + +
+
+ {localize('com_ui_langfuse_description')} +
+
+
+
+ {connectionTestState === 'checking' ? ( + + ) : ( + + )} + {connectionStatusLabel} +
+
+ + + +

{localize('com_ui_langfuse_beta_info')}

+
+
+
+ +
+ + +
+ +
+ + {secretConfigured && !isEditingPublicKey && ( + + )} + {publicKeyInputVisible && ( + { + connectionTestRequestRef.current += 1; + const nextPublicKey = e.target.value; + setPublicKey(nextPublicKey); + if ( + secretConfigured && + nextPublicKey.trim() !== (connectionStatus?.publicKey ?? '') + ) { + setIsEditingSecretKey(true); + } + setConnectionTestState('unverified'); + setConnectionTestMessage(''); + }} + /> + )} +
+ +
+ + {secretConfigured && !isEditingSecretKey && ( + + )} + {secretInputVisible && ( + { + connectionTestRequestRef.current += 1; + setSecretKey(e.target.value); + setConnectionTestState('unverified'); + setConnectionTestMessage(''); + }} + /> + )} +
+ +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/Integrations/__tests__/LangfuseConnection.spec.tsx b/client/src/components/Nav/SettingsTabs/Integrations/__tests__/LangfuseConnection.spec.tsx new file mode 100644 index 0000000000..506915da4e --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Integrations/__tests__/LangfuseConnection.spec.tsx @@ -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(); + 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(); + + 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(); + + 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(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + }); +}); diff --git a/client/src/data-provider/Langfuse/index.ts b/client/src/data-provider/Langfuse/index.ts new file mode 100644 index 0000000000..75edb51236 --- /dev/null +++ b/client/src/data-provider/Langfuse/index.ts @@ -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 => + useQuery( + [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] }, + ); diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index e4be6aee0b..6c5c971c6d 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -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'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 9234370159..5ca95632c6 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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.", diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 100f4a566a..7b122a0e00 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -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 }; - }; - 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 }; - }; - 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 }; - }; - 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 () => { diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index 171bdbdaab..5ed90383d8 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -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(BASE_ONLY_CONFIG_SECTIONS); +const BASE_PRINCIPAL_OVERRIDE_SECTIONS = new Set(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)[key]; + logger.warn( + `[adminConfig] Stripping dedicated tenant-wide config section "${key}" from the generic config API`, + ); + } + } const iface = (overrides as Record).interface; if (iface != null && typeof iface === 'object' && !Array.isArray(iface)) { const filteredIface: Record = {}; @@ -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)[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 | undefined + )?.[section]; + if (storedSection !== undefined) { + (preservedOverrides as Record)[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`, diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index 1a572804e2..ef8ec27ad0 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -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'; diff --git a/packages/api/src/admin/langfuse.handler.spec.ts b/packages/api/src/admin/langfuse.handler.spec.ts new file mode 100644 index 0000000000..c08b9af7f7 --- /dev/null +++ b/packages/api/src/admin/langfuse.handler.spec.ts @@ -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 as ServerRequest; +} + +interface MockRes { + statusCode: number; + body: undefined | Record; + 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 as Response & MockRes; +} + +function baseConfigDoc(langfuse: Record) { + 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): Record { + const langfuse: Record = {}; + 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(); + }); + }); +}); diff --git a/packages/api/src/admin/langfuse.ts b/packages/api/src/admin/langfuse.ts new file mode 100644 index 0000000000..8ae3c6dbc9 --- /dev/null +++ b/packages/api/src/admin/langfuse.ts @@ -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; + patchConfigFields: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + principalModel: PrincipalModel, + fields: Record, + priority: number, + session?: ClientSession, + ) => Promise; + toggleConfigActive: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + isActive: boolean, + session?: ClientSession, + ) => Promise; + invalidateConfigCaches?: (tenantId?: string) => Promise; +} + +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 | 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 { + 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; + updateConnection: (req: ServerRequest, res: Response) => Promise; + testConnection: (req: ServerRequest, res: Response) => Promise; +} { + const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } = + deps; + + function findBaseConfig(options?: { includeInactive?: boolean }): Promise { + 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 { + 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 { + 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 = { + '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 { + 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 }; +} diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 95fd6f19d9..f2c8f1d21d 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -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'], }); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 7249329f3b..e99cdff2a3 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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, }), diff --git a/packages/api/src/langfuse/config.spec.ts b/packages/api/src/langfuse/config.spec.ts index 5e42f15e71..44d4a5f097 100644 --- a/packages/api/src/langfuse/config.spec.ts +++ b/packages/api/src/langfuse/config.spec.ts @@ -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'], }); }); diff --git a/packages/api/src/langfuse/config.ts b/packages/api/src/langfuse/config.ts index c2452e5865..e29ef4a4a1 100644 --- a/packages/api/src/langfuse/config.ts +++ b/packages/api/src/langfuse/config.ts @@ -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; -type LangfuseAppConfig = NonNullable; -export type LangfuseFanoutConfig = LangfuseAppConfig['fanout']; type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & { librechatTraceAttributes?: Record; }; @@ -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, diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts index ad5e09bd1f..40562198d1 100644 --- a/packages/api/src/langfuse/destinations.ts +++ b/packages/api/src/langfuse/destinations.ts @@ -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; + retryAt: number; +}; +const centralProjectIdCache = new Map(); 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 { + 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 { // 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(); - 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 { + 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(); + 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 { + 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); } diff --git a/packages/api/src/langfuse/feedback.spec.ts b/packages/api/src/langfuse/feedback.spec.ts index 075d0b10f5..e114cbc967 100644 --- a/packages/api/src/langfuse/feedback.spec.ts +++ b/packages/api/src/langfuse/feedback.spec.ts @@ -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; @@ -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((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'; diff --git a/packages/api/src/langfuse/feedback.ts b/packages/api/src/langfuse/feedback.ts index 0e58ade588..6fffa64da4 100644 --- a/packages/api/src/langfuse/feedback.ts +++ b/packages/api/src/langfuse/feedback.ts @@ -12,6 +12,8 @@ export type LangfuseFeedbackMetadata = Record destinationIdSet == null || (id != null && destinationIdSet.has(id)), + ); if (destinations.length === 0) { return; } diff --git a/packages/api/src/langfuse/index.ts b/packages/api/src/langfuse/index.ts index b22dba4698..323344cb27 100644 --- a/packages/api/src/langfuse/index.ts +++ b/packages/api/src/langfuse/index.ts @@ -1,2 +1,4 @@ +export * from './destinations'; export * from './feedback'; +export * from './policy'; export * from './trace'; diff --git a/packages/api/src/langfuse/policy.spec.ts b/packages/api/src/langfuse/policy.spec.ts new file mode 100644 index 0000000000..bbac970538 --- /dev/null +++ b/packages/api/src/langfuse/policy.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/langfuse/policy.ts b/packages/api/src/langfuse/policy.ts new file mode 100644 index 0000000000..c1d3c79a19 --- /dev/null +++ b/packages/api/src/langfuse/policy.ts @@ -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(); +} diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 19b9677b25..4554423d39 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -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', }, }); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index ba786340d6..e0ba1320ae 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -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) => diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5244d8c8a7..c4c450b43a 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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; @@ -2693,6 +2695,10 @@ export enum SettingsTabValues { * Tab for Speech Settings */ SPEECH = 'speech', + /** + * Tab for Langfuse Settings + */ + LANGFUSE = 'langfuse', /** * Tab for Beta Features */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 59a651659b..d1ab0c0388 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -16,6 +16,22 @@ import request from './request'; import * as s from './schemas'; import * as r from './roles'; +export function getLangfuseConnection(): Promise { + return request.get(endpoints.adminLangfuseConnection()); +} + +export function updateLangfuseConnection( + payload: t.TUpdateLangfuseConnectionRequest, +): Promise { + return request.put(endpoints.adminLangfuseConnection(), payload); +} + +export function testLangfuseConnection( + payload: t.TLangfuseConnectionTestRequest, +): Promise { + return request.post(endpoints.adminLangfuseConnectionTest(), payload); +} + export function revokeUserKey(name: string): Promise { return request.delete(endpoints.revokeUserKey(name)); } diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index e30484d1ed..f0a122da5d 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -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', diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 36a5348035..6053f14cdb 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -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 }; diff --git a/packages/data-schemas/src/app/resolution.spec.ts b/packages/data-schemas/src/app/resolution.spec.ts index d3020c0707..956e58056a 100644 --- a/packages/data-schemas/src/app/resolution.spec.ts +++ b/packages/data-schemas/src/app/resolution.spec.ts @@ -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, 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; diff --git a/packages/data-schemas/src/app/resolution.ts b/packages/data-schemas/src/app/resolution.ts index 4f17acac2c..6e45cb1605 100644 --- a/packages/data-schemas/src/app/resolution.ts +++ b/packages/data-schemas/src/app/resolution.ts @@ -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(BASE_ONLY_CONFIG_SECTIONS); +const BASE_PRINCIPAL_OVERRIDE_SECTIONS = new Set(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; diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index afcda5a56b..4b1d5ccd50 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -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' }), diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 1b61726654..e596e6e5e7 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -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); diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index d7fd3fafb0..99415cb561 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -97,6 +97,13 @@ const messageSchema: Schema = new Schema( default: undefined, required: false, }, + langfuseSampled: { + type: Boolean, + }, + langfuseDestinationIds: { + type: [String], + default: undefined, + }, _meiliIndex: { type: Boolean, required: false, diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index 96d9c1ff35..60e354a870 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -27,6 +27,8 @@ export interface IMessage extends Document { tag: TFeedbackTag | undefined; text?: string; }; + langfuseSampled?: boolean; + langfuseDestinationIds?: string[]; _meiliIndex?: boolean; files?: unknown[]; plugin?: {