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') */