fix: harden Langfuse agent config handling

This commit is contained in:
Danny Avila 2026-05-12 00:10:20 -04:00
parent 0c2fdc92cd
commit bc636c9a2e
18 changed files with 628 additions and 149 deletions

View file

@ -1,12 +1,14 @@
const { z } = require('zod');
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger, encryptV2 } = require('@librechat/data-schemas');
const { logger } = require('@librechat/data-schemas');
const {
refreshS3Url,
agentCreateSchema,
agentUpdateSchema,
redactLangfuseSecret,
refreshListAvatars,
normalizeLangfuseConfig,
collectEdgeAgentIds,
mergeAgentOcrConversion,
MAX_AVATAR_REFRESH_AGENTS,
@ -60,88 +62,6 @@ const getSafeModelParameters = (modelParameters) => {
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
};
const toPlainObject = (value) =>
value && typeof value.toObject === 'function' ? value.toObject() : value;
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i;
const encryptSensitiveValue = async (value) => encryptV2(encodeURIComponent(value));
const normalizeLangfuseSecret = async (value, options = {}) => {
if (!isNonEmptyString(value)) {
return undefined;
}
const trimmed = value.trim();
if (options.preserveEncrypted === true && ENCRYPTED_V2_VALUE.test(trimmed)) {
return trimmed;
}
return await encryptSensitiveValue(trimmed);
};
const normalizeLangfuseConfig = async (incoming, existing, options = {}) => {
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
return incoming;
}
const existingConfig = toPlainObject(existing) ?? {};
const normalized = {};
if (typeof incoming.enabled === 'boolean') {
normalized.enabled = incoming.enabled;
}
for (const key of ['publicKey', 'baseUrl']) {
if (isNonEmptyString(incoming[key])) {
normalized[key] = incoming[key].trim();
}
}
if (isNonEmptyString(incoming.secretKey)) {
normalized.secretKey = await normalizeLangfuseSecret(incoming.secretKey, {
preserveEncrypted: options.preserveIncomingEncrypted === true,
});
} else if (isNonEmptyString(existingConfig.secretKey)) {
normalized.secretKey = await normalizeLangfuseSecret(existingConfig.secretKey, {
preserveEncrypted: true,
});
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
const redactLangfuseSecret = (agent) => {
const payload = toPlainObject(agent);
if (!payload || typeof payload !== 'object') {
return payload;
}
const redactSingleAgent = (value) => {
if (!value || typeof value !== 'object') {
return value;
}
if (value.langfuse && typeof value.langfuse === 'object' && value.langfuse.secretKey) {
return {
...value,
langfuse: {
...toPlainObject(value.langfuse),
secretKey: '',
},
};
}
return value;
};
const redactedPayload = redactSingleAgent(payload);
if (Array.isArray(redactedPayload.versions)) {
redactedPayload.versions = redactedPayload.versions.map((version) =>
redactSingleAgent(toPlainObject(version)),
);
}
return redactedPayload;
};
/**
* Looks up each referenced agent id in Mongo, splits them into three
* buckets the caller needs for validation: ids that don't exist at all,

View file

@ -10,7 +10,11 @@ const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { agentSchema, fileSchema, encryptV2, decryptV2 } = require('@librechat/data-schemas');
const { FileSources, PermissionBits } = require('librechat-data-provider');
const {
FileSources,
PermissionBits,
LANGFUSE_SECRET_CLEAR_VALUE,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
// Only mock the dependencies that are not database-related
@ -877,6 +881,44 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(await decryptStoredSecret(agentInDb.langfuse.secretKey)).toBe('sk-updated');
});
test('should clear existing Langfuse secret when update sends the clear sentinel', async () => {
const encryptedOriginal = await encryptStoredSecret('sk-original');
await Agent.updateOne(
{ id: existingAgentId },
{
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: encryptedOriginal,
baseUrl: 'https://cloud.langfuse.com',
},
},
);
mockReq.params.id = existingAgentId;
mockReq.body = {
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: LANGFUSE_SECRET_CLEAR_VALUE,
baseUrl: 'https://cloud.langfuse.com',
},
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.langfuse.secretKey).toBeUndefined();
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
expect(agentInDb.langfuse).toEqual({
enabled: true,
publicKey: 'pk-original',
baseUrl: 'https://cloud.langfuse.com',
});
});
test('uploadAgentAvatarHandler should redact Langfuse secret in response', async () => {
await Agent.updateOne(
{ id: existingAgentId },

View file

@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { Activity, Eye, EyeOff } from 'lucide-react';
import { Activity, Eye, EyeOff, X } from 'lucide-react';
import { Input, Label, Switch } from '@librechat/client';
import { LANGFUSE_SECRET_CLEAR_VALUE } from 'librechat-data-provider';
import type { ControllerRenderProps } from 'react-hook-form';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
@ -10,7 +11,7 @@ interface AgentLangfuseProps {
}
const fieldDefaults = {
enabled: false,
enabled: undefined as boolean | undefined,
publicKey: '',
secretKey: '',
baseUrl: '',
@ -21,9 +22,15 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
const [showSecret, setShowSecret] = useState(false);
const value = useMemo(() => ({ ...fieldDefaults, ...(field.value ?? {}) }), [field.value]);
const enabled = value.enabled === true;
let statusKey = 'com_ui_agent_langfuse_inherited';
if (typeof value.enabled === 'boolean') {
statusKey = enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled';
}
const secretMarkedForClear = value.secretKey === LANGFUSE_SECRET_CLEAR_VALUE;
const secretInputValue = secretMarkedForClear ? '' : value.secretKey;
const updateField = useCallback(
(key: keyof typeof fieldDefaults, next: string | boolean) => {
(key: keyof typeof fieldDefaults, next: string | boolean | undefined) => {
field.onChange({
...value,
[key]: next,
@ -32,6 +39,10 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
[field, value],
);
const toggleClearSecret = useCallback(() => {
updateField('secretKey', secretMarkedForClear ? '' : LANGFUSE_SECRET_CLEAR_VALUE);
}, [secretMarkedForClear, updateField]);
const enableId = 'agent-langfuse-enable-toggle';
return (
@ -52,7 +63,7 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
{localize(enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled')}
{localize(statusKey)}
</span>
<Switch
id={enableId}
@ -80,14 +91,29 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
</div>
<div className="space-y-1.5">
<Label htmlFor="agent-langfuse-secret-key" className="text-xs font-medium">
{localize('com_ui_agent_langfuse_secret_key')}
</Label>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="agent-langfuse-secret-key" className="text-xs font-medium">
{localize('com_ui_agent_langfuse_secret_key')}
</Label>
<button
type="button"
onClick={toggleClearSecret}
className="inline-flex items-center gap-1 text-xs font-medium text-text-secondary transition-colors hover:text-text-primary"
>
{secretMarkedForClear && <X className="h-3.5 w-3.5" aria-hidden="true" />}
{localize(
secretMarkedForClear
? 'com_ui_agent_langfuse_cancel_clear_secret'
: 'com_ui_agent_langfuse_clear_secret',
)}
</button>
</div>
<div className="relative">
<Input
id="agent-langfuse-secret-key"
type={showSecret ? 'text' : 'password'}
value={value.secretKey}
value={secretInputValue}
disabled={secretMarkedForClear}
onChange={(event) => updateField('secretKey', event.target.value)}
placeholder={localize('com_ui_agent_langfuse_secret_key_placeholder')}
autoComplete="new-password"
@ -126,7 +152,7 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
href="https://langfuse.com/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
className="inline-flex text-xs font-medium text-text-secondary transition-colors hover:text-text-primary"
>
{localize('com_ui_agent_langfuse_docs')}
</a>

View file

@ -53,22 +53,25 @@ function getUpdateToastMessage(
function composeLangfusePayload(
langfuse: AgentForm['langfuse'],
existingLangfuse?: AgentForm['langfuse'],
): AgentForm['langfuse'] | undefined {
if (!langfuse) {
return undefined;
}
const normalized = {
enabled: langfuse.enabled === true,
const normalized: NonNullable<AgentForm['langfuse']> = {
publicKey: langfuse.publicKey?.trim() ?? '',
secretKey: langfuse.secretKey?.trim() ?? '',
baseUrl: langfuse.baseUrl?.trim() ?? '',
};
if (typeof langfuse.enabled === 'boolean') {
normalized.enabled = langfuse.enabled;
}
const hasCredentialValue =
normalized.publicKey !== '' || normalized.secretKey !== '' || normalized.baseUrl !== '';
const hasExplicitEnabled = typeof normalized.enabled === 'boolean';
if (!normalized.enabled && !hasCredentialValue && !existingLangfuse) {
if (!hasExplicitEnabled && !hasCredentialValue) {
return undefined;
}
@ -84,7 +87,6 @@ function composeLangfusePayload(
*/
export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | null) {
const {
agent,
name,
artifacts,
description,
@ -112,7 +114,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
const model = _model ?? '';
const provider =
(typeof _provider === 'string' ? _provider : (_provider as StringOption).value) ?? '';
const langfusePayload = composeLangfusePayload(langfuse, agent?.langfuse);
const langfusePayload = composeLangfusePayload(langfuse);
return {
payload: {

View file

@ -133,7 +133,7 @@ function AgentSelect({
if (name === 'langfuse' && typeof value === 'object' && value !== null) {
const langfuse = value as NonNullable<AgentForm['langfuse']>;
formValues[name] = {
enabled: langfuse.enabled === true,
enabled: typeof langfuse.enabled === 'boolean' ? langfuse.enabled : undefined,
publicKey: typeof langfuse.publicKey === 'string' ? langfuse.publicKey : '',
secretKey: '',
baseUrl: typeof langfuse.baseUrl === 'string' ? langfuse.baseUrl : '',

View file

@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import { describe, it, expect, jest } from '@jest/globals';
import { Constants, type Agent } from 'librechat-data-provider';
import { Constants, LANGFUSE_SECRET_CLEAR_VALUE, type Agent } from 'librechat-data-provider';
import type { FieldNamesMarkedBoolean } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
@ -85,10 +85,10 @@ describe('composeAgentUpdatePayload', () => {
});
});
it('omits blank disabled Langfuse defaults to preserve tenant inheritance', () => {
it('omits blank inherited Langfuse defaults to preserve tenant inheritance', () => {
const form = createForm();
form.langfuse = {
enabled: false,
enabled: undefined,
publicKey: '',
secretKey: '',
baseUrl: '',
@ -99,17 +99,8 @@ describe('composeAgentUpdatePayload', () => {
expect(payload.langfuse).toBeUndefined();
});
it('keeps an explicit blank disabled Langfuse override for existing agent config', () => {
it('keeps an explicit blank disabled Langfuse override', () => {
const form = createForm();
form.agent = {
id: 'agent_123',
langfuse: {
enabled: true,
publicKey: 'pk-existing',
secretKey: '',
baseUrl: 'https://cloud.langfuse.com',
},
} as Agent;
form.langfuse = {
enabled: false,
publicKey: '',
@ -126,6 +117,58 @@ describe('composeAgentUpdatePayload', () => {
baseUrl: '',
});
});
it('preserves undefined Langfuse enabled so tenant inheritance survives routine saves', () => {
const form = createForm();
form.agent = {
id: 'agent_123',
langfuse: {
publicKey: 'pk-existing',
secretKey: '',
},
} as Agent;
form.langfuse = {
enabled: undefined,
publicKey: 'pk-existing',
secretKey: '',
baseUrl: '',
};
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.langfuse).toEqual({
publicKey: 'pk-existing',
secretKey: '',
baseUrl: '',
});
});
it('sends the Langfuse secret clear sentinel when requested', () => {
const form = createForm();
form.agent = {
id: 'agent_123',
langfuse: {
enabled: true,
publicKey: 'pk-existing',
secretKey: '',
},
} as Agent;
form.langfuse = {
enabled: true,
publicKey: 'pk-existing',
secretKey: LANGFUSE_SECRET_CLEAR_VALUE,
baseUrl: '',
};
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.langfuse).toEqual({
enabled: true,
publicKey: 'pk-existing',
secretKey: LANGFUSE_SECRET_CLEAR_VALUE,
baseUrl: '',
});
});
});
describe('persistAvatarChanges', () => {

View file

@ -705,10 +705,13 @@
"com_ui_agent_langfuse": "Langfuse tracing",
"com_ui_agent_langfuse_base_url": "Base URL",
"com_ui_agent_langfuse_base_url_placeholder": "https://cloud.langfuse.com",
"com_ui_agent_langfuse_cancel_clear_secret": "Cancel clear",
"com_ui_agent_langfuse_clear_secret": "Clear saved key",
"com_ui_agent_langfuse_disabled": "Disabled",
"com_ui_agent_langfuse_docs": "View Langfuse docs",
"com_ui_agent_langfuse_enable": "Enable Langfuse tracing",
"com_ui_agent_langfuse_enabled": "Enabled",
"com_ui_agent_langfuse_inherited": "Inherited",
"com_ui_agent_langfuse_info": "Capture this agent's LLM calls in Langfuse. Empty credential fields inherit tenant defaults when configured.",
"com_ui_agent_langfuse_public_key": "Public Key",
"com_ui_agent_langfuse_public_key_placeholder": "Enter your Public Key",

View file

@ -8,6 +8,7 @@ export * from './edges';
export * from './handlers';
export * from './initialize';
export * from './legacy';
export * from './langfuse';
export * from './memory';
export * from './orphans';
export * from './migration';

View file

@ -0,0 +1,87 @@
import { LANGFUSE_SECRET_CLEAR_VALUE } from 'librechat-data-provider';
import { normalizeLangfuseConfig, redactLangfuseSecret } from './langfuse';
describe('normalizeLangfuseConfig', () => {
it('returns undefined for non-object input', async () => {
await expect(normalizeLangfuseConfig('not-object')).resolves.toBeUndefined();
});
it('preserves env var refs instead of encrypting them', async () => {
const result = await normalizeLangfuseConfig({
enabled: true,
secretKey: '${LANGFUSE_SECRET_KEY}',
});
expect(result).toEqual({
enabled: true,
secretKey: '${LANGFUSE_SECRET_KEY}',
});
});
it('clears an existing secret when the clear sentinel is sent', async () => {
const result = await normalizeLangfuseConfig(
{
enabled: true,
publicKey: 'pk-agent',
secretKey: LANGFUSE_SECRET_CLEAR_VALUE,
},
{
enabled: true,
publicKey: 'pk-agent',
secretKey: '0123456789abcdef0123456789abcdef:736b2d6167656e74',
},
);
expect(result).toEqual({
enabled: true,
publicKey: 'pk-agent',
});
});
});
describe('redactLangfuseSecret', () => {
it('redacts top-level and version Langfuse secrets without mutating the input', () => {
const agent = {
id: 'agent_1',
langfuse: {
enabled: true,
publicKey: 'pk-agent',
secretKey: 'sk-agent',
},
versions: [
{
langfuse: {
secretKey: 'sk-version',
},
},
],
};
const result = redactLangfuseSecret(agent);
expect(result).toEqual({
id: 'agent_1',
langfuse: {
enabled: true,
publicKey: 'pk-agent',
secretKey: '',
},
versions: [
{
langfuse: {
secretKey: '',
},
},
],
});
expect(agent.langfuse.secretKey).toBe('sk-agent');
expect(agent.versions[0].langfuse.secretKey).toBe('sk-version');
});
it('returns the original object when there is nothing to redact', () => {
const agent = { id: 'agent_1', name: 'No Langfuse' };
expect(redactLangfuseSecret(agent)).toBe(agent);
});
});

View file

@ -0,0 +1,141 @@
import { encryptV2 } from '@librechat/data-schemas';
import { LANGFUSE_SECRET_CLEAR_VALUE, extractVariableName } from 'librechat-data-provider';
import type { LangfuseConfig } from 'librechat-data-provider';
export const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i;
export function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function toPlainObject(value: unknown): unknown {
if (!isRecord(value) || typeof value.toObject !== 'function') {
return value;
}
return value.toObject();
}
async function encryptSensitiveValue(value: string): Promise<string> {
return await encryptV2(encodeURIComponent(value));
}
async function normalizeLangfuseSecret(
value: string,
options: { preserveEncrypted?: boolean } = {},
): Promise<string | undefined> {
if (!isNonEmptyString(value)) {
return undefined;
}
const trimmed = value.trim();
if (trimmed === LANGFUSE_SECRET_CLEAR_VALUE) {
return undefined;
}
if (extractVariableName(trimmed) != null) {
return trimmed;
}
if (options.preserveEncrypted === true && ENCRYPTED_V2_VALUE.test(trimmed)) {
return trimmed;
}
return await encryptSensitiveValue(trimmed);
}
export async function normalizeLangfuseConfig(
incoming: unknown,
existing?: unknown,
options: { preserveIncomingEncrypted?: boolean } = {},
): Promise<LangfuseConfig | undefined> {
if (!isRecord(incoming)) {
return undefined;
}
const existingConfig = toPlainObject(existing);
const existingLangfuse = isRecord(existingConfig) ? existingConfig : {};
const normalized: LangfuseConfig = {};
if (typeof incoming.enabled === 'boolean') {
normalized.enabled = incoming.enabled;
}
for (const key of ['publicKey', 'baseUrl'] as const) {
const value = incoming[key];
if (isNonEmptyString(value)) {
normalized[key] = value.trim();
}
}
const incomingSecret = incoming.secretKey;
if (incomingSecret === LANGFUSE_SECRET_CLEAR_VALUE) {
return normalized;
}
if (isNonEmptyString(incomingSecret)) {
normalized.secretKey = await normalizeLangfuseSecret(incomingSecret, {
preserveEncrypted: options.preserveIncomingEncrypted === true,
});
} else {
const existingSecret = existingLangfuse.secretKey;
if (isNonEmptyString(existingSecret)) {
normalized.secretKey = await normalizeLangfuseSecret(existingSecret, {
preserveEncrypted: true,
});
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function hasLangfuseSecret(value: unknown): boolean {
const plain = toPlainObject(value);
if (!isRecord(plain)) {
return false;
}
const langfuse = toPlainObject(plain.langfuse);
return isRecord(langfuse) && isNonEmptyString(langfuse.secretKey);
}
function redactSingleAgent(value: unknown): unknown {
const plain = toPlainObject(value);
if (!isRecord(plain)) {
return plain;
}
const langfuse = toPlainObject(plain.langfuse);
if (!isRecord(langfuse) || !isNonEmptyString(langfuse.secretKey)) {
return plain;
}
return {
...plain,
langfuse: {
...langfuse,
secretKey: '',
},
};
}
export function redactLangfuseSecret(agent: unknown): unknown {
const payload = toPlainObject(agent);
if (!isRecord(payload)) {
return payload;
}
const versions = Array.isArray(payload.versions) ? payload.versions : [];
if (!hasLangfuseSecret(payload) && !versions.some(hasLangfuseSecret)) {
return payload;
}
const redactedPayload = redactSingleAgent(payload);
if (!isRecord(redactedPayload) || !Array.isArray(redactedPayload.versions)) {
return redactedPayload;
}
return {
...redactedPayload,
versions: redactedPayload.versions.map(redactSingleAgent),
};
}

View file

@ -22,6 +22,7 @@ jest.mock('@librechat/data-schemas', () => {
type LangfuseRunAgent = Parameters<typeof resolveEffectiveLangfuseConfig>[0];
type LangfuseAppConfig = NonNullable<Parameters<typeof resolveEffectiveLangfuseConfig>[1]>;
const decryptV2Mock = jest.mocked(decryptV2);
const createLangfuseAgent = (langfuse?: LangfuseRunAgent['langfuse']): LangfuseRunAgent =>
({
@ -29,9 +30,7 @@ const createLangfuseAgent = (langfuse?: LangfuseRunAgent['langfuse']): LangfuseR
langfuse,
}) as LangfuseRunAgent;
const createLangfuseAppConfig = (
langfuse?: LangfuseAppConfig['langfuse'],
): LangfuseAppConfig =>
const createLangfuseAppConfig = (langfuse?: LangfuseAppConfig['langfuse']): LangfuseAppConfig =>
({
langfuse,
}) as LangfuseAppConfig;
@ -186,6 +185,11 @@ describe('resolveEffectiveLangfuseConfig', () => {
beforeEach(() => {
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger);
decryptV2Mock.mockImplementation(async (value: string) =>
value === '0123456789abcdef0123456789abcdef:736b2d6167656e74'
? encodeURIComponent('sk-agent')
: value,
);
process.env.LANGFUSE_TEST_PUBLIC_KEY = 'pk-tenant';
process.env.LANGFUSE_TEST_SECRET_KEY = 'sk-tenant';
process.env.LANGFUSE_TEST_BASE_URL = 'https://cloud.langfuse.com';
@ -194,6 +198,7 @@ describe('resolveEffectiveLangfuseConfig', () => {
afterEach(() => {
warnSpy.mockRestore();
decryptV2Mock.mockReset();
delete process.env.LANGFUSE_TEST_PUBLIC_KEY;
delete process.env.LANGFUSE_TEST_SECRET_KEY;
delete process.env.LANGFUSE_TEST_BASE_URL;
@ -247,6 +252,25 @@ describe('resolveEffectiveLangfuseConfig', () => {
});
});
it('inherits tenant enabled when an agent override omits enabled', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
publicKey: 'pk-agent',
}),
createLangfuseAppConfig({
enabled: true,
publicKey: 'pk-tenant',
secretKey: 'sk-tenant',
}),
);
expect(result).toEqual({
enabled: true,
publicKey: 'pk-agent',
secretKey: 'sk-tenant',
});
});
it('enables tracing with valid keys and no base URL', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent(),
@ -287,6 +311,99 @@ describe('resolveEffectiveLangfuseConfig', () => {
});
});
it('does not expand placeholders inside decrypted literal agent secrets', async () => {
const encryptedSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74';
decryptV2Mock.mockResolvedValueOnce(encodeURIComponent('sk-${literal}'));
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: true,
publicKey: 'pk-agent',
secretKey: encryptedSecret,
}),
createLangfuseAppConfig(),
);
expect(result).toEqual({
enabled: true,
publicKey: 'pk-agent',
secretKey: 'sk-${literal}',
});
});
it('still resolves exact env refs stored in encrypted agent secrets', async () => {
const encryptedSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74';
decryptV2Mock.mockResolvedValueOnce(encodeURIComponent('${LANGFUSE_TEST_SECRET_KEY}'));
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: true,
publicKey: 'pk-agent',
secretKey: encryptedSecret,
}),
createLangfuseAppConfig(),
);
expect(result).toEqual({
enabled: true,
publicKey: 'pk-agent',
secretKey: 'sk-tenant',
});
});
it('does not attempt to decrypt tenant secrets that look encrypted', async () => {
const encryptedLookingSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74';
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent(),
createLangfuseAppConfig({
enabled: true,
publicKey: 'pk-tenant',
secretKey: encryptedLookingSecret,
}),
);
expect(decryptV2Mock).not.toHaveBeenCalled();
expect(result).toEqual({
enabled: true,
publicKey: 'pk-tenant',
secretKey: encryptedLookingSecret,
});
});
it('disables tracing and warns when agent secret decryption fails', async () => {
const encryptedSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74';
decryptV2Mock.mockRejectedValueOnce(new Error('bad decrypt'));
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: true,
publicKey: 'pk-agent',
secretKey: encryptedSecret,
}),
createLangfuseAppConfig(),
);
expect(result).toEqual({ enabled: false });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to decrypt secretKey'));
});
it('disables tracing and warns when decrypted agent secret is malformed', async () => {
const encryptedSecret = '0123456789abcdef0123456789abcdef:736b2d6167656e74';
decryptV2Mock.mockResolvedValueOnce('%E0%A4%A');
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({
enabled: true,
publicKey: 'pk-agent',
secretKey: encryptedSecret,
}),
createLangfuseAppConfig(),
);
expect(result).toEqual({ enabled: false });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to decrypt secretKey'));
});
it('lets an agent explicitly disable tracing over enabled tenant defaults', async () => {
const result = await resolveEffectiveLangfuseConfig(
createLangfuseAgent({

View file

@ -5,6 +5,7 @@ import {
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_RUN_CONFIGS,
extractEnvVariable,
extractVariableName,
providerEndpointMap,
normalizeEndpointName,
} from 'librechat-data-provider';
@ -36,6 +37,7 @@ import { getProviderConfig } from '~/endpoints/config/providers';
import { resolveHeaders, createSafeUser } from '~/utils/env';
import { getOpenAIConfig } from '~/endpoints/openai/config';
import { isUserProvided } from '~/utils/common';
import { ENCRYPTED_V2_VALUE, isNonEmptyString } from './langfuse';
/** Expected shape of JSON tool search results */
interface ToolSearchJsonResult {
@ -273,12 +275,7 @@ type RunAgent = Omit<Agent, 'tools'> & {
subagents?: AgentSubagentsConfig;
};
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
const UNRESOLVED_ENV_VAR_PLACEHOLDER = /\$\{[^}]+\}/;
const ENCRYPTED_V2_VALUE = /^[a-f0-9]{32}:[a-f0-9]+$/i;
function hasUnresolvedPlaceholder(value: string): boolean {
return UNRESOLVED_ENV_VAR_PLACEHOLDER.test(value);
@ -304,19 +301,23 @@ async function resolveLangfuseValue(
tenantValue?: string,
options: { encrypted?: boolean; agentId?: string } = {},
): Promise<string | undefined> {
const rawValue = isNonEmptyString(agentValue)
? agentValue
: isNonEmptyString(tenantValue)
? tenantValue
: undefined;
const isAgentValue = isNonEmptyString(agentValue);
let rawValue: string | undefined;
if (isAgentValue) {
rawValue = agentValue;
} else if (isNonEmptyString(tenantValue)) {
rawValue = tenantValue;
}
if (!rawValue) {
return undefined;
}
let value = rawValue;
if (options.encrypted === true && ENCRYPTED_V2_VALUE.test(value)) {
let decrypted = false;
if (options.encrypted === true && isAgentValue && ENCRYPTED_V2_VALUE.test(value)) {
try {
value = decodeURIComponent(await decryptV2(value));
decrypted = true;
} catch (error) {
logger.warn(
`[createRun] Langfuse tracing disabled for agent ${
@ -327,8 +328,9 @@ async function resolveLangfuseValue(
}
}
const resolved = extractEnvVariable(value);
if (!isNonEmptyString(resolved) || hasUnresolvedPlaceholder(resolved)) {
const shouldResolveEnv = !decrypted || extractVariableName(value) != null;
const resolved = shouldResolveEnv ? extractEnvVariable(value) : value.trim();
if (!isNonEmptyString(resolved) || (shouldResolveEnv && hasUnresolvedPlaceholder(resolved))) {
return undefined;
}
@ -351,12 +353,14 @@ export async function resolveEffectiveLangfuseConfig(
return { enabled: false };
}
const publicKey = await resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey);
const secretKey = await resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey, {
encrypted: true,
agentId: agent.id,
});
const baseUrl = await resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl);
const [publicKey, secretKey, baseUrl] = await Promise.all([
resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey),
resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey, {
encrypted: true,
agentId: agent.id,
}),
resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl),
]);
if (!publicKey || !secretKey) {
const missingFields = [

View file

@ -81,3 +81,41 @@ describe('agentUpdateSchema with subagents', () => {
expect(result.success).toBe(false);
});
});
describe('agentUpdateSchema with Langfuse', () => {
it('accepts HTTPS baseUrl values', () => {
const result = agentUpdateSchema.safeParse({
langfuse: {
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: 'https://cloud.langfuse.com',
},
});
expect(result.success).toBe(true);
});
it('accepts env var baseUrl refs', () => {
const result = agentUpdateSchema.safeParse({
langfuse: {
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: '${LANGFUSE_BASE_URL}',
},
});
expect(result.success).toBe(true);
});
it('rejects direct non-HTTPS baseUrl values', () => {
const result = agentUpdateSchema.safeParse({
langfuse: {
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
},
});
expect(result.success).toBe(false);
});
});

View file

@ -1,5 +1,10 @@
import { z } from 'zod';
import { MAX_SUBAGENTS, ViolationTypes, ErrorTypes } from 'librechat-data-provider';
import {
MAX_SUBAGENTS,
ErrorTypes,
ViolationTypes,
langfuseConfigSchema,
} from 'librechat-data-provider';
import type { Agent, TModelsConfig } from 'librechat-data-provider';
import type { Request, Response } from 'express';
@ -90,14 +95,7 @@ export const agentSubagentsSchema = z
})
.optional();
export const agentLangfuseSchema = z
.object({
enabled: z.boolean().optional(),
publicKey: z.string().optional(),
secretKey: z.string().optional(),
baseUrl: z.string().optional(),
})
.optional();
export const agentLangfuseSchema = langfuseConfigSchema.optional();
/** Base agent schema with all common fields */
export const agentBaseSchema = z.object({

View file

@ -479,6 +479,45 @@ describe('langfuse config', () => {
});
expect(result.success).toBe(true);
});
it('accepts an explicit HTTPS Langfuse base URL', () => {
const result = configSchema.safeParse({
version: '1.0',
langfuse: {
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: 'https://cloud.langfuse.com',
},
});
expect(result.success).toBe(true);
});
it('rejects direct non-HTTPS Langfuse base URLs', () => {
const result = configSchema.safeParse({
version: '1.0',
langfuse: {
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: 'http://169.254.169.254/latest/meta-data/',
},
});
expect(result.success).toBe(false);
});
it('rejects malformed direct Langfuse base URLs', () => {
const result = configSchema.safeParse({
version: '1.0',
langfuse: {
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: 'not a url',
},
});
expect(result.success).toBe(false);
});
});
describe('webSearchSchema', () => {

View file

@ -9,6 +9,7 @@ import { apiBaseUrl } from './api-endpoints';
import { FileSources } from './types/files';
import { MCPServersSchema } from './mcp';
import { REFILL_INTERVAL_UNITS } from './balance';
import { extractVariableName } from './utils';
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml'];
@ -465,6 +466,8 @@ export const defaultAgentCapabilities = [
AgentCapabilities.ocr,
];
export const LANGFUSE_SECRET_CLEAR_VALUE = '__LIBRECHAT_CLEAR_LANGFUSE_SECRET__';
const LOCAL_REMOTE_OIDC_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
export function isRemoteOidcUrlAllowed(value: string): boolean {
@ -1321,11 +1324,26 @@ export const summarizationConfigSchema = z.object({
export type SummarizationConfig = z.infer<typeof summarizationConfigSchema>;
const langfuseBaseUrlSchema = z.string().refine(
(value) => {
const trimmed = value.trim();
if (trimmed === '' || extractVariableName(trimmed) != null) {
return true;
}
try {
return new URL(trimmed).protocol === 'https:';
} catch {
return false;
}
},
{ message: 'Langfuse baseUrl must be an HTTPS URL or an env var reference' },
);
export const langfuseConfigSchema = z.object({
enabled: z.boolean().optional(),
publicKey: z.string().optional(),
secretKey: z.string().optional(),
baseUrl: z.string().optional(),
baseUrl: langfuseBaseUrlSchema.optional(),
});
export type LangfuseConfig = z.infer<typeof langfuseConfigSchema>;

View file

@ -4,6 +4,7 @@ import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './typ
import { TFeedback, feedbackSchema } from './feedback';
import type { SearchResultData } from './types/web';
import type { TFile } from './types/files';
import type { LangfuseConfig } from './config';
export const isUUID = z.string().uuid();
@ -303,9 +304,7 @@ export const defaultAgentFormValues = {
subagents: undefined as
| { enabled?: boolean; allowSelf?: boolean; agent_ids?: string[] }
| undefined,
langfuse: undefined as
| { enabled?: boolean; publicKey?: string; secretKey?: string; baseUrl?: string }
| undefined,
langfuse: undefined as LangfuseConfig | undefined,
};
export const ImageVisionTool: FunctionTool = {

View file

@ -11,6 +11,7 @@ import type {
CloudFrontConfig,
TCustomEndpoints,
TAssistantEndpoint,
LangfuseConfig,
TAnthropicEndpoint,
SummarizationConfig,
} from 'librechat-data-provider';
@ -61,7 +62,7 @@ export interface AppConfig {
/** Summarization configuration */
summarization?: SummarizationConfig;
/** Tenant-level Langfuse tracing defaults */
langfuse?: TCustomConfig['langfuse'];
langfuse?: LangfuseConfig;
/** Web search configuration */
webSearch?: TCustomConfig['webSearch'];
/** File storage strategy ('local', 's3', 'firebase', 'azure_blob', 'cloudfront') */