mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
fix: harden Langfuse agent config handling
This commit is contained in:
parent
0c2fdc92cd
commit
bc636c9a2e
18 changed files with 628 additions and 149 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</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')}
|
||||
{localize(statusKey)}
|
||||
</span>
|
||||
<Switch
|
||||
id={enableId}
|
||||
|
|
@ -80,14 +91,29 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
|
|||
</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="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="agent-langfuse-secret-key" className="text-xs font-medium">
|
||||
{localize('com_ui_agent_langfuse_secret_key')}
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleClearSecret}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
{secretMarkedForClear && <X className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{localize(
|
||||
secretMarkedForClear
|
||||
? 'com_ui_agent_langfuse_cancel_clear_secret'
|
||||
: 'com_ui_agent_langfuse_clear_secret',
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="agent-langfuse-secret-key"
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={value.secretKey}
|
||||
value={secretInputValue}
|
||||
disabled={secretMarkedForClear}
|
||||
onChange={(event) => 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')}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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<AgentForm['langfuse']> = {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ function AgentSelect({
|
|||
if (name === 'langfuse' && typeof value === 'object' && value !== null) {
|
||||
const langfuse = value as NonNullable<AgentForm['langfuse']>;
|
||||
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 : '',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
87
packages/api/src/agents/langfuse.spec.ts
Normal file
87
packages/api/src/agents/langfuse.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
141
packages/api/src/agents/langfuse.ts
Normal file
141
packages/api/src/agents/langfuse.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<string> {
|
||||
return await encryptV2(encodeURIComponent(value));
|
||||
}
|
||||
|
||||
async function normalizeLangfuseSecret(
|
||||
value: string,
|
||||
options: { preserveEncrypted?: boolean } = {},
|
||||
): Promise<string | undefined> {
|
||||
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<LangfuseConfig | undefined> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ jest.mock('@librechat/data-schemas', () => {
|
|||
|
||||
type LangfuseRunAgent = Parameters<typeof resolveEffectiveLangfuseConfig>[0];
|
||||
type LangfuseAppConfig = NonNullable<Parameters<typeof resolveEffectiveLangfuseConfig>[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({
|
||||
|
|
|
|||
|
|
@ -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<Agent, 'tools'> & {
|
|||
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<string | undefined> {
|
||||
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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof summarizationConfigSchema>;
|
||||
|
||||
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<typeof langfuseConfigSchema>;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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') */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue