From 03e21214d0dc83b7cf0b054f683cc23ca48a0f12 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 11 May 2026 21:20:53 -0400 Subject: [PATCH] feat: add tenant and agent Langfuse configuration --- api/server/controllers/agents/v1.js | 90 +++++++++++- api/server/controllers/agents/v1.spec.js | 133 +++++++++++++++++ client/src/common/agents-types.ts | 2 + .../Agents/Advanced/AdvancedPanel.tsx | 6 + .../Agents/Advanced/AgentLangfuse.tsx | 137 ++++++++++++++++++ .../SidePanel/Agents/AgentPanel.tsx | 9 ++ .../SidePanel/Agents/AgentSelect.tsx | 11 ++ .../__tests__/AgentPanel.helpers.spec.ts | 25 ++++ client/src/locales/en/translation.json | 12 ++ librechat.example.yaml | 11 +- packages/api/src/agents/run.spec.ts | 123 +++++++++++++++- packages/api/src/agents/run.ts | 80 +++++++++- packages/api/src/agents/validation.ts | 10 ++ packages/data-provider/src/config.spec.ts | 13 ++ packages/data-provider/src/config.ts | 12 +- packages/data-provider/src/schemas.ts | 6 + .../data-provider/src/types/assistants.ts | 5 + packages/data-schemas/src/app/service.ts | 2 + packages/data-schemas/src/schema/agent.ts | 5 + packages/data-schemas/src/types/agent.ts | 3 + packages/data-schemas/src/types/app.ts | 2 + 21 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/Advanced/AgentLangfuse.tsx 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') */