diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index fb7d591517..871c690dde 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); @@ -390,6 +392,7 @@ describe('GET /api/config', () => { 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); @@ -414,15 +417,14 @@ describe('GET /api/config', () => { process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout:4318'; const app = createApp({ ...mockUser, role: 'DELEGATED_ADMIN' }); - mockHasCapability.mockImplementation(async (_user, capability) => - ['access:admin', 'manage:configs:langfuse'].includes(capability), - ); - let response = await request(app).get('/api/config'); - expect(response.body.langfuseConnectionAccess).toBe(true); - 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); diff --git a/api/server/routes/admin/langfuse.js b/api/server/routes/admin/langfuse.js index 227438d14b..2508af2a7c 100644 --- a/api/server/routes/admin/langfuse.js +++ b/api/server/routes/admin/langfuse.js @@ -1,7 +1,10 @@ const express = require('express'); const { createAdminLangfuseHandlers } = require('@librechat/api'); -const { configCapability, SystemCapabilities } = require('@librechat/data-schemas'); -const { requireCapability } = require('~/server/middleware/roles/capabilities'); +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'); @@ -9,11 +12,32 @@ const db = require('~/models'); const router = express.Router(); const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); -const requireLangfuseManage = requireCapability(configCapability('langfuse')); + +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, }); diff --git a/api/server/routes/admin/langfuse.test.js b/api/server/routes/admin/langfuse.test.js index 13a6225078..76221f05cd 100644 --- a/api/server/routes/admin/langfuse.test.js +++ b/api/server/routes/admin/langfuse.test.js @@ -2,7 +2,9 @@ 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'); @@ -23,7 +25,6 @@ const mockHandlers = { jest.mock('@librechat/data-schemas', () => ({ SystemCapabilities: { ACCESS_ADMIN: 'access:admin' }, - configCapability: (section) => `manage:configs:${section}`, })); jest.mock('@librechat/api', () => ({ @@ -32,6 +33,7 @@ jest.mock('@librechat/api', () => ({ jest.mock('~/server/middleware/roles/capabilities', () => ({ requireCapability: mockRequireCapability, + hasConfigCapability: mockHasConfigCapability, })); jest.mock('~/server/middleware', () => ({ @@ -45,6 +47,7 @@ jest.mock('~/server/services/Config', () => ({ jest.mock('~/models', () => ({ findConfigByPrincipal: jest.fn(), patchConfigFields: jest.fn(), + toggleConfigActive: jest.fn(), })); describe('admin Langfuse routes', () => { @@ -59,6 +62,7 @@ describe('admin Langfuse routes', () => { beforeEach(() => { deniedCapability = undefined; + canManageLangfuse = true; middlewareCalls.length = 0; jest.clearAllMocks(); }); @@ -67,7 +71,16 @@ describe('admin Langfuse routes', () => { const response = await request(createApp()).get('/api/admin/langfuse/connection').expect(200); expect(response.body).toEqual({ handler: 'get' }); - expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'manage:configs:langfuse']); + 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); }); @@ -81,12 +94,12 @@ describe('admin Langfuse routes', () => { expect(response.body).toEqual({ handler: handlerName === 'updateConnection' ? 'update' : 'test', }); - expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'manage:configs:langfuse']); + expect(middlewareCalls).toEqual(['jwt', 'access:admin']); expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1); }); it('blocks updates when the user lacks Langfuse manage access', async () => { - deniedCapability = 'manage:configs:langfuse'; + canManageLangfuse = false; await request(createApp()).put('/api/admin/langfuse/connection').send({}).expect(403); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 8dcba1106c..818b304505 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -12,13 +12,8 @@ const { isFileSnapshotEnabled, } = require('@librechat/api'); const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); -const { - configCapability, - logger, - getTenantId, - SystemCapabilities, -} = require('@librechat/data-schemas'); -const { hasCapability } = require('~/server/middleware/roles/capabilities'); +const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); +const { hasCapability, hasConfigCapability } = require('~/server/middleware/roles/capabilities'); const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getRumConfig } = require('~/server/services/Config/rum'); const { getAppConfig } = require('~/server/services/Config/app'); @@ -270,7 +265,7 @@ router.get('/', async function (req, res) { }; const [hasAdminAccess, canManageLangfuse] = await Promise.all([ hasCapability(capabilityUser, SystemCapabilities.ACCESS_ADMIN), - hasCapability(capabilityUser, configCapability('langfuse')), + hasConfigCapability(capabilityUser, 'langfuse'), ]); langfuseConnectionAccess = hasAdminAccess && canManageLangfuse; } 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}

{ expect(screen.getByText('About')).toBeInTheDocument(); }); + it('shows the Langfuse tab when Langfuse is available to the user', () => { + setup({ langfuseFanoutEnabled: true, langfuseConnectionAccess: true }); + expect(screen.getByText('Langfuse')).toBeInTheDocument(); + }); + + it('hides the Langfuse tab without Langfuse connection access', () => { + setup({ langfuseFanoutEnabled: true, 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 583189e970..6c9f2c6a47 100644 --- a/client/src/components/Nav/Settings/__tests__/registry.spec.ts +++ b/client/src/components/Nav/Settings/__tests__/registry.spec.ts @@ -1,4 +1,5 @@ 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'; @@ -52,6 +53,13 @@ describe('settings registry', () => { 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 fanout is enabled and the user can manage it', () => { expect( langfuseEntry?.show?.({ diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 9b5aade473..76a51f3499 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -501,11 +501,11 @@ export const registry: SettingEntry[] = [ labelKey: 'com_ui_settings_label_revoke_keys', Component: RevokeKeys, }, - // Data controls ยท Integrations + // Langfuse { id: 'langfuseConnection', - tab: DATA, - section: 'integrations', + tab: SettingsTabValues.LANGFUSE, + section: 'langfuse', labelKey: 'com_ui_langfuse_title', keywords: ['langfuse', 'observability', 'tracing', 'telemetry', 'traces'], show: (ctx) => ctx.langfuseConnectionAccess && ctx.langfuseFanoutEnabled, diff --git a/client/src/components/Nav/Settings/types.ts b/client/src/components/Nav/Settings/types.ts index aea067ef89..1dbfd92885 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,7 +28,7 @@ export type SectionId = | 'memory' | 'data' | 'apiKeys' - | 'integrations' + | 'langfuse' | 'danger' | 'profile' | 'security' @@ -64,6 +65,7 @@ export interface SettingEntry { export interface SectionMeta { id: SectionId; labelKey: TranslationKeys; + icon?: ReactNode; danger?: boolean; } @@ -75,6 +77,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, @@ -107,6 +120,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 && ctx.langfuseFanoutEnabled, + }, { id: SettingsTabValues.DATA, labelKey: 'com_ui_settings_tab_data', @@ -115,7 +141,6 @@ export const TABS: TabMeta[] = [ { id: 'memory', labelKey: 'com_ui_settings_section_memory' }, { id: 'data', labelKey: 'com_ui_settings_section_data' }, { id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' }, - { id: 'integrations', labelKey: 'com_ui_settings_section_integrations' }, { id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true }, ], }, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 978010a09e..74a5b61279 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1760,6 +1760,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/langfuse.handler.spec.ts b/packages/api/src/admin/langfuse.handler.spec.ts index f1d33cf431..c04464fdb1 100644 --- a/packages/api/src/admin/langfuse.handler.spec.ts +++ b/packages/api/src/admin/langfuse.handler.spec.ts @@ -66,6 +66,7 @@ function baseConfigDoc(langfuse: Record) { principalType: 'role', principalId: '__base__', priority: 10, + isActive: true, overrides: { langfuse }, updatedAt: new Date('2026-06-29T00:00:00.000Z'), }; @@ -79,6 +80,12 @@ function createHandlers(overrides = {}) { .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, }; @@ -196,6 +203,33 @@ describe('createAdminLangfuseHandlers', () => { 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', () => { @@ -349,6 +383,40 @@ describe('createAdminLangfuseHandlers', () => { expect(deps.patchConfigFields).toHaveBeenCalledTimes(1); expect(deps.patchConfigFields.mock.calls[0][3]['langfuse.enabled']).toBe(true); }); + + 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 { handlers, deps } = createHandlers({ + 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.toggleConfigActive).toHaveBeenCalledWith('role', '__base__', true); + expect(res.body).toMatchObject({ configured: true, enabled: true }); + }); }); describe('testConnection', () => { diff --git a/packages/api/src/admin/langfuse.ts b/packages/api/src/admin/langfuse.ts index 5652ec6de1..7ccbe8b24a 100644 --- a/packages/api/src/admin/langfuse.ts +++ b/packages/api/src/admin/langfuse.ts @@ -39,6 +39,12 @@ export interface AdminLangfuseDeps { priority: number, session?: ClientSession, ) => Promise; + toggleConfigActive: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + isActive: boolean, + session?: ClientSession, + ) => Promise; invalidateConfigCaches?: (tenantId?: string) => Promise; } @@ -53,9 +59,10 @@ function readStoredLangfuse(config: IConfig | null): LangfuseConfig | undefined function buildStatus(config: IConfig | null): TLangfuseConnectionStatus { const stored = readStoredLangfuse(config); + const configured = Boolean(stored?.publicKey && stored?.secretKey); return { - configured: Boolean(stored?.publicKey && stored?.secretKey), - enabled: stored?.enabled === true, + configured, + enabled: configured && stored?.enabled === true, destinations: getLangfuseTenantDestinations(), destination: stored?.destination, publicKey: stored?.publicKey, @@ -155,12 +162,11 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { updateConnection: (req: ServerRequest, res: Response) => Promise; testConnection: (req: ServerRequest, res: Response) => Promise; } { - const { findConfigByPrincipal, patchConfigFields, invalidateConfigCaches } = deps; + const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } = + deps; function findBaseConfig(): Promise { - return findConfigByPrincipal(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID, { - includeInactive: true, - }); + return findConfigByPrincipal(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID); } async function getConnection(req: ServerRequest, res: Response): Promise { @@ -244,13 +250,16 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { fields['langfuse.secretKey'] = secretKey; } - const updated = await patchConfigFields( + 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), diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 9a07ee1df5..46c6c77f0f 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -1203,6 +1203,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1233,6 +1234,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1261,6 +1263,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), }, @@ -1283,6 +1286,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'us', @@ -1310,6 +1314,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1332,6 +1337,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'us', @@ -1364,6 +1370,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1393,6 +1400,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1421,6 +1429,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1450,6 +1459,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'unconfigured', @@ -1475,7 +1485,7 @@ describe('Langfuse run config', () => { const callArgs = await callAndCaptureRunConfig({ tenantId: 'tenant-1', appConfig: { - langfuse: {}, + langfuse: { enabled: true }, } as AppConfig, }); @@ -1518,6 +1528,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), }, @@ -1543,6 +1554,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1577,6 +1589,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -1606,6 +1619,7 @@ describe('Langfuse run config', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', diff --git a/packages/api/src/langfuse/config.spec.ts b/packages/api/src/langfuse/config.spec.ts index f9c91c6dbc..6b18a46470 100644 --- a/packages/api/src/langfuse/config.spec.ts +++ b/packages/api/src/langfuse/config.spec.ts @@ -54,6 +54,7 @@ describe('buildLangfuseConfig', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'eu', @@ -81,6 +82,7 @@ describe('buildLangfuseConfig', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: 'v3:not-valid-ciphertext', destination: 'eu', @@ -105,6 +107,7 @@ describe('buildLangfuseConfig', () => { tenantId: 'tenant-1', appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: 'sk-tenant-1', destination: 'eu', @@ -193,6 +196,7 @@ describe('buildLangfuseConfig', () => { centralTraceExportEnabled: false, appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'us', @@ -227,6 +231,7 @@ describe('buildLangfuseConfig', () => { centralTraceExportEnabled: false, appConfig: { langfuse: { + enabled: true, publicKey: 'pk-tenant-1', secretKey: encryptV3('sk-tenant-1'), destination: 'us', @@ -270,6 +275,31 @@ describe('buildLangfuseConfig', () => { }); }); + 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'; diff --git a/packages/api/src/langfuse/config.ts b/packages/api/src/langfuse/config.ts index 424cf0d09f..21b97c1d2e 100644 --- a/packages/api/src/langfuse/config.ts +++ b/packages/api/src/langfuse/config.ts @@ -154,7 +154,7 @@ export function buildLangfuseConfig({ langfuse.tags = tags; } - const tenantLangfuseEnabled = normalizeBoolean(config?.enabled) !== false; + const tenantLangfuseEnabled = normalizeBoolean(config?.enabled) === true; if (!centralTraceExportEnabled) { disableCentralExport(langfuse); } diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts index 3af27f832b..63f07a4a80 100644 --- a/packages/api/src/langfuse/destinations.ts +++ b/packages/api/src/langfuse/destinations.ts @@ -71,7 +71,7 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat } const config = appConfig?.langfuse; - if (normalizeBoolean(config?.enabled) === false) { + if (normalizeBoolean(config?.enabled) !== true) { return undefined; } if (!isLangfuseFanoutEnabled()) { diff --git a/packages/api/src/langfuse/feedback.spec.ts b/packages/api/src/langfuse/feedback.spec.ts index 7603596823..1f625a0b6e 100644 --- a/packages/api/src/langfuse/feedback.spec.ts +++ b/packages/api/src/langfuse/feedback.spec.ts @@ -88,7 +88,12 @@ function getCentralAuthorization(): string { } function appConfigWithLangfuse(langfuse: AppConfig['langfuse']): AppConfig { - return { langfuse } as AppConfig; + return { + langfuse: { + enabled: true, + ...langfuse, + }, + } as AppConfig; } describe('Langfuse feedback scores', () => { @@ -183,6 +188,7 @@ describe('Langfuse feedback scores', () => { metadata: { tenantId: 'tenant-a' }, appConfig: { langfuse: { + enabled: true, publicKey: 'tenant-public-key', secretKey: encryptedTenantSecret(), destination: 'eu', @@ -370,6 +376,7 @@ describe('Langfuse feedback scores', () => { feedback: null, appConfig: { langfuse: { + enabled: true, publicKey: 'tenant-public-key', secretKey: encryptedTenantSecret(), destination: 'eu', @@ -450,6 +457,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'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 29d49843cc..1426a424ed 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2608,6 +2608,10 @@ export enum SettingsTabValues { * Tab for Speech Settings */ SPEECH = 'speech', + /** + * Tab for Langfuse Settings + */ + LANGFUSE = 'langfuse', /** * Tab for Beta Features */