feat: add tenant and agent Langfuse configuration

This commit is contained in:
Danny Avila 2026-05-11 21:20:53 -04:00
parent 3e7262cfe0
commit 03e21214d0
21 changed files with 687 additions and 10 deletions

View file

@ -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);

View file

@ -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();

View file

@ -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;

View file

@ -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() {
</div>
<div className="flex flex-col gap-4 px-2 pb-2">
<MaxAgentSteps />
<Controller
name="langfuse"
control={control}
render={({ field }) => <AgentLangfuse field={field} />}
/>
{subagentsEnabled && (
<Controller
name="subagents"

View file

@ -0,0 +1,137 @@
import { useCallback, useMemo, useState } from 'react';
import { Activity, Eye, EyeOff } from 'lucide-react';
import { Input, Label, Switch } from '@librechat/client';
import type { ControllerRenderProps } from 'react-hook-form';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
interface AgentLangfuseProps {
field: ControllerRenderProps<AgentForm, 'langfuse'>;
}
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 (
<div className="rounded-md border border-border-light bg-surface-primary p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 gap-2">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-secondary text-text-secondary">
<Activity className="h-4 w-4" aria-hidden="true" />
</div>
<div className="min-w-0">
<Label htmlFor={enableId} className="font-semibold text-text-primary">
{localize('com_ui_agent_langfuse')}
</Label>
<p className="mt-1 text-xs leading-5 text-text-secondary">
{localize('com_ui_agent_langfuse_info')}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
{localize(enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled')}
</span>
<Switch
id={enableId}
checked={enabled}
onCheckedChange={(next) => updateField('enabled', next)}
aria-label={localize('com_ui_agent_langfuse_enable')}
/>
</div>
</div>
{enabled && (
<div className="mt-3 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="agent-langfuse-public-key" className="text-xs font-medium">
{localize('com_ui_agent_langfuse_public_key')}
</Label>
<Input
id="agent-langfuse-public-key"
value={value.publicKey}
onChange={(event) => updateField('publicKey', event.target.value)}
placeholder={localize('com_ui_agent_langfuse_public_key_placeholder')}
autoComplete="off"
className="bg-surface-secondary"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="agent-langfuse-secret-key" className="text-xs font-medium">
{localize('com_ui_agent_langfuse_secret_key')}
</Label>
<div className="relative">
<Input
id="agent-langfuse-secret-key"
type={showSecret ? 'text' : 'password'}
value={value.secretKey}
onChange={(event) => updateField('secretKey', event.target.value)}
placeholder={localize('com_ui_agent_langfuse_secret_key_placeholder')}
autoComplete="new-password"
className="bg-surface-secondary pr-10"
/>
<button
type="button"
onClick={() => setShowSecret((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-colors hover:text-text-primary"
aria-label={localize(showSecret ? 'com_ui_hide_password' : 'com_ui_show_password')}
>
{showSecret ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="agent-langfuse-base-url" className="text-xs font-medium">
{localize('com_ui_agent_langfuse_base_url')}
</Label>
<Input
id="agent-langfuse-base-url"
value={value.baseUrl}
onChange={(event) => updateField('baseUrl', event.target.value)}
placeholder={localize('com_ui_agent_langfuse_base_url_placeholder')}
autoComplete="off"
className="bg-surface-secondary"
/>
</div>
<a
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"
>
{localize('com_ui_agent_langfuse_docs')}
</a>
</div>
)}
</div>
);
}

View file

@ -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,

View file

@ -130,6 +130,17 @@ function AgentSelect({
return;
}
if (name === 'langfuse' && typeof value === 'object' && value !== null) {
const langfuse = value as NonNullable<AgentForm['langfuse']>;
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;

View file

@ -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', () => {

View file

@ -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",

View file

@ -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"

View file

@ -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<typeof resolveEffectiveLangfuseConfig>[0];
type LangfuseAppConfig = NonNullable<Parameters<typeof resolveEffectiveLangfuseConfig>[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'),
);
});
});

View file

@ -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<string, unknown> {
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[] = [];

View file

@ -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(),
});

View file

@ -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',

View file

@ -1321,6 +1321,15 @@ export const summarizationConfigSchema = z.object({
export type SummarizationConfig = z.infer<typeof summarizationConfigSchema>;
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<typeof langfuseConfigSchema>;
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 */

View file

@ -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 = {

View file

@ -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<string, Agent | undefined>;
@ -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 = {

View file

@ -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,

View file

@ -125,6 +125,11 @@ const agentSchema = new Schema<IAgent>(
type: Schema.Types.Mixed,
default: undefined,
},
/** Optional per-agent Langfuse tracing override. */
langfuse: {
type: Schema.Types.Mixed,
default: undefined,
},
tenantId: {
type: String,
index: true,

View file

@ -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<Document, 'model'> {
tool_options?: AgentToolOptions;
/** Subagent spawning configuration — isolated-context child agents. */
subagents?: AgentSubagentsConfig;
/** Optional per-agent Langfuse tracing override. */
langfuse?: LangfuseConfig;
tenantId?: string;
}

View file

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