🗝️ feat: Encrypted Langfuse Fanout Config (#14107)

* feat: encrypt tenant Langfuse secret in admin config

Add generic per-field secret encryption to the admin config layer: registered
secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a
non-secret fingerprint companion is stored. Admin config reads (base + per
principal) redact registered secrets so they are never returned; the fingerprint
is kept so the UI can show which key is configured.

The Langfuse fanout read path decrypts the tenant secret before export. Adds
secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact
policy.

* fix(api): secure admin config secret handling

* fix(api): preserve encrypted langfuse config secrets

* fix(api): couple config secret fingerprint deletion

* fix(api): read langfuse fanout collector url from env

* fix(api): display langfuse secret key hint

* fix(api): remove langfuse secret fingerprint breadcrumbs

* fix(api): use langfuse destination keys for tenant config

* fix(api): remove langfuse config compatibility fallbacks

* refactor(api): simplify langfuse secret helpers

* refactor(api): simplify langfuse config secret handling

---------

Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>
This commit is contained in:
Dustin Healy 2026-07-14 05:19:55 -07:00 committed by GitHub
parent e46805dc42
commit b0d46b0518
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1481 additions and 184 deletions

View file

@ -1,5 +1,19 @@
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
jest.mock('@librechat/data-schemas', () => {
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
encryptV3: jest.fn((value: string) => `v3:test:${value}`),
};
});
import { createAdminConfigHandlers } from './config';
function mockReq(overrides = {}) {
@ -66,6 +80,40 @@ function createHandlers(overrides = {}) {
}
describe('createAdminConfigHandlers', () => {
describe('listConfigs', () => {
it('redacts secret fields from config list responses', async () => {
const { handlers } = createHandlers({
listAllConfigs: jest.fn().mockResolvedValue([
{
_id: 'c1',
principalType: 'role',
principalId: 'admin',
overrides: {
langfuse: {
publicKey: 'pk-lf-1',
secretKey: 'v3:encrypted',
displaySecretKey: 'sk-lf-...cret',
},
},
},
]),
});
const req = mockReq();
const res = mockRes();
await handlers.listConfigs(req, res);
expect(res.statusCode).toBe(200);
const configs = res.body!.configs as Array<{
overrides: { langfuse: Record<string, string> };
}>;
expect(configs[0].overrides.langfuse).toEqual({
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...cret',
});
});
});
describe('getConfig', () => {
it('returns 403 before DB lookup when user lacks READ_CONFIGS', async () => {
const { handlers, deps } = createHandlers({
@ -218,6 +266,195 @@ 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,
})),
});
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
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.displaySecretKey).toBe('sk-lf-...cret');
const responseConfig = res.body!.config as {
overrides: { langfuse: Record<string, string> };
};
expect(responseConfig.overrides.langfuse).toEqual({
publicKey: 'pk-lf-1',
displaySecretKey: savedOverrides.langfuse.displaySecretKey,
});
});
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',
displaySecretKey: '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',
},
},
},
});
const res = mockRes();
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',
displaySecretKey: 'sk-old...-old',
});
const responseConfig = res.body!.config as {
overrides: { langfuse: Record<string, string> };
};
expect(responseConfig.overrides.langfuse).toEqual({
publicKey: 'pk-new',
destination: 'eu',
displaySecretKey: 'sk-old...-old',
});
});
it('clears existing Langfuse secrets on full override writes when explicitly empty', async () => {
const { handlers, deps } = createHandlers({
findConfigByPrincipal: jest.fn().mockResolvedValue({
_id: 'c1',
overrides: {
langfuse: {
secretKey: 'v3:test:sk-old',
displaySecretKey: 'sk-old...-old',
},
},
}),
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',
secretKey: '',
},
},
},
});
const res = mockRes();
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',
secretKey: '',
displaySecretKey: '',
});
});
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.displaySecretKey': '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.displaySecretKey');
expect(savedOverrides.langfuse).toEqual({ publicKey: 'pk-lf-1' });
const responseConfig = res.body!.config as {
overrides: { langfuse: Record<string, string> };
};
expect(responseConfig.overrides).toEqual({
langfuse: { publicKey: 'pk-lf-1' },
});
});
it('preserves UI sub-keys in composite permission fields like mcpServers', async () => {
const { handlers, deps } = createHandlers({
upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }),
@ -394,6 +631,39 @@ describe('createAdminConfigHandlers', () => {
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect');
});
it('also deletes the display secret key companion when deleting a secret field', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
query: { fieldPath: 'langfuse.secretKey' },
});
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.displaySecretKey',
);
});
it('rejects deletes of the displayed secret key', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
query: { fieldPath: 'langfuse.displaySecretKey' },
});
const res = mockRes();
await handlers.deleteConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.unsetConfigField).not.toHaveBeenCalled();
});
it('returns 400 when fieldPath query param is missing', async () => {
const { handlers } = createHandlers();
const req = mockReq({
@ -464,6 +734,47 @@ describe('createAdminConfigHandlers', () => {
);
});
it('also tombstones the display secret key companion when tombstoning a secret field', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'langfuse.secretKey' },
});
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.displaySecretKey',
10,
);
});
it('rejects tombstones of the displayed secret key', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: { fieldPath: 'langfuse.displaySecretKey' },
});
const res = mockRes();
await handlers.tombstoneConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.tombstoneConfigField).not.toHaveBeenCalled();
});
it('blocks interface permission paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
@ -552,6 +863,238 @@ describe('createAdminConfigHandlers', () => {
expect(patchedFields['interface.modelSelect']).toBe(false);
});
it('clears stale Langfuse display secret keys 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.displaySecretKey']).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.displaySecretKey).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',
displaySecretKey: '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',
displaySecretKey: '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',
displaySecretKey: '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: '',
displaySecretKey: '',
});
});
it('rejects array-valued Langfuse secret ancestors', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [
{
fieldPath: 'langfuse',
value: [{ secretKey: 'sk-lf-secret' }],
},
],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('does not store non-string values at Langfuse secret paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [{ fieldPath: 'langfuse.secretKey', value: { hidden: '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']).toBe('');
expect(patchedFields['langfuse.displaySecretKey']).toBe('');
});
it('rejects direct display secret key patch entries', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [{ fieldPath: 'langfuse.displaySecretKey', value: 'spoofed' }],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects encrypted Langfuse secret values on patch entries', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [{ fieldPath: 'langfuse.secretKey', value: 'v3:attacker-controlled' }],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects patch entries below protected Langfuse secret paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [{ fieldPath: 'langfuse.secretKey.hidden', value: 'sk-lf-secret' }],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('rejects patch entries below protected Langfuse displaySecretKey paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
params: { principalType: 'role', principalId: 'admin' },
body: {
entries: [{ fieldPath: 'langfuse.displaySecretKey.hidden', value: 'spoofed' }],
},
});
const res = mockRes();
await handlers.patchConfigField(req, res);
expect(res.statusCode).toBe(400);
expect(deps.patchConfigFields).not.toHaveBeenCalled();
});
it('blocks peoplePicker permission sub-key paths', async () => {
const { handlers, deps } = createHandlers();
const req = mockReq({
@ -1552,6 +2095,43 @@ describe('createAdminConfigHandlers', () => {
expect(res.body!.config).toEqual({ interface: { modelSelect: true } });
});
it('redacts Langfuse secrets from top-level and raw nested base config', async () => {
const { handlers } = createHandlers({
getAppConfig: jest.fn().mockResolvedValue({
langfuse: {
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
displaySecretKey: 'sk-lf-...cret',
},
config: {
langfuse: {
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-raw-secret',
displaySecretKey: 'sk-lf-...cret',
},
},
}),
});
const req = mockReq();
const res = mockRes();
await handlers.getBaseConfig(req, res);
expect(res.statusCode).toBe(200);
const responseConfig = res.body!.config as {
langfuse: Record<string, string>;
config: { langfuse: Record<string, string> };
};
expect(responseConfig.langfuse).toEqual({
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...cret',
});
expect(responseConfig.config.langfuse).toEqual({
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...cret',
});
});
it('forwards baseOnly=true to getAppConfig when query param is the literal string "true"', async () => {
const getAppConfig = jest.fn().mockResolvedValue({ interface: { modelSelect: true } });
const { handlers } = createHandlers({ getAppConfig });

View file

@ -12,6 +12,16 @@ import type { Types, ClientSession } from 'mongoose';
import type { Response } from 'express';
import type { CapabilityUser } from '~/middleware/capabilities';
import type { ServerRequest } from '~/types/http';
import {
encryptConfigSecretFields,
encryptConfigSecrets,
getConfigSecretMutationPaths,
getConfigSecretInputError,
isConfigSecretAncestorPath,
isConfigSecretDescendantPath,
preserveConfigSecrets,
redactConfigSecrets,
} from './secrets';
const UNSAFE_SEGMENTS = /(?:^|\.)(__[\w]*|constructor|prototype)(?:\.|$)/;
const MAX_PATCH_ENTRIES = 100;
@ -173,6 +183,45 @@ function getCapabilityUser(req: ServerRequest): CapabilityUser | null {
};
}
function redactConfigForResponse(config: IConfig): IConfig {
const safeConfig = JSON.parse(JSON.stringify(config)) as IConfig;
if (safeConfig.overrides) {
redactConfigSecrets(safeConfig.overrides);
}
return safeConfig;
}
function redactAppConfigForResponse(appConfig: AppConfig): AppConfig {
const safeConfig = JSON.parse(JSON.stringify(appConfig)) as AppConfig & { config?: unknown };
redactConfigSecrets(safeConfig);
if (safeConfig.config != null && typeof safeConfig.config === 'object') {
redactConfigSecrets(safeConfig.config);
}
return safeConfig;
}
function isObjectValuedLangfusePatch(fieldPath: string, value: unknown): boolean {
return (
isConfigSecretAncestorPath(fieldPath) &&
value != null &&
typeof value === 'object' &&
!Array.isArray(value)
);
}
function preservePatchedConfigSecretFields(
fields: Record<string, unknown>,
existingOverrides?: unknown,
): Record<string, unknown> {
const result = { ...fields };
for (const [fieldPath, value] of Object.entries(result)) {
if (isObjectValuedLangfusePatch(fieldPath, value)) {
result[fieldPath] = preserveConfigSecrets(value, existingOverrides, fieldPath);
}
}
return result;
}
// ── Handler factory ──────────────────────────────────────────────────
export function createAdminConfigHandlers(deps: AdminConfigDeps): {
@ -216,7 +265,8 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
}
const configs = await listAllConfigs();
return res.status(200).json({ configs });
const safeConfigs = configs.map(redactConfigForResponse);
return res.status(200).json({ configs: safeConfigs });
} catch (error) {
logger.error('[adminConfig] listConfigs error:', error);
return res.status(500).json({ error: 'Failed to list configs' });
@ -247,7 +297,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
tenantId: user.tenantId,
baseOnly,
});
return res.status(200).json({ config: appConfig });
return res.status(200).json({ config: redactAppConfigForResponse(appConfig) });
} catch (error) {
logger.error('[adminConfig] getBaseConfig error:', error);
return res.status(500).json({ error: 'Failed to get base config' });
@ -284,7 +334,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
return res.status(404).json({ error: 'Config not found' });
}
return res.status(200).json({ config });
return res.status(200).json({ config: redactConfigForResponse(config) });
} catch (error) {
logger.error('[adminConfig] getConfig error:', error);
return res.status(500).json({ error: 'Failed to get config' });
@ -404,11 +454,30 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
? { expectEmpty: false }
: { expectEmpty: true, preservePriority: true };
const langfuseInputError = getConfigSecretInputError(
'langfuse',
(filteredOverrides as Record<string, unknown>).langfuse,
);
if (langfuseInputError) {
return res.status(400).json({ error: langfuseInputError });
}
const encryptedOverrides = encryptConfigSecrets(filteredOverrides);
const existingForSecrets = isObjectValuedLangfusePatch(
'langfuse',
(filteredOverrides as Record<string, unknown>).langfuse,
)
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
: null;
const preservedOverrides = preserveConfigSecrets(
encryptedOverrides,
existingForSecrets?.overrides,
);
const config = await upsertConfig(
principalType,
principalId,
principalModel(principalType),
filteredOverrides,
preservedOverrides,
requestedPriority,
undefined,
upsertOptions,
@ -420,7 +489,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after upsert:', err),
);
return res.status(config?.configVersion === 1 ? 201 : 200).json({ config });
return res.status(config?.configVersion === 1 ? 201 : 200).json({
config: config ? redactConfigForResponse(config) : config,
});
} catch (error) {
logger.error('[adminConfig] upsertConfigOverrides error:', error);
return res.status(500).json({ error: 'Failed to upsert config' });
@ -466,6 +537,20 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
.status(400)
.json({ error: `Invalid or unsafe field path: ${entry.fieldPath}` });
}
if (isConfigSecretDescendantPath(entry.fieldPath)) {
return res
.status(400)
.json({ error: `Cannot patch inside protected secret path: ${entry.fieldPath}` });
}
const secretInputError = getConfigSecretInputError(entry.fieldPath, entry.value);
if (secretInputError) {
return res.status(400).json({ error: secretInputError });
}
if (Array.isArray(entry.value) && isConfigSecretAncestorPath(entry.fieldPath)) {
return res.status(400).json({
error: `Cannot patch protected secret ancestor as an array: ${entry.fieldPath}`,
});
}
}
const user = getCapabilityUser(req);
@ -528,23 +613,31 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
}
const requestedPriority = hasBroadManage ? priority : undefined;
const hasObjectValuedLangfusePatch = Object.entries(fields).some(([fieldPath, value]) =>
isObjectValuedLangfusePatch(fieldPath, value),
);
const existing =
requestedPriority == null
requestedPriority == null || hasObjectValuedLangfusePatch
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
: null;
const encryptedFields = encryptConfigSecretFields(fields);
const preservedFields = preservePatchedConfigSecretFields(
encryptedFields,
existing?.overrides,
);
const config = await patchConfigFields(
principalType,
principalId,
principalModel(principalType),
fields,
preservedFields,
requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY,
);
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after patch:', err),
);
return res.status(200).json({ config });
return res.status(200).json({ config: config ? redactConfigForResponse(config) : config });
} catch (error) {
logger.error('[adminConfig] patchConfigField error:', error);
return res.status(500).json({ error: 'Failed to patch config fields' });
@ -581,6 +674,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
if (!isValidFieldPath(fieldPath)) {
return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` });
}
const secretInputError = getConfigSecretInputError(fieldPath, undefined);
if (secretInputError) {
return res.status(400).json({ error: secretInputError });
}
const user = getCapabilityUser(req);
if (!user) {
@ -618,18 +715,24 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
: null;
const config = await writeConfigTombstone(
principalType,
principalId,
principalModel(principalType),
fieldPath,
requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY,
);
let config: IConfig | null = null;
for (const path of getConfigSecretMutationPaths(fieldPath)) {
const fieldConfig = await writeConfigTombstone(
principalType,
principalId,
principalModel(principalType),
path,
requestedPriority ?? existing?.priority ?? DEFAULT_PRIORITY,
);
if (fieldConfig) {
config = fieldConfig;
}
}
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after field tombstone:', err),
);
return res.status(200).json({ config });
return res.status(200).json({ config: config ? redactConfigForResponse(config) : config });
} catch (error) {
logger.error('[adminConfig] tombstoneConfigField error:', error);
return res.status(500).json({ error: 'Failed to tombstone config field' });
@ -658,6 +761,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
if (!isValidFieldPath(fieldPath)) {
return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` });
}
const secretInputError = getConfigSecretInputError(fieldPath, undefined);
if (secretInputError) {
return res.status(400).json({ error: secretInputError });
}
const user = getCapabilityUser(req);
if (!user) {
@ -686,7 +793,13 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
return res.status(200).json({ message: 'No actionable field path provided' });
}
const config = await unsetConfigField(principalType, principalId, fieldPath);
let config: IConfig | null = null;
for (const path of getConfigSecretMutationPaths(fieldPath)) {
const fieldConfig = await unsetConfigField(principalType, principalId, path);
if (fieldConfig) {
config = fieldConfig;
}
}
if (!config) {
return res.status(404).json({ error: 'Config not found' });
}
@ -694,7 +807,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after field delete:', err),
);
return res.status(200).json({ config });
return res.status(200).json({ config: redactConfigForResponse(config) });
} catch (error) {
logger.error('[adminConfig] deleteConfigField error:', error);
return res.status(500).json({ error: 'Failed to delete config field' });
@ -813,7 +926,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after toggle:', err),
);
return res.status(200).json({ config });
return res.status(200).json({ config: redactConfigForResponse(config) });
} catch (error) {
logger.error('[adminConfig] toggleConfig error:', error);
return res.status(500).json({ error: 'Failed to toggle config' });

View file

@ -0,0 +1,193 @@
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
// Loaded via dynamic import in beforeAll so encryption initializes after
// CREDS_KEY is set above (encryptV3 reads the key at module load).
let decryptConfigSecret: typeof import('./secrets').decryptConfigSecret;
let encryptConfigSecretFields: typeof import('./secrets').encryptConfigSecretFields;
let encryptConfigSecrets: typeof import('./secrets').encryptConfigSecrets;
let getConfigSecretInputError: typeof import('./secrets').getConfigSecretInputError;
let preserveConfigSecrets: typeof import('./secrets').preserveConfigSecrets;
let redactConfigSecrets: typeof import('./secrets').redactConfigSecrets;
let decryptV3: typeof import('@librechat/data-schemas').decryptV3;
beforeAll(async () => {
({
decryptConfigSecret,
encryptConfigSecretFields,
encryptConfigSecrets,
getConfigSecretInputError,
preserveConfigSecrets,
redactConfigSecrets,
} = await import('./secrets'));
({ decryptV3 } = await import('@librechat/data-schemas'));
});
describe('Langfuse config secrets', () => {
it('encrypts direct field writes and stores a display secret key', () => {
const out = encryptConfigSecretFields({
'langfuse.publicKey': 'pk-lf-1',
'langfuse.secretKey': 'sk-lf-secret',
});
expect(out['langfuse.secretKey']).toMatch(/^v3:/);
expect(decryptV3(out['langfuse.secretKey'] as string)).toBe('sk-lf-secret');
expect(out['langfuse.displaySecretKey']).toBe('sk-lf-...cret');
expect(out['langfuse.publicKey']).toBe('pk-lf-1');
});
it('encrypts object writes and removes client-supplied display secret keys', () => {
const out = encryptConfigSecrets({
langfuse: {
publicKey: 'pk-lf-1',
secretKey: 'sk-lf-secret',
displaySecretKey: 'spoofed',
},
});
expect(out.langfuse.secretKey).toMatch(/^v3:/);
expect(decryptV3(out.langfuse.secretKey)).toBe('sk-lf-secret');
expect(out.langfuse.displaySecretKey).toBe('sk-lf-...cret');
expect(out.langfuse.publicKey).toBe('pk-lf-1');
});
it('clears empty or non-string secret values', () => {
expect(encryptConfigSecretFields({ 'langfuse.secretKey': '' })).toEqual({
'langfuse.secretKey': '',
'langfuse.displaySecretKey': '',
});
expect(
encryptConfigSecrets({
langfuse: {
secretKey: null,
displaySecretKey: 'spoofed',
},
}),
).toEqual({
langfuse: {
secretKey: '',
displaySecretKey: '',
},
});
});
it('rejects protected display-key writes and encrypted secret submissions', () => {
expect(getConfigSecretInputError('langfuse.displaySecretKey', 'spoofed')).toContain(
'protected display secret path',
);
expect(getConfigSecretInputError('langfuse.secretKey', 'v3:attacker-controlled')).toContain(
'Encrypted config secret values',
);
expect(
getConfigSecretInputError('langfuse', { secretKey: 'v3:attacker-controlled' }),
).toContain('Encrypted config secret values');
expect(getConfigSecretInputError('langfuse.secretKey', 'sk-lf-secret')).toBeNull();
});
it('decrypts encrypted config secrets and rejects plaintext runtime values', () => {
const encrypted = encryptConfigSecrets({
langfuse: { secretKey: 'sk-lf-secret' },
}).langfuse.secretKey;
expect(decryptConfigSecret(encrypted)).toBe('sk-lf-secret');
expect(decryptConfigSecret(' sk-plaintext ')).toBeUndefined();
expect(decryptConfigSecret('')).toBeUndefined();
expect(decryptConfigSecret('v3:not-valid-ciphertext')).toBeUndefined();
});
it('preserves existing encrypted secrets when object writes omit them', () => {
const existing = encryptConfigSecrets({
langfuse: {
publicKey: 'pk-old',
secretKey: 'sk-old',
},
});
const next = encryptConfigSecrets({
langfuse: {
publicKey: 'pk-new',
},
});
const preserved = preserveConfigSecrets(next, existing);
const preservedLangfuse = preserved.langfuse as Record<string, string>;
const existingLangfuse = existing.langfuse as Record<string, string>;
expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-old');
expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey);
expect(preserved.langfuse.publicKey).toBe('pk-new');
});
it('does not preserve plaintext existing secrets or explicitly cleared secrets', () => {
const next = encryptConfigSecrets({
langfuse: {
publicKey: 'pk-new',
},
});
const fromPlaintext = preserveConfigSecrets(next, {
langfuse: {
publicKey: 'pk-old',
secretKey: 'sk-plain-existing',
},
});
expect(fromPlaintext.langfuse).toEqual({ publicKey: 'pk-new' });
const existing = encryptConfigSecrets({
langfuse: {
secretKey: 'sk-old',
},
});
const cleared = encryptConfigSecrets({
langfuse: {
secretKey: '',
},
});
expect(preserveConfigSecrets(cleared, existing)).toEqual({
langfuse: {
secretKey: '',
displaySecretKey: '',
},
});
});
it('preserves existing secrets for object-valued ancestor patches', () => {
const existing = encryptConfigSecrets({
langfuse: {
publicKey: 'pk-old',
secretKey: 'sk-old',
},
});
const preserved = preserveConfigSecrets({ publicKey: 'pk-new' }, existing, 'langfuse');
const preservedLangfuse = preserved as Record<string, string>;
const existingLangfuse = existing.langfuse as Record<string, string>;
expect(decryptV3(preservedLangfuse.secretKey)).toBe('sk-old');
expect(preservedLangfuse.displaySecretKey).toBe(existingLangfuse.displaySecretKey);
expect(preserved.publicKey).toBe('pk-new');
});
it('redacts secret values while preserving display secret keys', () => {
const redacted = redactConfigSecrets({
'langfuse.secretKey': 'literal',
'langfuse.displaySecretKey': 'literal-display',
langfuse: {
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
secretKey: 'v3:abc:def',
displaySecretKey: 'sk-lf-...cret',
},
});
expect(redacted['langfuse.secretKey']).toBeUndefined();
expect(redacted['langfuse.displaySecretKey']).toBeUndefined();
expect(redacted.langfuse).toEqual({
enabled: true,
destination: 'eu',
publicKey: 'pk-lf-1',
displaySecretKey: 'sk-lf-...cret',
});
});
});

View file

@ -0,0 +1,226 @@
import isPlainObject from 'lodash/isPlainObject';
import { encryptV3, decryptV3, logger } from '@librechat/data-schemas';
const LANGFUSE_SECTION = 'langfuse';
const LANGFUSE_SECRET_KEY = 'secretKey';
const LANGFUSE_DISPLAY_SECRET_KEY = 'displaySecretKey';
const LANGFUSE_SECRET_PATH = `${LANGFUSE_SECTION}.${LANGFUSE_SECRET_KEY}`;
const LANGFUSE_DISPLAY_SECRET_PATH = `${LANGFUSE_SECTION}.${LANGFUSE_DISPLAY_SECRET_KEY}`;
const ENCRYPTED_PREFIX = 'v3:';
export function getDisplaySecretKey(secret: string): string {
return secret.slice(0, 6) + '...' + secret.slice(-4);
}
function normalizeSecretString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
}
function isEncryptedConfigSecret(value: unknown): boolean {
return typeof value === 'string' && value.trim().startsWith(ENCRYPTED_PREFIX);
}
function getPlainRecord(value: unknown): Record<string, unknown> | null {
return isPlainObject(value) ? (value as Record<string, unknown>) : null;
}
function getLangfuseSection(root: unknown, basePath = ''): Record<string, unknown> | null {
const rootRecord = getPlainRecord(root);
if (!rootRecord) {
return null;
}
if (basePath === LANGFUSE_SECTION) {
return rootRecord;
}
return getPlainRecord(rootRecord[LANGFUSE_SECTION]);
}
export function decryptConfigSecret(value: unknown): string | undefined {
const normalized = normalizeSecretString(value);
if (!normalized || !normalized.startsWith(ENCRYPTED_PREFIX)) {
return undefined;
}
try {
return decryptV3(normalized);
} catch (error) {
logger.warn('[adminConfig] Failed to decrypt config secret', error);
return undefined;
}
}
export function getConfigSecretMutationPaths(fieldPath: string): string[] {
if (fieldPath === LANGFUSE_SECRET_PATH) {
return [LANGFUSE_SECRET_PATH, LANGFUSE_DISPLAY_SECRET_PATH];
}
return [fieldPath];
}
export function isConfigSecretDescendantPath(fieldPath: string): boolean {
return (
fieldPath.startsWith(`${LANGFUSE_SECRET_PATH}.`) ||
fieldPath.startsWith(`${LANGFUSE_DISPLAY_SECRET_PATH}.`)
);
}
export function isConfigSecretAncestorPath(fieldPath: string): boolean {
return fieldPath === LANGFUSE_SECTION;
}
export function getConfigSecretInputError(fieldPath: string, value: unknown): string | null {
if (fieldPath === LANGFUSE_DISPLAY_SECRET_PATH) {
return `Cannot write protected display secret path: ${fieldPath}`;
}
if (fieldPath === LANGFUSE_SECRET_PATH && isEncryptedConfigSecret(value)) {
return `Encrypted config secret values cannot be submitted: ${fieldPath}`;
}
const langfuseInput = fieldPath === LANGFUSE_SECTION ? getPlainRecord(value) : null;
if (langfuseInput && isEncryptedConfigSecret(langfuseInput[LANGFUSE_SECRET_KEY])) {
return `Encrypted config secret values cannot be submitted: ${LANGFUSE_SECRET_PATH}`;
}
return null;
}
function removeLangfuseArraySection(root: Record<string, unknown>): boolean {
if (Array.isArray(root[LANGFUSE_SECTION])) {
delete root[LANGFUSE_SECTION];
return true;
}
return false;
}
function applyLangfuseSecretWrite(section: Record<string, unknown>): void {
if (!(LANGFUSE_SECRET_KEY in section)) {
delete section[LANGFUSE_DISPLAY_SECRET_KEY];
return;
}
const value = section[LANGFUSE_SECRET_KEY];
if (typeof value !== 'string' || value.length === 0 || value.startsWith(ENCRYPTED_PREFIX)) {
section[LANGFUSE_SECRET_KEY] = '';
section[LANGFUSE_DISPLAY_SECRET_KEY] = '';
return;
}
section[LANGFUSE_SECRET_KEY] = encryptV3(value);
section[LANGFUSE_DISPLAY_SECRET_KEY] = getDisplaySecretKey(value);
}
/**
* Returns a new field map with Langfuse secret entries encrypted and their
* displaySecretKey companion set. Empty values reset the secret and displaySecretKey.
*/
export function encryptConfigSecretFields(
fields: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...fields };
if (Array.isArray(result[LANGFUSE_SECTION])) {
delete result[LANGFUSE_SECTION];
} else {
const section = getPlainRecord(result[LANGFUSE_SECTION]);
if (section) {
result[LANGFUSE_SECTION] = encryptConfigSecrets(section, LANGFUSE_SECTION);
}
}
if (!(LANGFUSE_SECRET_PATH in result) && LANGFUSE_DISPLAY_SECRET_PATH in result) {
delete result[LANGFUSE_DISPLAY_SECRET_PATH];
}
if (LANGFUSE_SECRET_PATH in result) {
const value = result[LANGFUSE_SECRET_PATH];
if (typeof value !== 'string' || value.length === 0 || value.startsWith(ENCRYPTED_PREFIX)) {
result[LANGFUSE_SECRET_PATH] = '';
result[LANGFUSE_DISPLAY_SECRET_PATH] = '';
} else {
result[LANGFUSE_SECRET_PATH] = encryptV3(value);
result[LANGFUSE_DISPLAY_SECRET_PATH] = getDisplaySecretKey(value);
}
}
return result;
}
/**
* Returns a cloned config override object with Langfuse secret values encrypted
* before full-document writes. Empty secrets reset their displaySecretKey.
*/
export function encryptConfigSecrets<T>(root: T, basePath = ''): T {
if (root == null || typeof root !== 'object') {
return root;
}
const result = structuredClone(root);
if (basePath === '') {
delete (result as Record<string, unknown>)[LANGFUSE_SECRET_PATH];
delete (result as Record<string, unknown>)[LANGFUSE_DISPLAY_SECRET_PATH];
removeLangfuseArraySection(result as Record<string, unknown>);
}
const section = getLangfuseSection(result, basePath);
if (section) {
applyLangfuseSecretWrite(section);
}
return result;
}
/**
* Preserves an existing encrypted Langfuse secret when a whole Langfuse object is
* replaced without a secret value. This lets redacted admin reads round-trip
* safely: omitting a secret keeps it, while setting it to an empty value clears it.
*/
export function preserveConfigSecrets<T>(next: T, existing?: unknown, basePath = ''): T {
if (
next == null ||
typeof next !== 'object' ||
existing == null ||
typeof existing !== 'object'
) {
return next;
}
const result = structuredClone(next);
const section = getLangfuseSection(result, basePath);
const existingSection = getLangfuseSection(existing);
if (
!section ||
!existingSection ||
LANGFUSE_SECRET_KEY in section ||
!isEncryptedConfigSecret(existingSection[LANGFUSE_SECRET_KEY])
) {
return result;
}
const existingSecret = normalizeSecretString(existingSection[LANGFUSE_SECRET_KEY]);
if (!existingSecret) {
return result;
}
section[LANGFUSE_SECRET_KEY] = existingSecret;
if (typeof existingSection[LANGFUSE_DISPLAY_SECRET_KEY] === 'string') {
section[LANGFUSE_DISPLAY_SECRET_KEY] = existingSection[LANGFUSE_DISPLAY_SECRET_KEY];
}
return result;
}
/**
* Deletes Langfuse secret fields from `root` in place so admin reads never
* return secret values (encrypted or otherwise). Display companions are preserved.
* The caller passes a cloned object.
*/
export function redactConfigSecrets<T>(root: T): T {
const rootRecord = getPlainRecord(root);
if (!rootRecord) {
return root;
}
delete rootRecord[LANGFUSE_SECRET_PATH];
delete rootRecord[LANGFUSE_DISPLAY_SECRET_PATH];
if (Array.isArray(rootRecord[LANGFUSE_SECTION])) {
delete rootRecord[LANGFUSE_SECTION];
return root;
}
const section = getPlainRecord(rootRecord[LANGFUSE_SECTION]);
if (section) {
delete section[LANGFUSE_SECRET_KEY];
}
return root;
}

View file

@ -1,4 +1,4 @@
import { logger } from '@librechat/data-schemas';
import { encryptV3, logger } from '@librechat/data-schemas';
import {
EModelEndpoint,
FileSources,
@ -48,6 +48,13 @@ jest.mock('~/utils/env', () => ({
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
decryptV3: jest.fn((value: string) => {
if (value === 'v3:test:sk-tenant-1') {
return 'sk-tenant-1';
}
throw new Error('bad decrypt');
}),
encryptV3: jest.fn((value: string) => `v3:test:${value}`),
logger: {
debug: jest.fn(),
warn: jest.fn(),
@ -215,7 +222,6 @@ beforeEach(() => {
delete process.env.LANGFUSE_HOST;
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_BASE_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
});
@ -1190,16 +1196,17 @@ describe('Langfuse run config', () => {
});
it('adds tenant Langfuse credentials from tenant-scoped app config', async () => {
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: true,
collectorUrl: 'http://langfuse-fanout-collector:4318',
},
},
} as unknown as AppConfig,
@ -1229,8 +1236,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1251,14 +1258,13 @@ describe('Langfuse run config', () => {
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';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'https://cloud.langfuse.com';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
},
} as AppConfig,
});
@ -1271,7 +1277,7 @@ describe('Langfuse run config', () => {
});
});
it('routes tenant fanout traces to the configured destination for the tenant base URL', async () => {
it('routes tenant fanout traces to the configured tenant destination', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
@ -1280,8 +1286,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://us.cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
},
} as AppConfig,
});
@ -1307,8 +1313,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1329,8 +1335,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://us.cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
},
} as AppConfig,
});
@ -1361,8 +1367,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1390,8 +1396,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1418,8 +1424,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1434,7 +1440,7 @@ describe('Langfuse run config', () => {
});
});
it('uses deployment fanout collector URL without auth when the tenant base URL is not a configured destination', async () => {
it('uses deployment fanout collector URL without auth when the tenant destination is not configured', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
@ -1447,8 +1453,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://unconfigured-langfuse.example.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'unconfigured',
},
} as AppConfig,
});
@ -1515,7 +1521,7 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
},
} as AppConfig,
});
@ -1540,8 +1546,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1574,8 +1580,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1603,8 +1609,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
},
} as AppConfig,
});
@ -1636,8 +1642,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: false,
},
@ -1667,8 +1673,8 @@ describe('Langfuse run config', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: 'false',
},
@ -1693,7 +1699,7 @@ describe('Langfuse run config', () => {
langfuse: {
enabled: false,
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
},
} as AppConfig,
});
@ -1713,7 +1719,7 @@ describe('Langfuse run config', () => {
langfuse: {
enabled: 'false',
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
},
} as unknown as AppConfig,
});

View file

@ -1,40 +1,120 @@
import type { AppConfig } from '@librechat/data-schemas';
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
},
}));
import { buildLangfuseConfig } from './config';
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const CENTRAL_EXPORT_ATTRIBUTE = 'librechat.langfuse.central_export.enabled';
const envKeys = [
'LANGFUSE_PUBLIC_KEY',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_BASE_URL',
'LANGFUSE_HOST',
'LANGFUSE_BASEURL',
'LANGFUSE_FANOUT_ENABLED',
'LANGFUSE_FANOUT_COLLECTOR_URL',
'LANGFUSE_FANOUT_TENANT_DESTINATIONS',
'LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED',
];
function clearLangfuseEnv() {
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_BASE_URL;
delete process.env.LANGFUSE_BASEURL;
delete process.env.LANGFUSE_HOST;
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_BASE_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
function clearEnv() {
for (const key of envKeys) {
delete process.env[key];
}
}
describe('buildLangfuseConfig central export control', () => {
describe('buildLangfuseConfig', () => {
beforeEach(() => {
clearLangfuseEnv();
clearEnv();
});
it('keeps central export enabled by default', () => {
afterEach(() => {
clearEnv();
});
it('decrypts encrypted tenant secrets for tenant trace export', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
const config = buildLangfuseConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: encryptV3('sk-tenant-1'),
destination: 'eu',
fanout: {
enabled: true,
},
},
} as unknown as AppConfig,
});
expect(config).toMatchObject({
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'http://langfuse-fanout-collector:4318/tenant/eu',
librechatTraceAttributes: {
'librechat.langfuse.tenant_export.enabled': 'true',
'librechat.langfuse.destination': 'eu',
},
});
});
it('fails closed to central-only export when tenant secret decryption fails', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const { buildLangfuseConfig } = await import('./config');
const config = buildLangfuseConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'v3:not-valid-ciphertext',
destination: 'eu',
},
} as unknown as AppConfig,
});
expect(config).toEqual({
deterministicTraceId: true,
baseUrl: 'http://langfuse-fanout-collector:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('fails closed to central-only export when tenant secret is plaintext at runtime', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
const { buildLangfuseConfig } = await import('./config');
const config = buildLangfuseConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
destination: 'eu',
},
} as unknown as AppConfig,
});
expect(config).toEqual({
deterministicTraceId: true,
baseUrl: 'http://langfuse-fanout-collector:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('keeps central export enabled by default', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
const { buildLangfuseConfig } = await import('./config');
expect(buildLangfuseConfig({ tenantId: 'tenant-1' })).toEqual({
deterministicTraceId: true,
@ -46,10 +126,11 @@ describe('buildLangfuseConfig central export control', () => {
});
});
it('disables direct central tracing when central export is disabled', () => {
it('disables direct central tracing when central export is disabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
@ -67,11 +148,12 @@ describe('buildLangfuseConfig central export control', () => {
});
});
it('does not emit central-suppressed traces when there is no tenant fanout route', () => {
it('does not emit central-suppressed traces when there is no tenant fanout route', 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-from-env:4318';
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
@ -89,9 +171,11 @@ describe('buildLangfuseConfig central export control', () => {
});
});
it('routes tenant fanout traces while marking central export disabled', () => {
it('routes encrypted tenant credentials while marking central export 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({
@ -100,10 +184,10 @@ describe('buildLangfuseConfig central export control', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://us.cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
},
} as AppConfig,
} as unknown as AppConfig,
}),
).toMatchObject({
deterministicTraceId: true,
@ -120,10 +204,12 @@ describe('buildLangfuseConfig central export control', () => {
});
});
it('does not emit central-suppressed traces when tenant fanout is emergency-disabled', () => {
it('does not emit central-suppressed traces when tenant fanout is emergency-disabled', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const { encryptV3 } = await import('@librechat/data-schemas');
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
@ -132,10 +218,10 @@ describe('buildLangfuseConfig central export control', () => {
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://us.cloud.langfuse.com',
secretKey: encryptV3('sk-tenant-1'),
destination: 'us',
},
} as AppConfig,
} as unknown as AppConfig,
}),
).toEqual({
deterministicTraceId: true,
@ -148,7 +234,9 @@ describe('buildLangfuseConfig central export control', () => {
});
});
it('honors tenant Langfuse enabled=false before adding routing attributes', () => {
it('honors tenant Langfuse enabled=false before adding routing attributes', async () => {
const { buildLangfuseConfig } = await import('./config');
expect(
buildLangfuseConfig({
tenantId: 'tenant-1',

View file

@ -1,14 +1,12 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { RunConfig } from '@librechat/agents';
import { isTrueEnv, normalizeBoolean, resolveTenantCredentials } from './utils';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { isTrueEnv, normalizeBoolean } from './utils';
import { normalizeString } from '~/utils/text';
type LangfuseRunConfig = NonNullable<RunConfig['langfuse']>;
type LangfuseAppConfig = NonNullable<AppConfig['langfuse']>;
export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'] & {
collectorUrl?: string;
};
export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'];
type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & {
librechatTraceAttributes?: Record<string, string | number | boolean | null | undefined>;
};
@ -166,23 +164,20 @@ export function buildLangfuseConfig({
disableCentralExport(langfuse);
}
const publicKey = normalizeString(config?.publicKey);
const secretKey = normalizeString(config?.secretKey);
const hasTenantCredentials = Boolean(publicKey && secretKey);
const tenantCredentials = resolveTenantCredentials(config);
const hasTenantCredentials = Boolean(tenantCredentials);
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
const fanoutEnabled = isLangfuseFanoutEnabled(fanout);
const fanoutCollectorUrl =
normalizeString(fanout?.collectorUrl) ??
normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const tenantDestination = resolveLangfuseTenantDestination(config?.destination);
const tenantExportEmergencyEnabled = isLangfuseTenantExportEnabled();
const tenantDestination = resolveLangfuseTenantDestination(config?.baseUrl);
const exportPlan = resolveLangfuseExportPlan({
centralTraceExportEnabled,
fanoutEnabled,
fanoutCollectorUrl,
tenantExportEnabled: hasTenantCredentials && tenantExportEmergencyEnabled,
publicKey,
secretKey,
publicKey: tenantCredentials?.publicKey,
secretKey: tenantCredentials?.secretKey,
tenantDestination,
});

View file

@ -1,7 +1,12 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { LangfuseFanoutConfig } from './config';
import {
isFalseEnv,
normalizeBoolean,
resolveTenantCredentials,
toBasicAuthorization,
} from './utils';
import { isLangfuseFanoutEnabled, isLangfuseTenantExportEnabled } from './config';
import { isFalseEnv, normalizeBoolean, toBasicAuthorization } from './utils';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { normalizeString } from '~/utils/text';
@ -74,19 +79,16 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
if (!isLangfuseFanoutEnabled(fanout)) {
return undefined;
}
const fanoutCollectorUrl =
normalizeString(fanout?.collectorUrl) ??
normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
if (!fanoutCollectorUrl) {
return undefined;
}
const publicKey = normalizeString(config?.publicKey);
const secretKey = normalizeString(config?.secretKey);
if (!publicKey || !secretKey) {
const tenantCredentials = resolveTenantCredentials(config);
if (!tenantCredentials) {
return undefined;
}
const destination = resolveLangfuseTenantDestination(config?.baseUrl);
const destination = resolveLangfuseTenantDestination(config?.destination);
if (!destination) {
return undefined;
}
@ -94,13 +96,13 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
return {
name: 'tenant',
baseUrl: destination.baseUrl,
authorization: toBasicAuthorization(publicKey, secretKey),
authorization: toBasicAuthorization(tenantCredentials.publicKey, tenantCredentials.secretKey),
};
}
/**
* Score fanout uses Langfuse's direct REST API. Trace fanout may use the OTLP
* collector via appConfig.langfuse.fanout.collectorUrl/LANGFUSE_FANOUT_COLLECTOR_URL.
* 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(

View file

@ -1,16 +1,33 @@
import type { AppConfig } from '@librechat/data-schemas';
process.env.CREDS_KEY =
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
jest.mock(
'@librechat/data-schemas',
() => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
decryptV3: jest.fn((value: string) => {
if (value === 'v3:test:tenant-secret-key') {
return 'tenant-secret-key';
}
throw new Error('bad decrypt');
}),
encryptV3: jest.fn((value: string) => `v3:test:${value}`),
}),
{ virtual: true },
);
jest.mock('~/admin/secrets', () => ({
decryptConfigSecret: jest.fn((value: string) =>
value === 'v3:test:tenant-secret-key' ? 'tenant-secret-key' : undefined,
),
}));
const langfuseEnvKeys = [
'LANGFUSE_PUBLIC_KEY',
'LANGFUSE_SECRET_KEY',
@ -22,7 +39,6 @@ const langfuseEnvKeys = [
'LANGFUSE_TRACING_ENVIRONMENT',
'LANGFUSE_FANOUT_ENABLED',
'LANGFUSE_FANOUT_COLLECTOR_URL',
'LANGFUSE_FANOUT_TENANT_BASE_URL',
'LANGFUSE_FANOUT_TENANT_DESTINATIONS',
'LANGFUSE_FANOUT_TENANT_EU_BASE_URL',
'LANGFUSE_FANOUT_TENANT_US_BASE_URL',
@ -63,6 +79,10 @@ function getTenantAuthorization(
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`;
}
function encryptedTenantSecret(): string {
return 'v3:test:tenant-secret-key';
}
function getCentralAuthorization(): string {
return getTenantAuthorization('public-key', 'secret-key');
}
@ -154,7 +174,7 @@ describe('Langfuse feedback scores', () => {
it('posts feedback scores to central fanout and tenant Langfuse projects', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
@ -164,8 +184,8 @@ describe('Langfuse feedback scores', () => {
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
secretKey: encryptedTenantSecret(),
destination: 'eu',
},
} as AppConfig,
});
@ -205,10 +225,10 @@ describe('Langfuse feedback scores', () => {
});
});
it('skips tenant feedback scores when tenant keys are configured without a tenant base URL', async () => {
it('decrypts encrypted tenant secrets before sending tenant feedback scores', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
@ -216,7 +236,64 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'http://tenant-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: getTenantAuthorization(),
}),
}),
);
});
it('skips tenant feedback scores when encrypted tenant secret decryption fails', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'v3:test:bad-secret',
destination: 'eu',
}),
});
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 feedback scores when tenant keys are configured without a tenant destination', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: encryptedTenantSecret(),
}),
});
@ -230,7 +307,7 @@ describe('Langfuse feedback scores', () => {
);
});
it('posts tenant feedback scores to the configured destination for the tenant base URL', async () => {
it('posts tenant feedback scores to the configured tenant destination', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
@ -240,8 +317,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://us.cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'us',
}),
});
@ -257,7 +334,7 @@ describe('Langfuse feedback scores', () => {
);
});
it('skips tenant feedback scores when the tenant base URL is not a configured destination', async () => {
it('skips tenant feedback scores when the tenant destination is not configured', async () => {
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=https://cloud.langfuse.com';
const { sendFeedbackScore } = await loadFeedback();
@ -267,8 +344,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://unconfigured-langfuse.example.com',
secretKey: encryptedTenantSecret(),
destination: 'unconfigured',
}),
});
@ -285,7 +362,7 @@ describe('Langfuse feedback scores', () => {
it('deletes feedback scores from central and tenant Langfuse projects', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
@ -294,8 +371,8 @@ describe('Langfuse feedback scores', () => {
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
secretKey: encryptedTenantSecret(),
destination: 'eu',
},
} as AppConfig,
});
@ -325,7 +402,7 @@ describe('Langfuse feedback scores', () => {
enableTenantFanout();
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
@ -333,8 +410,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -359,7 +436,7 @@ describe('Langfuse feedback scores', () => {
appConfig: appConfigWithLangfuse({
enabled: false,
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
secretKey: encryptedTenantSecret(),
}),
});
@ -384,8 +461,8 @@ describe('Langfuse feedback scores', () => {
appConfig: appConfigWithLangfuse({
enabled: 'false',
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
} as unknown as AppConfig['langfuse']),
});
@ -402,7 +479,7 @@ describe('Langfuse feedback scores', () => {
it('skips tenant scores when tenant fanout export is disabled but keeps central scores', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const { sendFeedbackScore } = await loadFeedback();
@ -411,7 +488,7 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
secretKey: encryptedTenantSecret(),
}),
});
@ -436,8 +513,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -471,8 +548,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -500,8 +577,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -525,8 +602,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -550,8 +627,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
fanout: { enabled: false },
}),
});
@ -576,8 +653,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
fanout: { enabled: 'false' },
} as unknown as AppConfig['langfuse']),
});
@ -602,8 +679,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -629,8 +706,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -644,7 +721,7 @@ describe('Langfuse feedback scores', () => {
it('attempts every destination and reports partial feedback score failures', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response('central down', { status: 500 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
@ -657,8 +734,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
}),
).rejects.toThrow('langfuse central score create failed: score create 500: central down');
@ -673,7 +750,7 @@ describe('Langfuse feedback scores', () => {
it('reports tenant feedback score failures after central succeeds', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response('tenant down', { status: 503 }));
@ -686,8 +763,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
}),
).rejects.toThrow('langfuse tenant score create failed: score create 503: tenant down');
@ -705,7 +782,7 @@ describe('Langfuse feedback scores', () => {
it('aggregates feedback score failures when every destination fails', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response('central down', { status: 500 }))
.mockResolvedValueOnce(new Response('tenant down', { status: 503 }));
@ -717,8 +794,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
}),
).rejects.toThrow(
@ -756,8 +833,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});
@ -785,8 +862,8 @@ describe('Langfuse feedback scores', () => {
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
secretKey: encryptedTenantSecret(),
destination: 'eu',
}),
});

View file

@ -7,7 +7,6 @@ const DEFAULT_TENANT_DESTINATIONS: Array<[string, string]> = [
];
const DESTINATIONS_ENV = 'LANGFUSE_FANOUT_TENANT_DESTINATIONS';
const LEGACY_TENANT_BASE_URL_ENV = 'LANGFUSE_FANOUT_TENANT_BASE_URL';
export type LangfuseTenantDestination = {
key: string;
@ -84,28 +83,26 @@ export function getLangfuseTenantDestinations(): LangfuseTenantDestination[] {
return uniqueDestinations(configured);
}
const legacyBaseUrl = normalizeBaseUrl(process.env[LEGACY_TENANT_BASE_URL_ENV]);
const defaults = DEFAULT_TENANT_DESTINATIONS.map(([key, defaultBaseUrl]) => ({
key,
baseUrl:
normalizeBaseUrl(process.env[destinationEnvName(key)]) ??
(key === 'eu' ? legacyBaseUrl : undefined) ??
defaultBaseUrl,
baseUrl: normalizeBaseUrl(process.env[destinationEnvName(key)]) ?? defaultBaseUrl,
}));
return uniqueDestinations(defaults);
}
export function resolveLangfuseTenantDestination(
baseUrl: unknown,
destinationKey: unknown,
): LangfuseTenantDestination | undefined {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
const normalizedKey = normalizeString(destinationKey);
if (!normalizedKey) {
return undefined;
}
return getLangfuseTenantDestinations().find(
(destination) => destination.baseUrl === normalizedBaseUrl,
);
const key = normalizeDestinationKey(normalizedKey);
if (!key) {
return undefined;
}
return getLangfuseTenantDestinations().find((destination) => destination.key === key);
}

View file

@ -1,7 +1,24 @@
import type { AppConfig } from '@librechat/data-schemas';
import { decryptConfigSecret } from '~/admin/secrets';
import { normalizeString } from '~/utils/text';
type LangfuseAppConfig = NonNullable<AppConfig['langfuse']>;
export function toBasicAuthorization(publicKey: string, secretKey: string): string {
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`;
}
export function resolveTenantCredentials(
config?: LangfuseAppConfig,
): { publicKey: string; secretKey: string } | undefined {
const publicKey = normalizeString(config?.publicKey);
const secretKey = decryptConfigSecret(config?.secretKey);
if (!publicKey || !secretKey) {
return undefined;
}
return { publicKey, secretKey };
}
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);

View file

@ -1844,11 +1844,14 @@ export const langfuseConfigSchema = z.object({
enabled: z.boolean().optional(),
publicKey: z.string().optional(),
secretKey: z.string().optional(),
baseUrl: z.string().optional(),
/** Non-secret display value of the secret key, stored at write time so
* admin reads can show which secret key is configured without returning the secret. */
displaySecretKey: 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(),
collectorUrl: z.string().optional(),
})
.optional(),
});