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?: {