= {}, 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?: {