mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat: add tenant and agent Langfuse configuration
This commit is contained in:
parent
3e7262cfe0
commit
03e21214d0
21 changed files with 687 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue