diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js
index 536044e5cc..c75001fcf6 100644
--- a/api/server/controllers/agents/v1.js
+++ b/api/server/controllers/agents/v1.js
@@ -60,6 +60,70 @@ 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 normalizeLangfuseConfig = (incoming, existing) => {
+ 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 = incoming.secretKey.trim();
+ } else if (isNonEmptyString(existingConfig.secretKey)) {
+ normalized.secretKey = existingConfig.secretKey;
+ }
+
+ 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,
@@ -284,6 +348,13 @@ const createAgentHandler = async (req, res) => {
agentData.model_parameters = removeNullishValues(agentData.model_parameters, true);
}
+ if (agentData.langfuse) {
+ agentData.langfuse = normalizeLangfuseConfig(agentData.langfuse);
+ if (!agentData.langfuse) {
+ delete agentData.langfuse;
+ }
+ }
+
const { id: userId, role: userRole } = req.user;
if (agentData.tool_resources) {
@@ -389,7 +460,7 @@ const createAgentHandler = async (req, res) => {
);
}
- res.status(201).json(agent);
+ res.status(201).json(redactLangfuseSecret(agent));
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents] Validation error', error.errors);
@@ -471,8 +542,8 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
});
}
- // EDIT permission: Full agent details including sensitive configuration
- return res.status(200).json(agent);
+ // EDIT permission: Full agent details, with write-only Langfuse secret redacted.
+ return res.status(200).json(redactLangfuseSecret(agent));
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
res.status(500).json({ error: error.message });
@@ -556,6 +627,13 @@ const updateAgentHandler = async (req, res) => {
return res.status(404).json({ error: 'Agent not found' });
}
+ if (updateData.langfuse) {
+ updateData.langfuse = normalizeLangfuseConfig(updateData.langfuse, existingAgent.langfuse);
+ if (!updateData.langfuse) {
+ delete updateData.langfuse;
+ }
+ }
+
// Convert legacy OCR tool resource to context format in existing agent
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
if (ocrConversion.tool_resources) {
@@ -615,7 +693,7 @@ const updateAgentHandler = async (req, res) => {
delete updatedAgent.author;
}
- return res.json(updatedAgent);
+ return res.json(redactLangfuseSecret(updatedAgent));
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents/:id] Validation error', error.errors);
@@ -794,7 +872,7 @@ const duplicateAgentHandler = async (req, res) => {
}
return res.status(201).json({
- agent: newAgent,
+ agent: redactLangfuseSecret(newAgent),
actions: newActionsList,
});
} catch (error) {
@@ -948,7 +1026,7 @@ const getListAgentsHandler = async (req, res) => {
// Silently ignore mapping errors
void e;
}
- return agent;
+ return redactLangfuseSecret(agent);
});
return res.json(data);
diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js
index 17904ad3fd..88b2821b25 100644
--- a/api/server/controllers/agents/v1.spec.js
+++ b/api/server/controllers/agents/v1.spec.js
@@ -178,6 +178,39 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
});
+ test('should persist Langfuse config but redact secret key in create response', async () => {
+ mockReq.body = {
+ name: 'Langfuse Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ langfuse: {
+ enabled: true,
+ publicKey: ' pk-test ',
+ secretKey: ' sk-test ',
+ baseUrl: ' https://cloud.langfuse.com ',
+ },
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ const createdAgent = mockRes.json.mock.calls[0][0];
+ expect(createdAgent.langfuse).toEqual({
+ enabled: true,
+ publicKey: 'pk-test',
+ secretKey: '',
+ baseUrl: 'https://cloud.langfuse.com',
+ });
+
+ const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
+ expect(agentInDb.langfuse).toEqual({
+ enabled: true,
+ publicKey: 'pk-test',
+ secretKey: 'sk-test',
+ baseUrl: 'https://cloud.langfuse.com',
+ });
+ });
+
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
const maliciousData = {
// Required fields
@@ -751,6 +784,82 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
});
+ test('should preserve existing Langfuse secret when update sends an empty secret', async () => {
+ await Agent.updateOne(
+ { id: existingAgentId },
+ {
+ langfuse: {
+ enabled: true,
+ publicKey: 'pk-original',
+ secretKey: 'sk-original',
+ baseUrl: 'https://cloud.langfuse.com',
+ },
+ },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ langfuse: {
+ enabled: true,
+ publicKey: ' pk-updated ',
+ secretKey: '',
+ baseUrl: ' https://us.cloud.langfuse.com ',
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.langfuse).toEqual({
+ enabled: true,
+ publicKey: 'pk-updated',
+ secretKey: '',
+ baseUrl: 'https://us.cloud.langfuse.com',
+ });
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.langfuse).toEqual({
+ enabled: true,
+ publicKey: 'pk-updated',
+ secretKey: 'sk-original',
+ baseUrl: 'https://us.cloud.langfuse.com',
+ });
+ });
+
+ test('should update Langfuse secret explicitly while redacting it in update response', async () => {
+ await Agent.updateOne(
+ { id: existingAgentId },
+ {
+ langfuse: {
+ enabled: true,
+ publicKey: 'pk-original',
+ secretKey: 'sk-original',
+ baseUrl: 'https://cloud.langfuse.com',
+ },
+ },
+ );
+
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.body = {
+ langfuse: {
+ enabled: true,
+ publicKey: 'pk-original',
+ secretKey: ' sk-updated ',
+ baseUrl: 'https://cloud.langfuse.com',
+ },
+ };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ const updatedAgent = mockRes.json.mock.calls[0][0];
+ expect(updatedAgent.langfuse.secretKey).toBe('');
+
+ const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
+ expect(agentInDb.langfuse.secretKey).toBe('sk-updated');
+ });
+
test('should return 404 for non-existent agent', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
@@ -1303,6 +1412,30 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].name).toBe('Agent A1');
});
+ test('should not expose Langfuse secrets in agent list responses', async () => {
+ await Agent.updateOne(
+ { id: agentA1.id },
+ {
+ langfuse: {
+ enabled: true,
+ publicKey: 'pk-list',
+ secretKey: 'sk-list',
+ baseUrl: 'https://cloud.langfuse.com',
+ },
+ },
+ );
+
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data).toHaveLength(1);
+ expect(response.data[0].langfuse).toBeUndefined();
+ });
+
test('should return multiple accessible agents', async () => {
// User B has access to multiple agents
mockReq.user.id = userB.toString();
diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts
index 7313812ec5..25a259b624 100644
--- a/client/src/common/agents-types.ts
+++ b/client/src/common/agents-types.ts
@@ -4,6 +4,7 @@ import type {
AgentSubagentsConfig,
AgentToolOptions,
SupportContact,
+ LangfuseConfig,
AgentProvider,
GraphEdge,
Agent,
@@ -46,6 +47,7 @@ export type AgentForm = {
agent_ids?: string[];
edges?: GraphEdge[];
subagents?: AgentSubagentsConfig;
+ langfuse?: LangfuseConfig;
[AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number;
support_contact?: SupportContact;
diff --git a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx
index 913b0ce49b..fb84458427 100644
--- a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx
+++ b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx
@@ -5,6 +5,7 @@ import { useFormContext, Controller } from 'react-hook-form';
import type { AgentForm } from '~/common';
import { useAgentPanelContext } from '~/Providers';
import AgentSubagents from './AgentSubagents';
+import AgentLangfuse from './AgentLangfuse';
import MaxAgentSteps from './MaxAgentSteps';
import AgentHandoffs from './AgentHandoffs';
import { useLocalize } from '~/hooks';
@@ -48,6 +49,11 @@ export default function AdvancedPanel() {
+
}
+ />
{subagentsEnabled && (
;
+}
+
+const fieldDefaults = {
+ enabled: false,
+ publicKey: '',
+ secretKey: '',
+ baseUrl: '',
+};
+
+export default function AgentLangfuse({ field }: AgentLangfuseProps) {
+ const localize = useLocalize();
+ const [showSecret, setShowSecret] = useState(false);
+ const value = useMemo(() => ({ ...fieldDefaults, ...(field.value ?? {}) }), [field.value]);
+ const enabled = value.enabled === true;
+
+ const updateField = useCallback(
+ (key: keyof typeof fieldDefaults, next: string | boolean) => {
+ field.onChange({
+ ...value,
+ [key]: next,
+ });
+ },
+ [field, value],
+ );
+
+ const enableId = 'agent-langfuse-enable-toggle';
+
+ return (
+
+
+
+
+
+
+
+ {localize('com_ui_agent_langfuse_info')}
+
+
+
+
+
+ {localize(enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled')}
+
+ updateField('enabled', next)}
+ aria-label={localize('com_ui_agent_langfuse_enable')}
+ />
+
+
+
+ {enabled && (
+
+
+
+ updateField('publicKey', event.target.value)}
+ placeholder={localize('com_ui_agent_langfuse_public_key_placeholder')}
+ autoComplete="off"
+ className="bg-surface-secondary"
+ />
+
+
+
+
+
+ updateField('secretKey', event.target.value)}
+ placeholder={localize('com_ui_agent_langfuse_secret_key_placeholder')}
+ autoComplete="new-password"
+ className="bg-surface-secondary pr-10"
+ />
+
+
+
+
+
+
+ updateField('baseUrl', event.target.value)}
+ placeholder={localize('com_ui_agent_langfuse_base_url_placeholder')}
+ autoComplete="off"
+ className="bg-surface-secondary"
+ />
+
+
+
+ {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 97ed8743ee..7abe575e58 100644
--- a/client/src/components/SidePanel/Agents/AgentPanel.tsx
+++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx
@@ -78,6 +78,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
tool_options,
skills,
skills_enabled,
+ langfuse,
avatar_action: avatarActionState,
} = data;
@@ -107,6 +108,14 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
tool_options,
skills,
skills_enabled,
+ langfuse: langfuse
+ ? {
+ enabled: langfuse.enabled === true,
+ publicKey: langfuse.publicKey?.trim() ?? '',
+ secretKey: langfuse.secretKey?.trim() ?? '',
+ baseUrl: langfuse.baseUrl?.trim() ?? '',
+ }
+ : undefined,
...(shouldResetAvatar ? { avatar: null } : {}),
},
provider,
diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx
index bf0978c153..502d81bb0f 100644
--- a/client/src/components/SidePanel/Agents/AgentSelect.tsx
+++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx
@@ -130,6 +130,17 @@ function AgentSelect({
return;
}
+ if (name === 'langfuse' && typeof value === 'object' && value !== null) {
+ const langfuse = value as NonNullable;
+ formValues[name] = {
+ enabled: langfuse.enabled === true,
+ publicKey: typeof langfuse.publicKey === 'string' ? langfuse.publicKey : '',
+ secretKey: '',
+ baseUrl: typeof langfuse.baseUrl === 'string' ? langfuse.baseUrl : '',
+ };
+ return;
+ }
+
if (name === 'tool_options' && typeof value === 'object' && value !== null) {
formValues[name] = value;
return;
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 988796cdc3..8f12eae601 100644
--- a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts
+++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts
@@ -28,6 +28,12 @@ const createForm = (): AgentForm => ({
recursion_limit: undefined,
category: 'general',
support_contact: undefined,
+ langfuse: {
+ enabled: false,
+ publicKey: '',
+ secretKey: '',
+ baseUrl: '',
+ },
artifacts: '',
execute_code: false,
file_search: false,
@@ -64,6 +70,25 @@ describe('composeAgentUpdatePayload', () => {
expect(payload.avatar).toBeUndefined();
});
+
+ it('includes normalized Langfuse config fields', () => {
+ const form = createForm();
+ form.langfuse = {
+ enabled: true,
+ publicKey: ' pk-test ',
+ secretKey: ' sk-test ',
+ baseUrl: ' https://langfuse.test ',
+ };
+
+ const { payload } = composeAgentUpdatePayload(form, 'agent_123');
+
+ expect(payload.langfuse).toEqual({
+ enabled: true,
+ publicKey: 'pk-test',
+ secretKey: 'sk-test',
+ baseUrl: 'https://langfuse.test',
+ });
+ });
});
describe('persistAvatarChanges', () => {
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index c21aed0340..ed8e085834 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -702,6 +702,18 @@
"com_ui_agent_name_is_required": "Agent name is required",
"com_ui_agent_recursion_limit": "Max Agent Steps",
"com_ui_agent_recursion_limit_info": "Limits how many steps the agent can take in a run before giving a final response. Default is 25 steps. A step is either an AI API request or a tool usage round. For example, a basic tool interaction takes 3 steps: initial request, tool usage, and follow-up request.",
+ "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_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_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",
+ "com_ui_agent_langfuse_secret_key": "Secret Key",
+ "com_ui_agent_langfuse_secret_key_placeholder": "Leave blank to keep current key",
"com_ui_agent_subagents": "Subagents",
"com_ui_agent_subagents_add": "Add subagent",
"com_ui_agent_subagents_agents": "Additional subagents",
diff --git a/librechat.example.yaml b/librechat.example.yaml
index 7d61b486f4..5e98a569b8 100644
--- a/librechat.example.yaml
+++ b/librechat.example.yaml
@@ -2,11 +2,20 @@
# https://www.librechat.ai/docs/configuration/librechat_yaml
# Configuration version (required)
-version: 1.3.10
+version: 1.3.11
# Cache settings: Set to true to enable caching
cache: true
+# Tenant-level Langfuse tracing defaults (optional)
+# Prefer environment-variable references here so secrets are not stored directly in this file.
+# Agents can override these values from the agent editor when needed.
+# langfuse:
+# enabled: true
+# publicKey: '${LANGFUSE_PUBLIC_KEY}'
+# secretKey: '${LANGFUSE_SECRET_KEY}'
+# baseUrl: '${LANGFUSE_BASE_URL}'
+
# File storage configuration
# Single strategy for all file types (legacy format, still supported)
# fileStrategy: "s3"
diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts
index 59f8959f39..9eef74f2b3 100644
--- a/packages/api/src/agents/run.spec.ts
+++ b/packages/api/src/agents/run.spec.ts
@@ -1,7 +1,28 @@
import { Providers } from '@librechat/agents';
+import { logger } from '@librechat/data-schemas';
import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages';
-import { extractDiscoveredToolsFromHistory, getReasoningKey } from './run';
+import {
+ getReasoningKey,
+ resolveEffectiveLangfuseConfig,
+ extractDiscoveredToolsFromHistory,
+} from './run';
+
+type LangfuseRunAgent = Parameters[0];
+type LangfuseAppConfig = NonNullable[1]>;
+
+const createLangfuseAgent = (langfuse?: LangfuseRunAgent['langfuse']): LangfuseRunAgent =>
+ ({
+ id: 'agent_1',
+ langfuse,
+ }) as LangfuseRunAgent;
+
+const createLangfuseAppConfig = (
+ langfuse?: LangfuseAppConfig['langfuse'],
+): LangfuseAppConfig =>
+ ({
+ langfuse,
+ }) as LangfuseAppConfig;
describe('extractDiscoveredToolsFromHistory', () => {
it('extracts tool names from tool_search JSON output', () => {
@@ -147,3 +168,103 @@ describe('getReasoningKey', () => {
expect(reasoningKey).toBe('reasoning');
});
});
+
+describe('resolveEffectiveLangfuseConfig', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
+ 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';
+ delete process.env.LANGFUSE_TEST_MISSING_KEY;
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ delete process.env.LANGFUSE_TEST_PUBLIC_KEY;
+ delete process.env.LANGFUSE_TEST_SECRET_KEY;
+ delete process.env.LANGFUSE_TEST_BASE_URL;
+ delete process.env.LANGFUSE_TEST_MISSING_KEY;
+ });
+
+ it('returns undefined when no tenant or agent config is supplied', () => {
+ expect(
+ resolveEffectiveLangfuseConfig(createLangfuseAgent(), createLangfuseAppConfig()),
+ ).toBeUndefined();
+ });
+
+ it('uses tenant defaults and resolves env var refs', () => {
+ const result = resolveEffectiveLangfuseConfig(
+ createLangfuseAgent(),
+ createLangfuseAppConfig({
+ enabled: true,
+ publicKey: '${LANGFUSE_TEST_PUBLIC_KEY}',
+ secretKey: '${LANGFUSE_TEST_SECRET_KEY}',
+ baseUrl: '${LANGFUSE_TEST_BASE_URL}',
+ }),
+ );
+
+ expect(result).toEqual({
+ enabled: true,
+ publicKey: 'pk-tenant',
+ secretKey: 'sk-tenant',
+ baseUrl: 'https://cloud.langfuse.com',
+ });
+ });
+
+ it('overlays non-empty agent fields on tenant defaults', () => {
+ const result = resolveEffectiveLangfuseConfig(
+ createLangfuseAgent({
+ enabled: true,
+ publicKey: 'pk-agent',
+ }),
+ createLangfuseAppConfig({
+ enabled: true,
+ publicKey: 'pk-tenant',
+ secretKey: '${LANGFUSE_TEST_SECRET_KEY}',
+ baseUrl: '${LANGFUSE_TEST_BASE_URL}',
+ }),
+ );
+
+ expect(result).toEqual({
+ enabled: true,
+ publicKey: 'pk-agent',
+ secretKey: 'sk-tenant',
+ baseUrl: 'https://cloud.langfuse.com',
+ });
+ });
+
+ it('lets an agent explicitly disable tracing over enabled tenant defaults', () => {
+ const result = resolveEffectiveLangfuseConfig(
+ createLangfuseAgent({
+ enabled: false,
+ }),
+ createLangfuseAppConfig({
+ enabled: true,
+ publicKey: 'pk-tenant',
+ secretKey: 'sk-tenant',
+ baseUrl: 'https://cloud.langfuse.com',
+ }),
+ );
+
+ expect(result).toEqual({ enabled: false });
+ });
+
+ it('disables tracing and warns when credentials are unresolved', () => {
+ const result = resolveEffectiveLangfuseConfig(
+ createLangfuseAgent(),
+ createLangfuseAppConfig({
+ enabled: true,
+ publicKey: '${LANGFUSE_TEST_MISSING_KEY}',
+ secretKey: 'sk-tenant',
+ baseUrl: 'https://cloud.langfuse.com',
+ }),
+ );
+
+ expect(result).toEqual({ enabled: false });
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Langfuse tracing disabled for agent agent_1'),
+ );
+ });
+});
diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts
index d90a623d27..dfd9df9863 100644
--- a/packages/api/src/agents/run.ts
+++ b/packages/api/src/agents/run.ts
@@ -24,6 +24,7 @@ import type {
} from '@librechat/agents';
import type {
Agent,
+ LangfuseConfig,
AgentModelParameters,
AgentSubagentsConfig,
SummarizationConfig,
@@ -286,6 +287,78 @@ function isPlainObject(value: unknown): value is Record {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
+type EffectiveLangfuseConfig =
+ | {
+ enabled: true;
+ publicKey: string;
+ secretKey: string;
+ baseUrl: string;
+ }
+ | {
+ enabled: false;
+ };
+
+function resolveLangfuseValue(agentValue?: string, tenantValue?: string): string | undefined {
+ const rawValue = isNonEmptyString(agentValue)
+ ? agentValue
+ : isNonEmptyString(tenantValue)
+ ? tenantValue
+ : undefined;
+ if (!rawValue) {
+ return undefined;
+ }
+
+ const resolved = extractEnvVariable(rawValue);
+ if (!isNonEmptyString(resolved) || hasUnresolvedPlaceholder(resolved)) {
+ return undefined;
+ }
+
+ return resolved;
+}
+
+export function resolveEffectiveLangfuseConfig(
+ agent: RunAgent,
+ appConfig?: AppConfig,
+): EffectiveLangfuseConfig | undefined {
+ const tenantLangfuse = appConfig?.langfuse;
+ const agentLangfuse = agent.langfuse;
+
+ if (!tenantLangfuse && !agentLangfuse) {
+ return undefined;
+ }
+
+ const enabled = agentLangfuse?.enabled ?? tenantLangfuse?.enabled;
+ if (enabled !== true) {
+ return { enabled: false };
+ }
+
+ const publicKey = resolveLangfuseValue(agentLangfuse?.publicKey, tenantLangfuse?.publicKey);
+ const secretKey = resolveLangfuseValue(agentLangfuse?.secretKey, tenantLangfuse?.secretKey);
+ const baseUrl = resolveLangfuseValue(agentLangfuse?.baseUrl, tenantLangfuse?.baseUrl);
+
+ if (!publicKey || !secretKey || !baseUrl) {
+ const missingFields = [
+ !publicKey ? 'publicKey' : undefined,
+ !secretKey ? 'secretKey' : undefined,
+ !baseUrl ? 'baseUrl' : undefined,
+ ].filter(Boolean);
+
+ logger.warn(
+ `[createRun] Langfuse tracing disabled for agent ${agent.id}; missing or unresolved ${missingFields.join(
+ ', ',
+ )}`,
+ );
+ return { enabled: false };
+ }
+
+ return {
+ enabled: true,
+ publicKey,
+ secretKey,
+ baseUrl,
+ };
+}
+
const nullableAgentModelParameterKeys = [
'temperature',
'maxContextTokens',
@@ -909,7 +982,8 @@ export async function createRun({
);
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint);
- return {
+ const langfuse = resolveEffectiveLangfuseConfig(agent, appConfig);
+ const agentInput: AgentInputs & { langfuse?: LangfuseConfig } = {
provider,
reasoningKey,
toolDefinitions,
@@ -930,6 +1004,10 @@ export async function createRun({
contextPruningConfig: summarization.contextPruning,
maxToolResultChars: agent.maxToolResultChars,
};
+ if (langfuse) {
+ agentInput.langfuse = langfuse;
+ }
+ return agentInput;
};
const agentInputs: AgentInputs[] = [];
diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts
index 30b1da7d09..d714f5aa5d 100644
--- a/packages/api/src/agents/validation.ts
+++ b/packages/api/src/agents/validation.ts
@@ -90,6 +90,15 @@ 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();
+
/** Base agent schema with all common fields */
export const agentBaseSchema = z.object({
name: z.string().nullable().optional(),
@@ -111,6 +120,7 @@ export const agentBaseSchema = z.object({
tool_resources: agentToolResourcesSchema,
tool_options: agentToolOptionsSchema,
subagents: agentSubagentsSchema,
+ langfuse: agentLangfuseSchema,
support_contact: agentSupportContactSchema,
category: z.string().optional(),
});
diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts
index f5c107c58f..9606fbc8a7 100644
--- a/packages/data-provider/src/config.spec.ts
+++ b/packages/data-provider/src/config.spec.ts
@@ -440,6 +440,19 @@ describe('allowedAddressesSchema', () => {
expect(result.success).toBe(true);
});
+ it('accepts tenant-level Langfuse config with env var refs', () => {
+ const result = configSchema.safeParse({
+ version: '1.0',
+ langfuse: {
+ enabled: true,
+ publicKey: '${LANGFUSE_PUBLIC_KEY}',
+ secretKey: '${LANGFUSE_SECRET_KEY}',
+ baseUrl: '${LANGFUSE_BASE_URL}',
+ },
+ });
+ expect(result.success).toBe(true);
+ });
+
it('rejects a public IP at the endpoints location', () => {
const result = configSchema.safeParse({
version: '1.0',
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index e7c0add133..5ac7569602 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -1321,6 +1321,15 @@ export const summarizationConfigSchema = z.object({
export type SummarizationConfig = z.infer;
+export const langfuseConfigSchema = z.object({
+ enabled: z.boolean().optional(),
+ publicKey: z.string().optional(),
+ secretKey: z.string().optional(),
+ baseUrl: z.string().optional(),
+});
+
+export type LangfuseConfig = z.infer;
+
const customEndpointsSchema = z.array(endpointSchema.partial()).optional();
export const configSchema = z.object({
@@ -1330,6 +1339,7 @@ export const configSchema = z.object({
webSearch: webSearchSchema.optional(),
memory: memorySchema.optional(),
summarization: summarizationConfigSchema.optional(),
+ langfuse: langfuseConfigSchema.optional(),
secureImageLinks: z.boolean().optional(),
imageOutputType: z.nativeEnum(EImageOutputType).default(EImageOutputType.PNG),
includedTools: z.array(z.string()).optional(),
@@ -2122,7 +2132,7 @@ export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.8.5',
/** Key for the Custom Config's version (librechat.yaml). */
- CONFIG_VERSION = '1.3.10',
+ CONFIG_VERSION = '1.3.11',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value to use whatever the submission prelim. `responseMessageId` is */
diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts
index bf9c119eec..df357ad824 100644
--- a/packages/data-provider/src/schemas.ts
+++ b/packages/data-provider/src/schemas.ts
@@ -303,6 +303,12 @@ export const defaultAgentFormValues = {
subagents: undefined as
| { enabled?: boolean; allowSelf?: boolean; agent_ids?: string[] }
| undefined,
+ langfuse: {
+ enabled: false,
+ publicKey: '',
+ secretKey: '',
+ baseUrl: '',
+ },
};
export const ImageVisionTool: FunctionTool = {
diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts
index b222c718ed..41e6a5ec9d 100644
--- a/packages/data-provider/src/types/assistants.ts
+++ b/packages/data-provider/src/types/assistants.ts
@@ -1,5 +1,6 @@
import type { OpenAPIV3 } from 'openapi-types';
import type { AssistantsEndpoint, AgentProvider } from 'src/schemas';
+import type { LangfuseConfig } from 'src/config';
import type { Agents, GraphEdge } from './agents';
import type { ContentTypes } from './runs';
import type { TFile } from './files';
@@ -295,6 +296,8 @@ export type Agent = {
skills_enabled?: boolean;
/** Subagent spawning configuration — isolated-context child agents. */
subagents?: AgentSubagentsConfig;
+ /** Optional per-agent Langfuse tracing override. */
+ langfuse?: LangfuseConfig;
};
export type TAgentsMap = Record;
@@ -323,6 +326,7 @@ export type AgentCreateParams = {
| 'skills'
| 'skills_enabled'
| 'subagents'
+ | 'langfuse'
>;
export type AgentUpdateParams = {
@@ -350,6 +354,7 @@ export type AgentUpdateParams = {
| 'skills'
| 'skills_enabled'
| 'subagents'
+ | 'langfuse'
>;
export type AgentListParams = {
diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts
index 19fec9f5a1..3a972e2982 100644
--- a/packages/data-schemas/src/app/service.ts
+++ b/packages/data-schemas/src/app/service.ts
@@ -83,6 +83,7 @@ export const AppService = async (params?: {
const webSearch = loadWebSearchConfig(config.webSearch);
const memory = loadMemoryConfig(config.memory);
const summarization = loadSummarizationConfig(config);
+ const langfuse = config.langfuse;
const filteredTools = config.filteredTools;
const includedTools = config.includedTools;
const fileStrategy = (config.fileStrategy ?? configDefaults.fileStrategy) as
@@ -127,6 +128,7 @@ export const AppService = async (params?: {
filteredTools,
includedTools,
summarization,
+ langfuse,
availableTools,
imageOutputType,
interfaceConfig,
diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts
index 33e1a92d6f..b6c9febd38 100644
--- a/packages/data-schemas/src/schema/agent.ts
+++ b/packages/data-schemas/src/schema/agent.ts
@@ -125,6 +125,11 @@ const agentSchema = new Schema(
type: Schema.Types.Mixed,
default: undefined,
},
+ /** Optional per-agent Langfuse tracing override. */
+ langfuse: {
+ type: Schema.Types.Mixed,
+ default: undefined,
+ },
tenantId: {
type: String,
index: true,
diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts
index 416112141d..f0e186277f 100644
--- a/packages/data-schemas/src/types/agent.ts
+++ b/packages/data-schemas/src/types/agent.ts
@@ -4,6 +4,7 @@ import type {
AgentToolOptions,
AgentToolResources,
AgentSubagentsConfig,
+ LangfuseConfig,
} from 'librechat-data-provider';
export interface ISupportContact {
@@ -50,5 +51,7 @@ export interface IAgent extends Omit {
tool_options?: AgentToolOptions;
/** Subagent spawning configuration — isolated-context child agents. */
subagents?: AgentSubagentsConfig;
+ /** Optional per-agent Langfuse tracing override. */
+ langfuse?: LangfuseConfig;
tenantId?: string;
}
diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts
index 4562e588ee..5c1b01d9e8 100644
--- a/packages/data-schemas/src/types/app.ts
+++ b/packages/data-schemas/src/types/app.ts
@@ -60,6 +60,8 @@ export interface AppConfig {
memory?: TMemoryConfig;
/** Summarization configuration */
summarization?: SummarizationConfig;
+ /** Tenant-level Langfuse tracing defaults */
+ langfuse?: TCustomConfig['langfuse'];
/** Web search configuration */
webSearch?: TCustomConfig['webSearch'];
/** File storage strategy ('local', 's3', 'firebase', 'azure_blob', 'cloudfront') */