mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
feat(langfuse): refine tenant connection controls
This commit is contained in:
parent
26f6c70936
commit
30214e0302
13 changed files with 777 additions and 365 deletions
|
|
@ -277,7 +277,9 @@ router.get('/', async function (req, res) {
|
|||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
langfuseFanoutEnabled: /^(true|1)$/i.test((process.env.LANGFUSE_FANOUT_ENABLED ?? '').trim()),
|
||||
langfuseFanoutEnabled: /^(true|1|yes|on)$/i.test(
|
||||
(process.env.LANGFUSE_FANOUT_ENABLED ?? '').trim(),
|
||||
),
|
||||
...(cloudFront ? { cloudFront } : {}),
|
||||
...(rum ? { rum } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
CircleHelpIcon,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
HoverCardTrigger,
|
||||
Input,
|
||||
Label,
|
||||
SecretInput,
|
||||
|
|
@ -8,12 +13,62 @@ import {
|
|||
Switch,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TLangfuseConnectionStatus } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import {
|
||||
useGetLangfuseConnectionQuery,
|
||||
useUpdateLangfuseConnectionMutation,
|
||||
useTestLangfuseConnectionMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
type ConnectionTestState = 'idle' | 'checking' | 'connected' | 'failed';
|
||||
|
||||
function getStoredConnectionTestKey(status?: TLangfuseConnectionStatus): string | undefined {
|
||||
if (status?.configured !== true || !status.destination || !status.publicKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [status.destination, status.publicKey, status.updatedAt ?? ''].join('\u0000');
|
||||
}
|
||||
|
||||
function getConnectionStatusLabelKey(state: ConnectionTestState): TranslationKeys {
|
||||
switch (state) {
|
||||
case 'checking':
|
||||
return 'com_ui_langfuse_status_checking';
|
||||
case 'connected':
|
||||
return 'com_ui_langfuse_status_connected';
|
||||
case 'failed':
|
||||
return 'com_ui_langfuse_status_failed';
|
||||
case 'idle':
|
||||
default:
|
||||
return 'com_ui_langfuse_status_not_configured';
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionStatusDotClass(state: ConnectionTestState): string {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return 'bg-green-500';
|
||||
case 'failed':
|
||||
return 'bg-red-500';
|
||||
case 'checking':
|
||||
return 'bg-yellow-500';
|
||||
case 'idle':
|
||||
default:
|
||||
return 'border border-border-medium';
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayPublicKey(publicKey: string): string {
|
||||
const trimmedPublicKey = publicKey.trim();
|
||||
if (trimmedPublicKey.length <= 12) {
|
||||
return trimmedPublicKey;
|
||||
}
|
||||
|
||||
return `${trimmedPublicKey.slice(0, 6)}...${trimmedPublicKey.slice(-4)}`;
|
||||
}
|
||||
|
||||
export default function LangfuseConnection() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -22,153 +77,326 @@ export default function LangfuseConnection() {
|
|||
const updateMutation = useUpdateLangfuseConnectionMutation();
|
||||
const testMutation = useTestLangfuseConnectionMutation();
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<TLangfuseConnectionStatus>();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [publicKey, setPublicKey] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [isEditingPublicKey, setIsEditingPublicKey] = useState(false);
|
||||
const [isEditingSecret, setIsEditingSecret] = useState(false);
|
||||
const [connectionTestState, setConnectionTestState] = useState<ConnectionTestState>('idle');
|
||||
const [connectionTestMessage, setConnectionTestMessage] = useState('');
|
||||
const autoTestedConnectionRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
setEnabled(status.enabled === true);
|
||||
setBaseUrl(status.baseUrl ?? '');
|
||||
setPublicKey(status.publicKey ?? '');
|
||||
setConnectionStatus(status);
|
||||
}, [status]);
|
||||
|
||||
const secretConfigured = status?.configured === true;
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
useEffect(() => {
|
||||
if (!connectionStatus) {
|
||||
return;
|
||||
}
|
||||
setEnabled(connectionStatus.enabled === true);
|
||||
const availableDestinations = connectionStatus.destinations ?? [];
|
||||
const storedDestination = availableDestinations.some(
|
||||
(option) => option.key === connectionStatus.destination,
|
||||
)
|
||||
? connectionStatus.destination
|
||||
: undefined;
|
||||
setDestination(storedDestination ?? '');
|
||||
setPublicKey(connectionStatus.publicKey ?? '');
|
||||
}, [connectionStatus]);
|
||||
|
||||
const secretConfigured = connectionStatus?.configured === true;
|
||||
const destinations = connectionStatus?.destinations ?? [];
|
||||
const selectedDestination = destinations.find((option) => option.key === destination);
|
||||
const trimmedPublicKey = publicKey.trim();
|
||||
const trimmedSecretKey = secretKey.trim();
|
||||
const publicKeyInputVisible = !secretConfigured || isEditingPublicKey;
|
||||
const secretInputVisible = !secretConfigured || isEditingSecret;
|
||||
const displayPublicKey = getDisplayPublicKey(publicKey);
|
||||
const canSubmit =
|
||||
trimmedBaseUrl !== '' &&
|
||||
trimmedPublicKey !== '' &&
|
||||
(trimmedSecretKey !== '' || secretConfigured);
|
||||
destination !== '' &&
|
||||
(!publicKeyInputVisible || trimmedPublicKey !== '') &&
|
||||
(!secretInputVisible || trimmedSecretKey !== '');
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate(
|
||||
useEffect(() => {
|
||||
const storedConnectionTestKey = getStoredConnectionTestKey(connectionStatus);
|
||||
if (!connectionStatus || !storedConnectionTestKey) {
|
||||
setConnectionTestState('idle');
|
||||
setConnectionTestMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoTestedConnectionRef.current === storedConnectionTestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoTestedConnectionRef.current = storedConnectionTestKey;
|
||||
setConnectionTestState('checking');
|
||||
testMutation.mutate(
|
||||
{
|
||||
enabled,
|
||||
baseUrl: trimmedBaseUrl,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
destination: connectionStatus.destination ?? '',
|
||||
publicKey: connectionStatus.publicKey ?? '',
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
setConnectionTestState(result.success ? 'connected' : 'failed');
|
||||
setConnectionTestMessage(result.success ? '' : (result.message ?? ''));
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [connectionStatus, localize, testMutation]);
|
||||
|
||||
const connectionStatusLabel =
|
||||
connectionTestState === 'failed' && connectionTestMessage !== ''
|
||||
? connectionTestMessage
|
||||
: localize(getConnectionStatusLabelKey(connectionTestState));
|
||||
const connectionStatusDotClass = getConnectionStatusDotClass(connectionTestState);
|
||||
const connectionStatusTextClass =
|
||||
connectionTestState === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-text-secondary';
|
||||
const connectionStatusTitle =
|
||||
connectionTestState === 'failed' ? localize('com_ui_langfuse_status_failed_hover') : undefined;
|
||||
|
||||
const handleSave = () => {
|
||||
const payload = {
|
||||
enabled,
|
||||
destination,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
};
|
||||
|
||||
const saveConnection = () => {
|
||||
updateMutation.mutate(payload, {
|
||||
onSuccess: (nextStatus) => {
|
||||
autoTestedConnectionRef.current = getStoredConnectionTestKey(nextStatus);
|
||||
setConnectionStatus(nextStatus);
|
||||
setConnectionTestState(enabled ? 'connected' : 'idle');
|
||||
setConnectionTestMessage('');
|
||||
setSecretKey('');
|
||||
setIsEditingPublicKey(false);
|
||||
setIsEditingSecret(false);
|
||||
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
saveConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate(
|
||||
{
|
||||
baseUrl: trimmedBaseUrl,
|
||||
destination,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) =>
|
||||
showToast({
|
||||
message: result.success
|
||||
? localize('com_ui_langfuse_test_success')
|
||||
: (result.message ?? localize('com_ui_langfuse_test_error')),
|
||||
status: result.success ? 'success' : 'error',
|
||||
}),
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_langfuse_test_error'), status: 'error' }),
|
||||
onSuccess: (result) => {
|
||||
if (!result.success) {
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(result.message ?? localize('com_ui_langfuse_test_error'));
|
||||
showToast({
|
||||
message: result.message ?? localize('com_ui_langfuse_test_error'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionTestState('connected');
|
||||
setConnectionTestMessage('');
|
||||
saveConnection();
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
|
||||
showToast({ message: localize('com_ui_langfuse_test_error'), status: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div id="langfuse-enabled-label" className="font-medium">
|
||||
{localize('com_ui_langfuse_title')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_langfuse_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-labelledby="langfuse-enabled-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-base-url">{localize('com_ui_langfuse_base_url')}</Label>
|
||||
<Input
|
||||
id="langfuse-base-url"
|
||||
value={baseUrl}
|
||||
placeholder="https://cloud.langfuse.com"
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-public-key">{localize('com_ui_langfuse_public_key')}</Label>
|
||||
<Input
|
||||
id="langfuse-public-key"
|
||||
value={publicKey}
|
||||
placeholder="pk-lf-..."
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="langfuse-secret-key">
|
||||
{localize('com_ui_langfuse_secret_key')}{' '}
|
||||
<span className="sr-only">
|
||||
({secretConfigured ? localize('com_ui_set') : localize('com_ui_unset')})
|
||||
</span>
|
||||
</Label>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
secretConfigured
|
||||
? 'h-1.5 w-1.5 rounded-full bg-green-500'
|
||||
: 'h-1.5 w-1.5 rounded-full border border-border-medium'
|
||||
}
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div id="langfuse-enabled-label" className="font-medium">
|
||||
{localize('com_ui_langfuse_title')}
|
||||
</div>
|
||||
<div className="rounded-full border border-purple-600/40 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-700 hover:bg-purple-700/10 dark:text-purple-400">
|
||||
{localize('com_ui_beta')}
|
||||
</div>
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<div className="mt-1 max-w-md text-xs text-text-secondary">
|
||||
{localize('com_ui_langfuse_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-labelledby="langfuse-enabled-label"
|
||||
/>
|
||||
<span>{secretConfigured ? localize('com_ui_set') : localize('com_ui_unset')}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs ${connectionStatusTextClass}`}
|
||||
aria-live="polite"
|
||||
title={connectionStatusTitle}
|
||||
>
|
||||
{connectionTestState === 'checking' ? (
|
||||
<Spinner className="h-3 w-3" />
|
||||
) : (
|
||||
<span className={`h-2 w-2 rounded-full ${connectionStatusDotClass}`} />
|
||||
)}
|
||||
<span>{connectionStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SecretInput
|
||||
id="langfuse-secret-key"
|
||||
autoComplete="new-password"
|
||||
controlsOnHover
|
||||
value={secretKey}
|
||||
placeholder={secretConfigured ? localize('com_ui_langfuse_secret_key_set') : 'sk-lf-...'}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{localize('com_ui_langfuse_secret_key_hint')}
|
||||
</span>
|
||||
{secretConfigured && status?.secretKeyFingerprint != null && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{localize('com_ui_langfuse_secret_key_fingerprint')}{' '}
|
||||
<code>{status.secretKeyFingerprint}</code>
|
||||
</span>
|
||||
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_langfuse_beta_info')}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-destination">{localize('com_ui_langfuse_destination')}</Label>
|
||||
<select
|
||||
id="langfuse-destination"
|
||||
className="flex h-9 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary shadow-sm outline-none focus:ring-2 focus:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={destination}
|
||||
disabled={destinations.length === 0}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
>
|
||||
<option value="">{localize('com_ui_select')}</option>
|
||||
{destinations.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.key} - {option.baseUrl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedDestination != null && (
|
||||
<span className="text-xs text-text-tertiary">{selectedDestination.baseUrl}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{publicKeyInputVisible ? (
|
||||
<Label htmlFor="langfuse-public-key">{localize('com_ui_langfuse_public_key')}</Label>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_langfuse_public_key')}
|
||||
</div>
|
||||
)}
|
||||
{secretConfigured && !isEditingPublicKey && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border-light px-3 py-2">
|
||||
<code className="min-w-0 truncate font-mono text-sm text-text-primary">
|
||||
{displayPublicKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 px-3"
|
||||
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_public_key')}`}
|
||||
onClick={() => setIsEditingPublicKey(true)}
|
||||
>
|
||||
{localize('com_ui_edit')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{publicKeyInputVisible && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="langfuse-public-key"
|
||||
value={publicKey}
|
||||
placeholder="pk-lf-..."
|
||||
className="flex-1"
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
/>
|
||||
{secretConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 shrink-0 px-3"
|
||||
onClick={() => {
|
||||
setPublicKey(connectionStatus?.publicKey ?? '');
|
||||
setIsEditingPublicKey(false);
|
||||
}}
|
||||
>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{secretInputVisible ? (
|
||||
<Label htmlFor="langfuse-secret-key">{localize('com_ui_langfuse_secret_key')}</Label>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_langfuse_secret_key')}
|
||||
</div>
|
||||
)}
|
||||
{secretConfigured && !isEditingSecret && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border-light px-3 py-2">
|
||||
<code className="min-w-0 truncate font-mono text-sm text-text-primary">
|
||||
{connectionStatus?.displaySecretKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 px-3"
|
||||
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_secret_key')}`}
|
||||
onClick={() => setIsEditingSecret(true)}
|
||||
>
|
||||
{localize('com_ui_edit')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{secretInputVisible && (
|
||||
<div className="flex items-center gap-2">
|
||||
<SecretInput
|
||||
id="langfuse-secret-key"
|
||||
autoComplete="new-password"
|
||||
controlsOnHover
|
||||
value={secretKey}
|
||||
placeholder="sk-lf-..."
|
||||
className="flex-1"
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
/>
|
||||
{secretConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 shrink-0 px-3"
|
||||
onClick={() => {
|
||||
setSecretKey('');
|
||||
setIsEditingSecret(false);
|
||||
}}
|
||||
>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canSubmit || testMutation.isLoading}
|
||||
onClick={handleTest}
|
||||
disabled={!canSubmit || testMutation.isLoading || updateMutation.isLoading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{testMutation.isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
|
@ -176,12 +404,9 @@ export default function LangfuseConnection() {
|
|||
{localize('com_ui_langfuse_testing')}
|
||||
</span>
|
||||
) : (
|
||||
localize('com_ui_langfuse_test')
|
||||
localize('com_ui_save')
|
||||
)}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit || updateMutation.isLoading} onClick={handleSave}>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import LangfuseConnection from '../LangfuseConnection';
|
||||
|
||||
const mockGet = jest.fn();
|
||||
|
|
@ -25,42 +25,95 @@ beforeEach(() => {
|
|||
mockGet.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockTest.mockReset();
|
||||
mockGet.mockReturnValue({ data: undefined });
|
||||
mockTest.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({ success: true });
|
||||
});
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: false,
|
||||
enabled: false,
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('LangfuseConnection', () => {
|
||||
it('renders the connection form fields', () => {
|
||||
render(<LangfuseConnection />);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_langfuse_destination')).toHaveValue('');
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_langfuse_test')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_configured')).toBeInTheDocument();
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefills stored values and shows the key fingerprint without the secret', () => {
|
||||
it('prefills stored values, tests on load, and shows masked keys until edit', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-12345678-515f',
|
||||
displaySecretKey: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toHaveValue(
|
||||
'https://cloud.langfuse.com',
|
||||
expect(screen.getByLabelText('com_ui_langfuse_destination')).toHaveValue('us');
|
||||
expect(screen.queryByLabelText('com_ui_langfuse_public_key')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('pk-lf-...515f')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('sk-lf-...515f')).toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'com_ui_edit com_ui_langfuse_public_key' }),
|
||||
);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-1');
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveValue('');
|
||||
expect(screen.getByText('abc123def456')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-12345678-515f');
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
expect(mockTest.mock.calls[0][0]).toEqual({
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-12345678-515f',
|
||||
});
|
||||
expect(screen.getByText('com_ui_langfuse_status_connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes the typed secret key when saving a new connection', async () => {
|
||||
render(<LangfuseConnection />);
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_base_url'), {
|
||||
target: { value: 'https://cloud.langfuse.com' },
|
||||
it('shows a failed saved-connection status when the load-time test fails', async () => {
|
||||
mockTest.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({ success: false, message: 'Langfuse rejected these keys' });
|
||||
});
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
displaySecretKey: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Langfuse rejected these keys')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Langfuse rejected these keys').closest('div')).toHaveAttribute(
|
||||
'title',
|
||||
'com_ui_langfuse_status_failed_hover',
|
||||
);
|
||||
});
|
||||
|
||||
it('tests and saves the typed secret key when enabling a new connection', async () => {
|
||||
render(<LangfuseConnection />);
|
||||
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
|
||||
await userEvent.selectOptions(screen.getByLabelText('com_ui_langfuse_destination'), 'us');
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
|
||||
target: { value: 'pk-lf-1' },
|
||||
});
|
||||
|
|
@ -70,50 +123,165 @@ describe('LangfuseConnection', () => {
|
|||
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
expect(mockTest.mock.calls[0][0]).toEqual({
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-secret',
|
||||
});
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toEqual({
|
||||
enabled: false,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
enabled: true,
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the secret key when saving without re-entering it for an already-configured connection', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
it('shows the display secret key immediately after saving a new connection', async () => {
|
||||
mockUpdate.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
},
|
||||
displaySecretKey: 'sk-lf-...cret',
|
||||
});
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
|
||||
await userEvent.selectOptions(screen.getByLabelText('com_ui_langfuse_destination'), 'us');
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
|
||||
target: { value: 'pk-lf-1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
|
||||
target: { value: 'sk-lf-secret' },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).not.toHaveProperty('secretKey');
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({ publicKey: 'pk-lf-1' });
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('sk-lf-...cret')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers a connection test', async () => {
|
||||
it('tests and saves without re-entering the secret for an already-configured connection', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
displaySecretKey: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
mockTest.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByText('com_ui_langfuse_test'));
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
expect(mockTest.mock.calls[0][0]).toMatchObject({
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
});
|
||||
expect(mockTest.mock.calls[0][0]).not.toHaveProperty('secretKey');
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).not.toHaveProperty('secretKey');
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({ destination: 'eu', publicKey: 'pk-lf-1' });
|
||||
});
|
||||
|
||||
it('shows the secret input only while replacing an already-configured secret', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
displaySecretKey: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
mockTest.mockClear();
|
||||
|
||||
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'com_ui_edit com_ui_langfuse_secret_key' }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
|
||||
target: { value: 'sk-lf-replacement' },
|
||||
});
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
expect(mockTest.mock.calls[0][0]).toMatchObject({
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-replacement',
|
||||
});
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-replacement',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks saving when the implicit connection test fails', async () => {
|
||||
mockTest.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({ success: false, message: 'bad key' });
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
|
||||
await userEvent.selectOptions(screen.getByLabelText('com_ui_langfuse_destination'), 'us');
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
|
||||
target: { value: 'pk-lf-1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
|
||||
target: { value: 'sk-lf-secret' },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the connection test when disabling an already-configured connection', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
displaySecretKey: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
mockTest.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByRole('switch', { name: 'com_ui_langfuse_title' }));
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
|
||||
enabled: false,
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1641,19 +1641,20 @@
|
|||
"com_ui_settings_section_accessibility": "Accessibility",
|
||||
"com_ui_settings_section_api_keys": "API keys",
|
||||
"com_ui_settings_section_integrations": "Integrations",
|
||||
"com_ui_langfuse_title": "Langfuse connection",
|
||||
"com_ui_langfuse_title": "Langfuse export",
|
||||
"com_ui_langfuse_description": "Send this organization's traces and feedback scores to your own Langfuse project.",
|
||||
"com_ui_langfuse_base_url": "Host",
|
||||
"com_ui_langfuse_beta_info": "This feature is in beta. Enabling this setting will export traces from all agents in your org to this Langfuse connection.",
|
||||
"com_ui_langfuse_destination": "Destination",
|
||||
"com_ui_langfuse_public_key": "Public key",
|
||||
"com_ui_langfuse_secret_key": "Secret key",
|
||||
"com_ui_langfuse_secret_key_set": "Saved. Enter a new key to replace it",
|
||||
"com_ui_langfuse_secret_key_hint": "The secret key is encrypted at rest and is never shown again after saving.",
|
||||
"com_ui_langfuse_secret_key_fingerprint": "Configured key fingerprint:",
|
||||
"com_ui_langfuse_test": "Test connection",
|
||||
"com_ui_langfuse_status_checking": "Checking connection",
|
||||
"com_ui_langfuse_status_connected": "Connected",
|
||||
"com_ui_langfuse_status_failed": "Connection failed",
|
||||
"com_ui_langfuse_status_failed_hover": "Check Langfuse to see if traces are still failing. A one-time ping with the keys just failed.",
|
||||
"com_ui_langfuse_status_not_configured": "Not configured",
|
||||
"com_ui_langfuse_testing": "Testing connection",
|
||||
"com_ui_langfuse_saved": "Langfuse connection saved",
|
||||
"com_ui_langfuse_save_error": "Failed to save the Langfuse connection",
|
||||
"com_ui_langfuse_test_success": "Connected to Langfuse successfully",
|
||||
"com_ui_langfuse_test_error": "Could not connect to Langfuse",
|
||||
"com_ui_settings_section_appearance": "Appearance",
|
||||
"com_ui_settings_section_billing": "Billing",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ beforeAll(async () => {
|
|||
({ createAdminLangfuseHandlers } = await import('./langfuse'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.LANGFUSE_FANOUT_ENABLED;
|
||||
});
|
||||
|
||||
function mockReq(overrides = {}) {
|
||||
return {
|
||||
user: { id: 'u1', role: 'ADMIN', tenantId: 't1' },
|
||||
|
|
@ -83,6 +91,52 @@ function rehydrate(fields: Record<string, unknown>): Record<string, unknown> {
|
|||
}
|
||||
|
||||
describe('createAdminLangfuseHandlers', () => {
|
||||
describe('fanout feature gate', () => {
|
||||
it('rejects connection reads when deployment fanout is disabled', async () => {
|
||||
delete process.env.LANGFUSE_FANOUT_ENABLED;
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getConnection(mockReq(), res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Langfuse fanout is not enabled' });
|
||||
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects connection updates when deployment fanout is disabled', async () => {
|
||||
delete process.env.LANGFUSE_FANOUT_ENABLED;
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Langfuse fanout is not enabled' });
|
||||
expect(deps.patchConfigFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects connection tests when deployment fanout is disabled', async () => {
|
||||
delete process.env.LANGFUSE_FANOUT_ENABLED;
|
||||
global.fetch = jest.fn() as unknown as typeof fetch;
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.testConnection(
|
||||
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Langfuse fanout is not enabled' });
|
||||
expect(deps.findConfigByPrincipal).not.toHaveBeenCalled();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnection', () => {
|
||||
it('reports not configured when no base config exists', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
|
|
@ -100,10 +154,10 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
findConfigByPrincipal: jest.fn().mockResolvedValue(
|
||||
baseConfigDoc({
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: encryptV3('sk-lf-secret'),
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
displaySecretKey: 'sk-lf...cret',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
@ -114,10 +168,13 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
expect(res.body).toMatchObject({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
displaySecretKey: 'sk-lf...cret',
|
||||
});
|
||||
expect(res.body?.destinations).toEqual(
|
||||
expect.arrayContaining([{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }]),
|
||||
);
|
||||
expect(res.body?.secretKey).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain('sk-lf-secret');
|
||||
expect(JSON.stringify(res.body)).not.toContain('v3:');
|
||||
|
|
@ -125,7 +182,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
});
|
||||
|
||||
describe('updateConnection', () => {
|
||||
it('requires baseUrl', async () => {
|
||||
it('requires destination', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(mockReq({ body: { publicKey: 'pk' } }), res);
|
||||
|
|
@ -135,35 +192,43 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
it('requires publicKey', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
|
||||
res,
|
||||
);
|
||||
await handlers.updateConnection(mockReq({ body: { destination: 'eu' } }), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an invalid baseUrl', async () => {
|
||||
it('rejects an unknown destination', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { baseUrl: 'not-a-url', publicKey: 'pk', secretKey: 'sk' } }),
|
||||
mockReq({ body: { destination: 'mars', publicKey: 'pk', secretKey: 'sk' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requires a secret key on first-time configuration', async () => {
|
||||
it('rejects encrypted secret values from clients', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
|
||||
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: encryptV3('sk') } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(deps.patchConfigFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encrypts the secret key, stores a fingerprint, and never returns the secret', async () => {
|
||||
it('requires a secret key on first-time configuration', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { destination: 'eu', publicKey: 'pk' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(deps.patchConfigFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the secret through the shared config secret helper and never returns the secret', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
|
|
@ -171,7 +236,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
mockReq({
|
||||
body: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-secret',
|
||||
},
|
||||
|
|
@ -183,8 +248,9 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
const fields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(fields['langfuse.secretKey']).toMatch(/^v3:/);
|
||||
expect(fields['langfuse.secretKey']).not.toContain('sk-lf-secret');
|
||||
expect(fields['langfuse.secretKeyFingerprint']).toMatch(/^[a-f0-9]{12}$/);
|
||||
expect(fields['langfuse.displaySecretKey']).toBe('sk-lf-...cret');
|
||||
expect(fields['langfuse.enabled']).toBe(true);
|
||||
expect(fields['langfuse.destination']).toBe('eu');
|
||||
expect(fields['langfuse.publicKey']).toBe('pk-lf-1');
|
||||
expect(res.body?.secretKey).toBeUndefined();
|
||||
expect(deps.invalidateConfigCaches).toHaveBeenCalledWith('t1');
|
||||
|
|
@ -200,7 +266,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
|
||||
await handlers.updateConnection(
|
||||
mockReq({
|
||||
body: { enabled: false, baseUrl: 'https://us.cloud.langfuse.com', publicKey: 'pk-2' },
|
||||
body: { enabled: false, destination: 'us', publicKey: 'pk-2' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
|
@ -208,6 +274,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
expect(res.statusCode).toBe(200);
|
||||
const fields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(fields['langfuse.secretKey']).toBeUndefined();
|
||||
expect(fields['langfuse.destination']).toBe('us');
|
||||
expect(fields['langfuse.publicKey']).toBe('pk-2');
|
||||
});
|
||||
});
|
||||
|
|
@ -218,11 +285,28 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('requires baseUrl and publicKey', async () => {
|
||||
it('requires destination and publicKey', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.testConnection(mockReq({ body: { destination: 'eu' } }), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an unknown destination', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.testConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
|
||||
mockReq({ body: { destination: 'mars', publicKey: 'pk', secretKey: 'sk' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects encrypted secret values from clients', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.testConnection(
|
||||
mockReq({ body: { destination: 'eu', publicKey: 'pk', secretKey: encryptV3('sk') } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
|
@ -237,7 +321,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
|
||||
await handlers.testConnection(
|
||||
mockReq({
|
||||
body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
|
||||
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
|
@ -248,7 +332,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
expect(init.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it('returns failure with status when Langfuse rejects the credentials', async () => {
|
||||
it('returns a key-specific failure when Langfuse rejects the credentials', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 401 }) as unknown as typeof fetch;
|
||||
|
|
@ -257,13 +341,35 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
|
||||
await handlers.testConnection(
|
||||
mockReq({
|
||||
body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
|
||||
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.body?.success).toBe(false);
|
||||
expect(res.body?.message).toContain('401');
|
||||
expect(res.body).toEqual({
|
||||
success: false,
|
||||
message: 'Langfuse rejected these keys. Check the public and secret keys.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an incident-oriented failure when Langfuse returns a server error', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 503 }) as unknown as typeof fetch;
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.testConnection(
|
||||
mockReq({
|
||||
body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.body).toEqual({
|
||||
success: false,
|
||||
message: 'Langfuse is returning server errors. This may be a Langfuse incident.',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the stored (decrypted) secret when none is supplied', async () => {
|
||||
|
|
@ -277,10 +383,7 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.testConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
|
||||
res,
|
||||
);
|
||||
await handlers.testConnection(mockReq({ body: { destination: 'eu', publicKey: 'pk' } }), res);
|
||||
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const [, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import crypto from 'node:crypto';
|
||||
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
|
||||
import { logger, BASE_CONFIG_PRINCIPAL_ID, encryptV3, decryptV3 } from '@librechat/data-schemas';
|
||||
import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
|
||||
import type {
|
||||
TCustomConfig,
|
||||
LangfuseConfig,
|
||||
|
|
@ -13,15 +12,15 @@ import type { IConfig } from '@librechat/data-schemas';
|
|||
import type { Types, ClientSession } from 'mongoose';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
import {
|
||||
getLangfuseTenantDestinations,
|
||||
resolveLangfuseTenantDestination,
|
||||
} from '~/langfuse/tenantDestinations';
|
||||
import { decryptConfigSecret, encryptConfigSecretFields } from './secrets';
|
||||
import { isLangfuseFanoutEnabled } from '~/langfuse/config';
|
||||
|
||||
const DEFAULT_PRIORITY = 10;
|
||||
const FINGERPRINT_LENGTH = 12;
|
||||
|
||||
/** Short, non-reversible fingerprint of a secret so reads can show which key is
|
||||
* configured without exposing it. */
|
||||
function fingerprintSecret(secret: string): string {
|
||||
return crypto.createHash('sha256').update(secret).digest('hex').slice(0, FINGERPRINT_LENGTH);
|
||||
}
|
||||
const ENCRYPTED_PREFIX = 'v3:';
|
||||
|
||||
export interface AdminLangfuseDeps {
|
||||
findConfigByPrincipal: (
|
||||
|
|
@ -55,21 +54,32 @@ function buildStatus(config: IConfig | null): TLangfuseConnectionStatus {
|
|||
return {
|
||||
configured: Boolean(stored?.publicKey && stored?.secretKey),
|
||||
enabled: stored?.enabled === true,
|
||||
baseUrl: stored?.baseUrl,
|
||||
destinations: getLangfuseTenantDestinations(),
|
||||
destination: stored?.destination,
|
||||
publicKey: stored?.publicKey,
|
||||
secretKeyFingerprint: stored?.secretKeyFingerprint,
|
||||
displaySecretKey: stored?.displaySecretKey,
|
||||
updatedAt: config?.updatedAt ? new Date(config.updatedAt).toISOString() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStoredSecret(secret?: string): string | undefined {
|
||||
if (!secret) {
|
||||
function rejectWhenFanoutDisabled(res: Response): Response | undefined {
|
||||
if (isLangfuseFanoutEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
if (!secret.startsWith('v3:')) {
|
||||
return secret;
|
||||
|
||||
return res.status(404).json({ error: 'Langfuse fanout is not enabled' });
|
||||
}
|
||||
|
||||
function getLangfuseTestFailureMessage(status: number): string {
|
||||
if (status === 401) {
|
||||
return 'Langfuse rejected these keys. Check the public and secret keys.';
|
||||
}
|
||||
return decryptV3(secret);
|
||||
|
||||
if (status >= 500) {
|
||||
return 'Langfuse is returning server errors. This may be a Langfuse incident.';
|
||||
}
|
||||
|
||||
return `Langfuse responded with status ${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,6 +103,11 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
}
|
||||
|
||||
async function getConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const disabledResponse = rejectWhenFanoutDisabled(res);
|
||||
if (disabledResponse) {
|
||||
return disabledResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await findBaseConfig();
|
||||
return res.status(200).json(buildStatus(config));
|
||||
|
|
@ -103,23 +118,30 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
}
|
||||
|
||||
async function updateConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const disabledResponse = rejectWhenFanoutDisabled(res);
|
||||
if (disabledResponse) {
|
||||
return disabledResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as TUpdateLangfuseConnectionRequest;
|
||||
const enabled = body.enabled === true;
|
||||
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
|
||||
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
|
||||
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
|
||||
const secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
|
||||
const tenantDestination = resolveLangfuseTenantDestination(destination);
|
||||
|
||||
if (!baseUrl) {
|
||||
return res.status(400).json({ error: 'baseUrl is required' });
|
||||
if (!destination) {
|
||||
return res.status(400).json({ error: 'destination is required' });
|
||||
}
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({ error: 'publicKey is required' });
|
||||
}
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'baseUrl must be a valid URL' });
|
||||
if (!tenantDestination) {
|
||||
return res.status(400).json({ error: 'destination is not configured' });
|
||||
}
|
||||
if (secretKey.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return res.status(400).json({ error: 'Encrypted secretKey values cannot be submitted' });
|
||||
}
|
||||
|
||||
const existing = await findBaseConfig();
|
||||
|
|
@ -132,19 +154,18 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
|
||||
const fields: Record<string, unknown> = {
|
||||
'langfuse.enabled': enabled,
|
||||
'langfuse.baseUrl': baseUrl,
|
||||
'langfuse.destination': tenantDestination.key,
|
||||
'langfuse.publicKey': publicKey,
|
||||
};
|
||||
if (secretKey) {
|
||||
fields['langfuse.secretKey'] = encryptV3(secretKey);
|
||||
fields['langfuse.secretKeyFingerprint'] = fingerprintSecret(secretKey);
|
||||
fields['langfuse.secretKey'] = secretKey;
|
||||
}
|
||||
|
||||
const updated = await patchConfigFields(
|
||||
PrincipalType.ROLE,
|
||||
BASE_CONFIG_PRINCIPAL_ID,
|
||||
PrincipalModel.ROLE,
|
||||
fields,
|
||||
encryptConfigSecretFields(fields),
|
||||
existing?.priority ?? DEFAULT_PRIORITY,
|
||||
);
|
||||
|
||||
|
|
@ -160,26 +181,40 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
}
|
||||
|
||||
async function testConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const disabledResponse = rejectWhenFanoutDisabled(res);
|
||||
if (disabledResponse) {
|
||||
return disabledResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (req.body ?? {}) as TLangfuseConnectionTestRequest;
|
||||
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
|
||||
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
|
||||
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
|
||||
let secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
|
||||
const tenantDestination = resolveLangfuseTenantDestination(destination);
|
||||
|
||||
if (!baseUrl || !publicKey) {
|
||||
return res.status(400).json({ error: 'baseUrl and publicKey are required' });
|
||||
if (!destination || !publicKey) {
|
||||
return res.status(400).json({ error: 'destination and publicKey are required' });
|
||||
}
|
||||
if (!tenantDestination) {
|
||||
return res.status(400).json({ error: 'destination is not configured' });
|
||||
}
|
||||
if (secretKey.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return res.status(400).json({ error: 'Encrypted secretKey values cannot be submitted' });
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
const existing = await findBaseConfig();
|
||||
try {
|
||||
secretKey = resolveStoredSecret(readStoredLangfuse(existing)?.secretKey) ?? '';
|
||||
} catch {
|
||||
const failed: TLangfuseConnectionTestResponse = {
|
||||
success: false,
|
||||
message: 'Stored secret key could not be decrypted',
|
||||
};
|
||||
return res.status(200).json(failed);
|
||||
const storedSecret = readStoredLangfuse(existing)?.secretKey;
|
||||
if (storedSecret) {
|
||||
secretKey = decryptConfigSecret(storedSecret) ?? '';
|
||||
if (!secretKey) {
|
||||
const failed: TLangfuseConnectionTestResponse = {
|
||||
success: false,
|
||||
message: 'Stored secret key could not be decrypted',
|
||||
};
|
||||
return res.status(200).json(failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,12 +227,12 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
}
|
||||
|
||||
const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64');
|
||||
const url = `${baseUrl.replace(/\/+$/, '')}/api/public/projects`;
|
||||
const url = `${tenantDestination.baseUrl}/api/public/projects`;
|
||||
const response = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
|
||||
|
||||
const result: TLangfuseConnectionTestResponse = response.ok
|
||||
? { success: true }
|
||||
: { success: false, message: `Langfuse responded with status ${response.status}` };
|
||||
: { success: false, message: getLangfuseTestFailureMessage(response.status) };
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('[adminLangfuse] testConnection error:', error);
|
||||
|
|
|
|||
|
|
@ -1194,6 +1194,7 @@ describe('Langfuse run config', () => {
|
|||
});
|
||||
|
||||
it('adds tenant Langfuse credentials from tenant-scoped app config', async () => {
|
||||
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
|
||||
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://langfuse-fanout-collector:4318';
|
||||
|
||||
const callArgs = await callAndCaptureRunConfig({
|
||||
|
|
@ -1203,9 +1204,6 @@ describe('Langfuse run config', () => {
|
|||
publicKey: 'pk-tenant-1',
|
||||
secretKey: encryptV3('sk-tenant-1'),
|
||||
destination: 'eu',
|
||||
fanout: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as unknown as AppConfig,
|
||||
});
|
||||
|
|
@ -1628,69 +1626,10 @@ describe('Langfuse run config', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('uses central env Langfuse config when tenant fanout.enabled=false overrides deployment fanout env', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
|
||||
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
|
||||
it('keeps central collector tracing when tenant Langfuse export is disabled', async () => {
|
||||
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
|
||||
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
|
||||
|
||||
const callArgs = await callAndCaptureRunConfig({
|
||||
tenantId: 'tenant-1',
|
||||
appConfig: {
|
||||
langfuse: {
|
||||
publicKey: 'pk-tenant-1',
|
||||
secretKey: encryptV3('sk-tenant-1'),
|
||||
destination: 'eu',
|
||||
fanout: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as AppConfig,
|
||||
});
|
||||
|
||||
expect(callArgs.langfuse).toEqual({
|
||||
deterministicTraceId: true,
|
||||
publicKey: 'pk-central',
|
||||
secretKey: 'sk-central',
|
||||
baseUrl: 'https://central.langfuse.example',
|
||||
metadata: { 'librechat.tenant.id': 'tenant-1' },
|
||||
tags: ['tenant:tenant-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses central env Langfuse config when tenant fanout.enabled is the string false', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
|
||||
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
|
||||
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
|
||||
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
|
||||
|
||||
const callArgs = await callAndCaptureRunConfig({
|
||||
tenantId: 'tenant-1',
|
||||
appConfig: {
|
||||
langfuse: {
|
||||
publicKey: 'pk-tenant-1',
|
||||
secretKey: encryptV3('sk-tenant-1'),
|
||||
destination: 'eu',
|
||||
fanout: {
|
||||
enabled: 'false',
|
||||
},
|
||||
},
|
||||
} as unknown as AppConfig,
|
||||
});
|
||||
|
||||
expect(callArgs.langfuse).toEqual({
|
||||
deterministicTraceId: true,
|
||||
publicKey: 'pk-central',
|
||||
secretKey: 'sk-central',
|
||||
baseUrl: 'https://central.langfuse.example',
|
||||
metadata: { 'librechat.tenant.id': 'tenant-1' },
|
||||
tags: ['tenant:tenant-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('honors tenant Langfuse enabled=false as a tracing opt-out', async () => {
|
||||
const callArgs = await callAndCaptureRunConfig({
|
||||
tenantId: 'tenant-1',
|
||||
appConfig: {
|
||||
|
|
@ -1704,13 +1643,16 @@ describe('Langfuse run config', () => {
|
|||
|
||||
expect(callArgs.langfuse).toEqual({
|
||||
deterministicTraceId: true,
|
||||
enabled: false,
|
||||
baseUrl: 'http://collector-from-env:4318',
|
||||
metadata: { 'librechat.tenant.id': 'tenant-1' },
|
||||
tags: ['tenant:tenant-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('honors tenant Langfuse enabled as the string false', async () => {
|
||||
it('keeps central collector tracing when tenant Langfuse enabled is the string false', async () => {
|
||||
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
|
||||
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
|
||||
|
||||
const callArgs = await callAndCaptureRunConfig({
|
||||
tenantId: 'tenant-1',
|
||||
appConfig: {
|
||||
|
|
@ -1724,7 +1666,7 @@ describe('Langfuse run config', () => {
|
|||
|
||||
expect(callArgs.langfuse).toEqual({
|
||||
deterministicTraceId: true,
|
||||
enabled: false,
|
||||
baseUrl: 'http://collector-from-env:4318',
|
||||
metadata: { 'librechat.tenant.id': 'tenant-1' },
|
||||
tags: ['tenant:tenant-1'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import { resolveLangfuseTenantDestination } from './tenantDestinations';
|
|||
import { normalizeString } from '~/utils/text';
|
||||
|
||||
type LangfuseRunConfig = NonNullable<RunConfig['langfuse']>;
|
||||
type LangfuseAppConfig = NonNullable<AppConfig['langfuse']>;
|
||||
export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'];
|
||||
type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & {
|
||||
librechatTraceAttributes?: Record<string, string | number | boolean | null | undefined>;
|
||||
};
|
||||
|
|
@ -22,9 +20,8 @@ export function isLangfuseTenantExportEnabled(): boolean {
|
|||
return !isTrueEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED);
|
||||
}
|
||||
|
||||
export function isLangfuseFanoutEnabled(fanout?: LangfuseFanoutConfig): boolean {
|
||||
const enabled = normalizeBoolean(fanout?.enabled);
|
||||
return enabled !== false && (enabled === true || isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED));
|
||||
export function isLangfuseFanoutEnabled(): boolean {
|
||||
return isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED);
|
||||
}
|
||||
|
||||
function mergeTraceMetadata(
|
||||
|
|
@ -83,22 +80,16 @@ export function buildLangfuseConfig({
|
|||
langfuse.tags = tags;
|
||||
}
|
||||
|
||||
if (normalizeBoolean(config?.enabled) === false) {
|
||||
return {
|
||||
...langfuse,
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const tenantLangfuseEnabled = normalizeBoolean(config?.enabled) !== false;
|
||||
const tenantCredentials = resolveTenantCredentials(config);
|
||||
const hasTenantCredentials = Boolean(tenantCredentials);
|
||||
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
|
||||
const fanoutEnabled = isLangfuseFanoutEnabled(fanout);
|
||||
const fanoutEnabled = isLangfuseFanoutEnabled();
|
||||
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
|
||||
const tenantDestination = resolveLangfuseTenantDestination(config?.destination);
|
||||
const tenantExportDestination = hasTenantCredentials ? tenantDestination : undefined;
|
||||
const tenantExportCollectorUrl = fanoutCollectorUrl;
|
||||
const tenantExportEnabled =
|
||||
tenantLangfuseEnabled &&
|
||||
hasTenantCredentials &&
|
||||
fanoutEnabled &&
|
||||
isLangfuseTenantExportEnabled() &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { LangfuseFanoutConfig } from './config';
|
||||
import {
|
||||
isFalseEnv,
|
||||
normalizeBoolean,
|
||||
|
|
@ -75,8 +74,7 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
|
|||
if (normalizeBoolean(config?.enabled) === false) {
|
||||
return undefined;
|
||||
}
|
||||
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
|
||||
if (!isLangfuseFanoutEnabled(fanout)) {
|
||||
if (!isLangfuseFanoutEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
const fanoutCollectorUrl = normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
|
||||
|
|
|
|||
|
|
@ -617,58 +617,6 @@ describe('Langfuse feedback scores', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('skips tenant scores when tenant fanout is disabled in app config', async () => {
|
||||
enableTenantFanout();
|
||||
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
|
||||
const { sendFeedbackScore } = await loadFeedback();
|
||||
|
||||
await sendFeedbackScore({
|
||||
traceId: 'trace-id',
|
||||
feedback: { rating: 'thumbsUp' },
|
||||
appConfig: appConfigWithLangfuse({
|
||||
publicKey: 'tenant-public-key',
|
||||
secretKey: encryptedTenantSecret(),
|
||||
destination: 'eu',
|
||||
fanout: { enabled: false },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getFetchMock()).toHaveBeenCalledTimes(1);
|
||||
expect(getFetchMock()).toHaveBeenCalledWith(
|
||||
'http://central-langfuse:3000/api/public/scores',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips tenant scores when tenant fanout enabled is the string false', async () => {
|
||||
enableTenantFanout();
|
||||
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
|
||||
const { sendFeedbackScore } = await loadFeedback();
|
||||
|
||||
await sendFeedbackScore({
|
||||
traceId: 'trace-id',
|
||||
feedback: { rating: 'thumbsUp' },
|
||||
appConfig: appConfigWithLangfuse({
|
||||
publicKey: 'tenant-public-key',
|
||||
secretKey: encryptedTenantSecret(),
|
||||
destination: 'eu',
|
||||
fanout: { enabled: 'false' },
|
||||
} as unknown as AppConfig['langfuse']),
|
||||
});
|
||||
|
||||
expect(getFetchMock()).toHaveBeenCalledTimes(1);
|
||||
expect(getFetchMock()).toHaveBeenCalledWith(
|
||||
'http://central-langfuse:3000/api/public/scores',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips tenant scores when fanout has no collector URL', async () => {
|
||||
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
|
||||
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
|
||||
|
|
|
|||
|
|
@ -1180,16 +1180,14 @@ describe('specsConfigSchema', () => {
|
|||
});
|
||||
|
||||
describe('configSchema langfuse', () => {
|
||||
it('accepts tenant Langfuse fanout config', () => {
|
||||
it('accepts tenant Langfuse connection config', () => {
|
||||
const result = configSchema.safeParse({
|
||||
version: '1.3.7',
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-lf-tenant',
|
||||
secretKey: 'sk-lf-tenant',
|
||||
fanout: {
|
||||
enabled: true,
|
||||
collectorUrl: 'http://langfuse-fanout-collector:4318',
|
||||
},
|
||||
destination: 'eu',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1840,11 +1840,6 @@ export const langfuseConfigSchema = z.object({
|
|||
displaySecretKey: z.string().optional(),
|
||||
/** Routing key for one of the deployment-configured tenant Langfuse destinations. */
|
||||
destination: z.string().optional(),
|
||||
fanout: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type LangfuseConfig = z.infer<typeof langfuseConfigSchema>;
|
||||
|
|
|
|||
|
|
@ -834,21 +834,27 @@ export type TUpdateSkillNodeRequest = {
|
|||
export type TLangfuseConnectionStatus = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
baseUrl?: string;
|
||||
destinations: TLangfuseDestinationOption[];
|
||||
destination?: string;
|
||||
publicKey?: string;
|
||||
secretKeyFingerprint?: string;
|
||||
displaySecretKey?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type TLangfuseDestinationOption = {
|
||||
key: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export type TUpdateLangfuseConnectionRequest = {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
destination: string;
|
||||
publicKey: string;
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
export type TLangfuseConnectionTestRequest = {
|
||||
baseUrl: string;
|
||||
destination: string;
|
||||
publicKey: string;
|
||||
secretKey?: string;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue