feat(langfuse): require explicit tenant export activation

This commit is contained in:
Ravi Kumar L 2026-07-21 00:35:19 +02:00
parent b52a39d53e
commit 71cf753a06
20 changed files with 288 additions and 40 deletions

View file

@ -10,8 +10,10 @@ jest.mock('~/server/services/Config/ldap', () => ({
}));
const mockHasCapability = jest.fn();
const mockHasConfigCapability = jest.fn();
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
hasConfigCapability: (...args) => mockHasConfigCapability(...args),
}));
const mockGetTenantId = jest.fn(() => undefined);
@ -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);

View file

@ -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,
});

View file

@ -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);

View file

@ -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;
}

View file

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

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

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

View file

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

View file

@ -48,6 +48,16 @@ describe('Sidebar', () => {
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');

View file

@ -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?.({

View file

@ -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,

View file

@ -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 },
],
},

View file

@ -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.",

View file

@ -66,6 +66,7 @@ function baseConfigDoc(langfuse: Record<string, unknown>) {
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', () => {

View file

@ -39,6 +39,12 @@ export interface AdminLangfuseDeps {
priority: number,
session?: ClientSession,
) => Promise<IConfig | null>;
toggleConfigActive: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
isActive: boolean,
session?: ClientSession,
) => Promise<IConfig | null>;
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
}
@ -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<Response>;
testConnection: (req: ServerRequest, res: Response) => Promise<Response>;
} {
const { findConfigByPrincipal, patchConfigFields, invalidateConfigCaches } = deps;
const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } =
deps;
function findBaseConfig(): Promise<IConfig | null> {
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<Response> {
@ -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),

View file

@ -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',

View file

@ -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';

View file

@ -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);
}

View file

@ -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()) {

View file

@ -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';

View file

@ -2608,6 +2608,10 @@ export enum SettingsTabValues {
* Tab for Speech Settings
*/
SPEECH = 'speech',
/**
* Tab for Langfuse Settings
*/
LANGFUSE = 'langfuse',
/**
* Tab for Beta Features
*/