feat: in-app Langfuse connection settings panel

Add a discoverable, admin-gated Langfuse connection panel inside LibreChat
Settings (Dify-style): enable toggle, host, public key, masked write-only secret,
configured-key fingerprint, and a test-connection action. Backed by a dedicated
/api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns
metadata plus fingerprint on read, and validates credentials. Builds on the
per-field encryption and fanout decrypt from the langfuse-config-encryption branch.
This commit is contained in:
Dustin Healy 2026-07-04 13:00:28 -07:00 committed by Ravi Kumar L
parent dc7f4808e4
commit fbe3cbe5c1
20 changed files with 939 additions and 4 deletions

View file

@ -249,6 +249,7 @@ const startServer = async () => {
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
app.use('/api/admin/langfuse', routes.adminLangfuse);
app.use('/api/admin/grants', routes.adminGrants);
app.use('/api/admin/groups', routes.adminGroups);
app.use('/api/admin/roles', routes.adminRoles);

View file

@ -0,0 +1,25 @@
const express = require('express');
const { createAdminLangfuseHandlers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { invalidateConfigCaches } = require('~/server/services/Config');
const { requireJwtAuth } = require('~/server/middleware');
const db = require('~/models');
const router = express.Router();
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const handlers = createAdminLangfuseHandlers({
findConfigByPrincipal: db.findConfigByPrincipal,
patchConfigFields: db.patchConfigFields,
invalidateConfigCaches,
});
router.use(requireJwtAuth, requireAdminAccess);
router.get('/connection', handlers.getConnection);
router.put('/connection', handlers.updateConnection);
router.post('/connection/test', handlers.testConnection);
module.exports = router;

View file

@ -3,6 +3,7 @@ const assistants = require('./assistants');
const categories = require('./categories');
const adminAuth = require('./admin/auth');
const adminConfig = require('./admin/config');
const adminLangfuse = require('./admin/langfuse');
const adminGrants = require('./admin/grants');
const adminGroups = require('./admin/groups');
const adminRoles = require('./admin/roles');
@ -43,6 +44,7 @@ module.exports = {
auth,
adminAuth,
adminConfig,
adminLangfuse,
adminGrants,
adminGroups,
adminRoles,

View file

@ -18,6 +18,7 @@ const ctx: SettingsContextValue = {
allowAccountDeletion: true,
aboutEnabled: false,
engineTTS: 'browser',
isAdmin: false,
};
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider';
import type { SettingsContextValue } from './types';
import useProviderKeys from '../SettingsTabs/ProviderKeys/useProviderKeys';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
@ -27,6 +27,7 @@ export function useSettingsContext(): SettingsContextValue {
});
const balanceEnabled = startupConfig?.balance?.enabled === true;
const isAdmin = user?.role === SystemRoles.ADMIN;
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,
isAdmin,
}),
[
balanceEnabled,
@ -65,6 +67,7 @@ export function useSettingsContext(): SettingsContextValue {
allowAccountDeletion,
aboutEnabled,
engineTTS,
isAdmin,
],
);
}

View file

@ -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';
@ -491,6 +492,16 @@ export const registry: SettingEntry[] = [
labelKey: 'com_ui_settings_label_revoke_keys',
Component: RevokeKeys,
},
// Data controls · Integrations
{
id: 'langfuseConnection',
tab: DATA,
section: 'integrations',
labelKey: 'com_ui_langfuse_title',
keywords: ['langfuse', 'observability', 'tracing', 'telemetry', 'traces'],
show: (ctx) => ctx.isAdmin,
Component: LangfuseConnection,
},
// Data controls · Danger zone
{
id: 'deleteCache',

View file

@ -27,6 +27,7 @@ export type SectionId =
| 'memory'
| 'data'
| 'apiKeys'
| 'integrations'
| 'danger'
| 'profile'
| 'security'
@ -46,6 +47,7 @@ export interface SettingsContextValue {
allowAccountDeletion: boolean;
aboutEnabled: boolean;
engineTTS: string;
isAdmin: boolean;
}
export interface SettingEntry {
@ -112,6 +114,7 @@ export const TABS: TabMeta[] = [
{ id: 'memory', labelKey: 'com_ui_settings_section_memory' },
{ id: 'data', labelKey: 'com_ui_settings_section_data' },
{ id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' },
{ id: 'integrations', labelKey: 'com_ui_settings_section_integrations' },
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },
],
},

View file

@ -0,0 +1,153 @@
import { useState, useEffect } from 'react';
import { Button, Input, Label, Switch, useToastContext } from '@librechat/client';
import {
useGetLangfuseConnectionQuery,
useUpdateLangfuseConnectionMutation,
useTestLangfuseConnectionMutation,
} from '~/data-provider';
import { useLocalize } from '~/hooks';
export default function LangfuseConnection() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: status } = useGetLangfuseConnectionQuery();
const updateMutation = useUpdateLangfuseConnectionMutation();
const testMutation = useTestLangfuseConnectionMutation();
const [enabled, setEnabled] = useState(false);
const [baseUrl, setBaseUrl] = useState('');
const [publicKey, setPublicKey] = useState('');
const [secretKey, setSecretKey] = useState('');
useEffect(() => {
if (!status) {
return;
}
setEnabled(status.enabled === true);
setBaseUrl(status.baseUrl ?? '');
setPublicKey(status.publicKey ?? '');
}, [status]);
const secretConfigured = status?.configured === true;
const trimmedBaseUrl = baseUrl.trim();
const trimmedPublicKey = publicKey.trim();
const trimmedSecretKey = secretKey.trim();
const canSubmit =
trimmedBaseUrl !== '' &&
trimmedPublicKey !== '' &&
(trimmedSecretKey !== '' || secretConfigured);
const handleSave = () => {
updateMutation.mutate(
{
enabled,
baseUrl: trimmedBaseUrl,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
},
{
onSuccess: () => {
setSecretKey('');
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
},
onError: () =>
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' }),
},
);
};
const handleTest = () => {
testMutation.mutate(
{
baseUrl: trimmedBaseUrl,
publicKey: trimmedPublicKey,
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
},
{
onSuccess: (result) =>
showToast({
message: result.success
? localize('com_ui_langfuse_test_success')
: (result.message ?? localize('com_ui_langfuse_test_error')),
status: result.success ? 'success' : 'error',
}),
onError: () =>
showToast({ message: localize('com_ui_langfuse_test_error'), status: 'error' }),
},
);
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<div id="langfuse-enabled-label" className="font-medium">
{localize('com_ui_langfuse_title')}
</div>
<div className="mt-1 text-xs text-text-secondary">
{localize('com_ui_langfuse_description')}
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-labelledby="langfuse-enabled-label"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-base-url">{localize('com_ui_langfuse_base_url')}</Label>
<Input
id="langfuse-base-url"
value={baseUrl}
placeholder="https://cloud.langfuse.com"
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-public-key">{localize('com_ui_langfuse_public_key')}</Label>
<Input
id="langfuse-public-key"
value={publicKey}
placeholder="pk-lf-..."
onChange={(e) => setPublicKey(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="langfuse-secret-key">{localize('com_ui_langfuse_secret_key')}</Label>
<Input
id="langfuse-secret-key"
type="password"
autoComplete="new-password"
value={secretKey}
placeholder={secretConfigured ? localize('com_ui_langfuse_secret_key_set') : 'sk-lf-...'}
onChange={(e) => setSecretKey(e.target.value)}
/>
<span className="text-xs text-text-tertiary">
{localize('com_ui_langfuse_secret_key_hint')}
</span>
{secretConfigured && status?.secretKeyFingerprint != null && (
<span className="text-xs text-text-tertiary">
{localize('com_ui_langfuse_secret_key_fingerprint')}{' '}
<code>{status.secretKeyFingerprint}</code>
</span>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
disabled={!canSubmit || testMutation.isLoading}
onClick={handleTest}
>
{localize('com_ui_langfuse_test')}
</Button>
<Button disabled={!canSubmit || updateMutation.isLoading} onClick={handleSave}>
{localize('com_ui_save')}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent } from '@testing-library/react';
import LangfuseConnection from '../LangfuseConnection';
const mockGet = jest.fn();
const mockUpdate = jest.fn();
const mockTest = jest.fn();
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(() => {
mockGet.mockReset();
mockUpdate.mockReset();
mockTest.mockReset();
mockGet.mockReturnValue({ data: undefined });
});
describe('LangfuseConnection', () => {
it('renders the connection form fields', () => {
render(<LangfuseConnection />);
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toBeInTheDocument();
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toBeInTheDocument();
expect(screen.getByLabelText('com_ui_langfuse_secret_key')).toBeInTheDocument();
});
it('prefills stored values and shows the key fingerprint without the secret', () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
secretKeyFingerprint: 'abc123def456',
},
});
render(<LangfuseConnection />);
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toHaveValue(
'https://cloud.langfuse.com',
);
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-1');
expect(screen.getByLabelText('com_ui_langfuse_secret_key')).toHaveValue('');
expect(screen.getByText('abc123def456')).toBeInTheDocument();
});
it('includes the typed secret key when saving a new connection', async () => {
render(<LangfuseConnection />);
fireEvent.change(screen.getByLabelText('com_ui_langfuse_base_url'), {
target: { value: 'https://cloud.langfuse.com' },
});
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_save'));
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).toEqual({
enabled: false,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
});
});
it('omits the secret key when saving without re-entering it for an already-configured connection', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
secretKeyFingerprint: 'abc123def456',
},
});
render(<LangfuseConnection />);
await userEvent.click(screen.getByText('com_ui_save'));
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][0]).not.toHaveProperty('secretKey');
expect(mockUpdate.mock.calls[0][0]).toMatchObject({ publicKey: 'pk-lf-1' });
});
it('triggers a connection test', async () => {
mockGet.mockReturnValue({
data: {
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
},
});
render(<LangfuseConnection />);
await userEvent.click(screen.getByText('com_ui_langfuse_test'));
expect(mockTest).toHaveBeenCalledTimes(1);
expect(mockTest.mock.calls[0][0]).toMatchObject({
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
});
});
});

View file

@ -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<TLangfuseConnectionStatus> =>
useQuery<TLangfuseConnectionStatus>(
[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] },
);

View file

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

View file

@ -1640,6 +1640,20 @@
"com_ui_settings_search_placeholder": "Search settings",
"com_ui_settings_section_accessibility": "Accessibility",
"com_ui_settings_section_api_keys": "API keys",
"com_ui_settings_section_integrations": "Integrations",
"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_base_url": "Host",
"com_ui_langfuse_public_key": "Public key",
"com_ui_langfuse_secret_key": "Secret key",
"com_ui_langfuse_secret_key_set": "Saved — enter a new key to replace it",
"com_ui_langfuse_secret_key_hint": "The secret key is encrypted at rest and is never shown again after saving.",
"com_ui_langfuse_secret_key_fingerprint": "Configured key fingerprint:",
"com_ui_langfuse_test": "Test connection",
"com_ui_langfuse_saved": "Langfuse connection saved",
"com_ui_langfuse_save_error": "Failed to save the Langfuse connection",
"com_ui_langfuse_test_success": "Connected to Langfuse successfully",
"com_ui_langfuse_test_error": "Could not connect to Langfuse",
"com_ui_settings_section_appearance": "Appearance",
"com_ui_settings_section_billing": "Billing",
"com_ui_settings_section_commands": "Commands",

View file

@ -1,4 +1,5 @@
export { createAdminConfigHandlers } from './config';
export { createAdminLangfuseHandlers } from './langfuse';
export { createAdminGrantsHandlers } from './grants';
export { createAdminGroupsHandlers } from './groups';
export { createAdminRolesHandlers } from './roles';
@ -6,6 +7,7 @@ export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './sk
export { createAdminUsersHandlers } from './users';
export { createAdminAuditLogHandlers } from './auditLog';
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';

View file

@ -0,0 +1,294 @@
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;
beforeAll(async () => {
({ encryptV3 } = await import('@librechat/data-schemas'));
({ createAdminLangfuseHandlers } = await import('./langfuse'));
});
function mockReq(overrides = {}) {
return {
user: { id: 'u1', role: 'ADMIN', tenantId: 't1' },
params: {},
body: {},
query: {},
...overrides,
} as Partial<ServerRequest> as ServerRequest;
}
interface MockRes {
statusCode: number;
body: undefined | Record<string, unknown>;
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<Response> as Response & MockRes;
}
function baseConfigDoc(langfuse: Record<string, unknown>) {
return {
_id: 'cfg1',
principalType: 'role',
principalId: '__base__',
priority: 10,
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))),
),
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<string, unknown>): Record<string, unknown> {
const langfuse: Record<string, unknown> = {};
for (const [path, value] of Object.entries(fields)) {
langfuse[path.replace(/^langfuse\./, '')] = value;
}
return langfuse;
}
describe('createAdminLangfuseHandlers', () => {
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,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
secretKey: encryptV3('sk-lf-secret'),
secretKeyFingerprint: 'abc123def456',
}),
),
});
const res = mockRes();
await handlers.getConnection(mockReq(), res);
expect(res.body).toMatchObject({
configured: true,
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
publicKey: 'pk-lf-1',
secretKeyFingerprint: 'abc123def456',
});
expect(res.body?.secretKey).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('sk-lf-secret');
expect(JSON.stringify(res.body)).not.toContain('v3:');
});
});
describe('updateConnection', () => {
it('requires baseUrl', 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: { baseUrl: 'https://cloud.langfuse.com' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('rejects an invalid baseUrl', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { baseUrl: 'not-a-url', publicKey: 'pk', secretKey: 'sk' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('requires a secret key on first-time configuration', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
res,
);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('encrypts the secret key, stores a fingerprint, and never returns the secret', async () => {
const { handlers, deps } = createHandlers();
const res = mockRes();
await handlers.updateConnection(
mockReq({
body: {
enabled: true,
baseUrl: 'https://cloud.langfuse.com',
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.secretKeyFingerprint']).toMatch(/^[a-f0-9]{12}$/);
expect(fields['langfuse.enabled']).toBe(true);
expect(fields['langfuse.publicKey']).toBe('pk-lf-1');
expect(res.body?.secretKey).toBeUndefined();
expect(deps.invalidateConfigCaches).toHaveBeenCalledWith('t1');
});
it('allows updating metadata without resupplying the secret when one is stored', 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, baseUrl: 'https://us.cloud.langfuse.com', publicKey: 'pk-2' },
}),
res,
);
expect(res.statusCode).toBe(200);
const fields = deps.patchConfigFields.mock.calls[0][3];
expect(fields['langfuse.secretKey']).toBeUndefined();
expect(fields['langfuse.publicKey']).toBe('pk-2');
});
});
describe('testConnection', () => {
const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});
it('requires baseUrl and publicKey', async () => {
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
res,
);
expect(res.statusCode).toBe(400);
});
it('returns success when Langfuse responds ok', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: true, status: 200 }) as unknown as typeof fetch;
const { handlers } = createHandlers();
const res = mockRes();
await handlers.testConnection(
mockReq({
body: { baseUrl: 'https://cloud.langfuse.com', 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 /);
});
it('returns failure with status 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: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
}),
res,
);
expect(res.body?.success).toBe(false);
expect(res.body?.message).toContain('401');
});
it('falls back to the stored (decrypted) secret when none is supplied', async () => {
global.fetch = jest
.fn()
.mockResolvedValue({ ok: true, status: 200 }) as unknown as typeof fetch;
const { handlers } = createHandlers({
findConfigByPrincipal: jest
.fn()
.mockResolvedValue(baseConfigDoc({ secretKey: encryptV3('sk-stored') })),
});
const res = mockRes();
await handlers.testConnection(
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', 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');
});
});
});

View file

@ -0,0 +1,213 @@
import crypto from 'node:crypto';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import { logger, BASE_CONFIG_PRINCIPAL_ID, encryptV3, decryptV3 } from '@librechat/data-schemas';
import type {
TCustomConfig,
LangfuseConfig,
TLangfuseConnectionStatus,
TUpdateLangfuseConnectionRequest,
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 { ServerRequest } from '~/types/http';
const DEFAULT_PRIORITY = 10;
const FINGERPRINT_LENGTH = 12;
/** Short, non-reversible fingerprint of a secret so reads can show which key is
* configured without exposing it. */
function fingerprintSecret(secret: string): string {
return crypto.createHash('sha256').update(secret).digest('hex').slice(0, FINGERPRINT_LENGTH);
}
export interface AdminLangfuseDeps {
findConfigByPrincipal: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
options?: { includeInactive?: boolean },
session?: ClientSession,
) => Promise<IConfig | null>;
patchConfigFields: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
principalModel: PrincipalModel,
fields: Record<string, unknown>,
priority: number,
session?: ClientSession,
) => Promise<IConfig | null>;
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
}
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<TCustomConfig> | undefined;
return overrides?.langfuse;
}
function buildStatus(config: IConfig | null): TLangfuseConnectionStatus {
const stored = readStoredLangfuse(config);
return {
configured: Boolean(stored?.publicKey && stored?.secretKey),
enabled: stored?.enabled === true,
baseUrl: stored?.baseUrl,
publicKey: stored?.publicKey,
secretKeyFingerprint: stored?.secretKeyFingerprint,
updatedAt: config?.updatedAt ? new Date(config.updatedAt).toISOString() : undefined,
};
}
function resolveStoredSecret(secret?: string): string | undefined {
if (!secret) {
return undefined;
}
if (!secret.startsWith('v3:')) {
return secret;
}
return decryptV3(secret);
}
/**
* 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<Response>;
updateConnection: (req: ServerRequest, res: Response) => Promise<Response>;
testConnection: (req: ServerRequest, res: Response) => Promise<Response>;
} {
const { findConfigByPrincipal, patchConfigFields, invalidateConfigCaches } = deps;
function findBaseConfig(): Promise<IConfig | null> {
return findConfigByPrincipal(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID, {
includeInactive: true,
});
}
async function getConnection(req: ServerRequest, res: Response): Promise<Response> {
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<Response> {
try {
const body = (req.body ?? {}) as TUpdateLangfuseConnectionRequest;
const enabled = body.enabled === true;
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
const secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
if (!baseUrl) {
return res.status(400).json({ error: 'baseUrl is required' });
}
if (!publicKey) {
return res.status(400).json({ error: 'publicKey is required' });
}
try {
new URL(baseUrl);
} catch {
return res.status(400).json({ error: 'baseUrl must be a valid URL' });
}
const existing = await findBaseConfig();
const hasStoredSecret = Boolean(readStoredLangfuse(existing)?.secretKey);
if (!secretKey && !hasStoredSecret) {
return res
.status(400)
.json({ error: 'secretKey is required for first-time configuration' });
}
const fields: Record<string, unknown> = {
'langfuse.enabled': enabled,
'langfuse.baseUrl': baseUrl,
'langfuse.publicKey': publicKey,
};
if (secretKey) {
fields['langfuse.secretKey'] = encryptV3(secretKey);
fields['langfuse.secretKeyFingerprint'] = fingerprintSecret(secretKey);
}
const updated = await patchConfigFields(
PrincipalType.ROLE,
BASE_CONFIG_PRINCIPAL_ID,
PrincipalModel.ROLE,
fields,
existing?.priority ?? DEFAULT_PRIORITY,
);
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<Response> {
try {
const body = (req.body ?? {}) as TLangfuseConnectionTestRequest;
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
let secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
if (!baseUrl || !publicKey) {
return res.status(400).json({ error: 'baseUrl and publicKey are required' });
}
if (!secretKey) {
const existing = await findBaseConfig();
try {
secretKey = resolveStoredSecret(readStoredLangfuse(existing)?.secretKey) ?? '';
} catch {
const failed: TLangfuseConnectionTestResponse = {
success: false,
message: 'Stored secret key could not be decrypted',
};
return res.status(200).json(failed);
}
}
if (!secretKey) {
const failed: TLangfuseConnectionTestResponse = {
success: false,
message: 'secretKey is required to test the connection',
};
return res.status(200).json(failed);
}
const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64');
const url = `${baseUrl.replace(/\/+$/, '')}/api/public/projects`;
const response = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
const result: TLangfuseConnectionTestResponse = response.ok
? { success: true }
: { success: false, message: `Langfuse responded with status ${response.status}` };
return res.status(200).json(result);
} catch (error) {
logger.error('[adminLangfuse] testConnection error:', error);
const result: TLangfuseConnectionTestResponse = {
success: false,
message: 'Could not reach the Langfuse host',
};
return res.status(200).json(result);
}
}
return { getConnection, updateConnection, testConnection };
}

View file

@ -43,9 +43,6 @@ describe('buildLangfuseConfig', () => {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: true,
},
},
} as unknown as AppConfig,
});

View file

@ -438,6 +438,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`;
/* Roles */
export const roles = () => `${BASE_URL}/api/roles`;
export const adminRoles = () => `${BASE_URL}/api/admin/roles`;

View file

@ -15,6 +15,22 @@ import request from './request';
import * as s from './schemas';
import * as r from './roles';
export function getLangfuseConnection(): Promise<t.TLangfuseConnectionStatus> {
return request.get(endpoints.adminLangfuseConnection());
}
export function updateLangfuseConnection(
payload: t.TUpdateLangfuseConnectionRequest,
): Promise<t.TLangfuseConnectionStatus> {
return request.put(endpoints.adminLangfuseConnection(), payload);
}
export function testLangfuseConnection(
payload: t.TLangfuseConnectionTestRequest,
): Promise<t.TLangfuseConnectionTestResponse> {
return request.post(endpoints.adminLangfuseConnectionTest(), payload);
}
export function revokeUserKey(name: string): Promise<unknown> {
return request.delete(endpoints.revokeUserKey(name));
}

View file

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

View file

@ -830,3 +830,30 @@ export type TUpdateSkillNodeRequest = {
parentId?: string | null;
order?: number;
};
export type TLangfuseConnectionStatus = {
configured: boolean;
enabled: boolean;
baseUrl?: string;
publicKey?: string;
secretKeyFingerprint?: string;
updatedAt?: string;
};
export type TUpdateLangfuseConnectionRequest = {
enabled: boolean;
baseUrl: string;
publicKey: string;
secretKey?: string;
};
export type TLangfuseConnectionTestRequest = {
baseUrl: string;
publicKey: string;
secretKey?: string;
};
export type TLangfuseConnectionTestResponse = {
success: boolean;
message?: string;
};