diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js
index 45e1ebfab8..f6b61521f2 100644
--- a/api/server/controllers/agents/v1.js
+++ b/api/server/controllers/agents/v1.js
@@ -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,
diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js
index 7c6718ce81..2ca494e3cb 100644
--- a/api/server/controllers/agents/v1.spec.js
+++ b/api/server/controllers/agents/v1.spec.js
@@ -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 },
diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentLangfuse.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentLangfuse.tsx
index cb435b8e84..c30fc35e32 100644
--- a/client/src/components/SidePanel/Agents/Advanced/AgentLangfuse.tsx
+++ b/client/src/components/SidePanel/Agents/Advanced/AgentLangfuse.tsx
@@ -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) {
- {localize(enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled')}
+ {localize(statusKey)}
-
+
+
+
+
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')}
diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx
index 78d70e3dda..1df646096b 100644
--- a/client/src/components/SidePanel/Agents/AgentPanel.tsx
+++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx
@@ -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
= {
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: {
diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx
index 502d81bb0f..635e1fd584 100644
--- a/client/src/components/SidePanel/Agents/AgentSelect.tsx
+++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx
@@ -133,7 +133,7 @@ function AgentSelect({
if (name === 'langfuse' && typeof value === 'object' && value !== null) {
const langfuse = value as NonNullable;
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 : '',
diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts
index 64eb5d5f3f..2da9ee6b12 100644
--- a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts
+++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts
@@ -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', () => {
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index ed8e085834..20f1d519db 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts
index 1a6ae696a0..c0cb63225d 100644
--- a/packages/api/src/agents/index.ts
+++ b/packages/api/src/agents/index.ts
@@ -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';
diff --git a/packages/api/src/agents/langfuse.spec.ts b/packages/api/src/agents/langfuse.spec.ts
new file mode 100644
index 0000000000..108d52b322
--- /dev/null
+++ b/packages/api/src/agents/langfuse.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/api/src/agents/langfuse.ts b/packages/api/src/agents/langfuse.ts
new file mode 100644
index 0000000000..b1ac2141e2
--- /dev/null
+++ b/packages/api/src/agents/langfuse.ts
@@ -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 {
+ 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 {
+ return await encryptV2(encodeURIComponent(value));
+}
+
+async function normalizeLangfuseSecret(
+ value: string,
+ options: { preserveEncrypted?: boolean } = {},
+): Promise {
+ 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 {
+ 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),
+ };
+}
diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts
index de1ea59637..82104755ae 100644
--- a/packages/api/src/agents/run.spec.ts
+++ b/packages/api/src/agents/run.spec.ts
@@ -22,6 +22,7 @@ jest.mock('@librechat/data-schemas', () => {
type LangfuseRunAgent = Parameters[0];
type LangfuseAppConfig = NonNullable[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({
diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts
index 4df2e88bee..28cc9f0c97 100644
--- a/packages/api/src/agents/run.ts
+++ b/packages/api/src/agents/run.ts
@@ -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 & {
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 {
- 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 = [
diff --git a/packages/api/src/agents/validation.spec.ts b/packages/api/src/agents/validation.spec.ts
index 743217780f..62bb8d1a5e 100644
--- a/packages/api/src/agents/validation.spec.ts
+++ b/packages/api/src/agents/validation.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts
index d714f5aa5d..3560ae3e1f 100644
--- a/packages/api/src/agents/validation.ts
+++ b/packages/api/src/agents/validation.ts
@@ -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({
diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts
index 31797c9a69..9cb8c67281 100644
--- a/packages/data-provider/src/config.spec.ts
+++ b/packages/data-provider/src/config.spec.ts
@@ -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', () => {
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index 5ac7569602..e5c2e42b95 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -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;
+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;
diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts
index bc8c070fe6..c200f04336 100644
--- a/packages/data-provider/src/schemas.ts
+++ b/packages/data-provider/src/schemas.ts
@@ -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 = {
diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts
index 5c1b01d9e8..d76e30185f 100644
--- a/packages/data-schemas/src/types/app.ts
+++ b/packages/data-schemas/src/types/app.ts
@@ -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') */