mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🪢 feat: Langfuse Fanout Connection Setting (#14108)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* feat: encrypt tenant Langfuse secret in admin config Add generic per-field secret encryption to the admin config layer: registered secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a non-secret fingerprint companion is stored. Admin config reads (base + per principal) redact registered secrets so they are never returned; the fingerprint is kept so the UI can show which key is configured. The Langfuse fanout read path decrypts the tenant secret before export. Adds secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact policy. * fix(api): secure admin config secret handling * fix(api): preserve encrypted langfuse config secrets * fix(api): couple config secret fingerprint deletion * fix(api): read langfuse fanout collector url from env * fix(api): display langfuse secret key hint * fix(api): remove langfuse secret fingerprint breadcrumbs * fix(api): use langfuse destination keys for tenant config * fix(api): remove langfuse config compatibility fallbacks * refactor(api): simplify langfuse secret helpers * refactor(api): simplify langfuse config secret handling * feat: in-app Langfuse connection settings panel Add a discoverable, admin-gated Langfuse connection panel inside LibreChat Settings (Dify-style): enable toggle, host, public key, masked write-only secret, configured-key fingerprint, and a test-connection action. Backed by a dedicated /api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns metadata plus fingerprint on read, and validates credentials. Builds on the per-field encryption and fanout decrypt from the langfuse-config-encryption branch. * refactor: align Langfuse secret field to CustomUserVars pattern Use the established SecretInput plus Set/Unset state pill (com_ui_set/com_ui_unset) from the MCP CustomUserVars UI for the saved-secret state, instead of a bespoke masked input. * fix: drop em dash from saved-secret placeholder * feat: show loading state on Langfuse test connection button * feat: gate in-app Langfuse settings on fanout config and admin role * test: align Langfuse connection spec with SecretInput refactor * feat(langfuse): refine tenant connection controls * fix(admin): refine Langfuse connection verification * fix(langfuse): refine tenant connection settings * fix(langfuse): simplify export enablement controls * fix(langfuse): validate tenant export configuration * fix(langfuse): align startup fanout gate * fix(admin): time out Langfuse verification * fix(ui): rename Langfuse connection setting * fix(admin): enforce Langfuse config capability * feat(langfuse): require explicit tenant export activation * feat(langfuse): support single-tenant connection settings * fix(i18n): remove obsolete integrations label * fix(langfuse): authenticate ingestion verification * fix(langfuse): validate public key independently * fix(langfuse): localize connection errors * perf(config): skip Langfuse checks for non-admins * fix(langfuse): preserve trace sampling for feedback * test(langfuse): fix feedback sampling fixture * fix(langfuse): align secret preview field * fix(langfuse): harden connection settings state * fix(langfuse): preserve trace destination state * fix(langfuse): enforce tenant-wide routing invariants * fix(langfuse): preserve verified connection invariants * fix(langfuse): preserve stable project identity * fix(langfuse): warm project identity asynchronously --------- Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
91adcf3f2c
commit
af795be0c2
51 changed files with 4356 additions and 527 deletions
|
|
@ -64,7 +64,12 @@ export default function Content({ activeTab, query, ctx }: ContentProps) {
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<Section key={section.id} heading={localize(section.labelKey)} danger={section.danger}>
|
||||
<Section
|
||||
key={section.id}
|
||||
heading={localize(section.labelKey)}
|
||||
icon={section.icon}
|
||||
danger={section.danger}
|
||||
>
|
||||
{entries.map((e) => {
|
||||
const Cmp = e.Component;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,19 +3,21 @@ import { cn } from '~/utils';
|
|||
|
||||
interface SectionProps {
|
||||
heading: string;
|
||||
icon?: ReactNode;
|
||||
danger?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Section({ heading, danger, children }: SectionProps) {
|
||||
export default function Section({ heading, icon, danger, children }: SectionProps) {
|
||||
return (
|
||||
<section className="mb-7">
|
||||
<h3
|
||||
className={cn(
|
||||
'mb-2 px-1 text-xs font-semibold uppercase tracking-wide',
|
||||
'mb-2 flex items-center gap-1.5 px-1 text-xs font-semibold uppercase tracking-wide',
|
||||
danger ? 'text-red-500' : 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{heading}
|
||||
</h3>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ctx: SettingsContextValue = {
|
|||
allowAccountDeletion: true,
|
||||
aboutEnabled: false,
|
||||
engineTTS: 'browser',
|
||||
langfuseConnectionAccess: false,
|
||||
};
|
||||
|
||||
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {
|
||||
|
|
@ -46,6 +47,16 @@ describe('Sidebar', () => {
|
|||
expect(screen.getByText('About')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Langfuse tab when Langfuse is available to the user', () => {
|
||||
setup({ langfuseConnectionAccess: true });
|
||||
expect(screen.getByText('Langfuse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Langfuse tab without Langfuse connection access', () => {
|
||||
setup({ langfuseConnectionAccess: false });
|
||||
expect(screen.queryByText('Langfuse')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards typing to onQueryChange', async () => {
|
||||
const { onQueryChange } = setup();
|
||||
await userEvent.type(screen.getByRole('textbox'), 'theme');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import { isValidElementType } from 'react-is';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import type { SettingsContextValue } from '../types';
|
||||
import en from '~/locales/en/translation.json';
|
||||
import { registry } from '../registry';
|
||||
import { TABS } from '../types';
|
||||
|
||||
const validTabSections = new Map(TABS.map((t) => [t.id, new Set(t.sections.map((s) => s.id))]));
|
||||
|
||||
const settingsContext: SettingsContextValue = {
|
||||
balanceEnabled: false,
|
||||
hasAnyPersonalizationFeature: false,
|
||||
hasMemoryOptOut: false,
|
||||
hasRemoteAgents: false,
|
||||
hasUserProvidedEndpoints: false,
|
||||
hasMultiConvo: false,
|
||||
hasPrompts: false,
|
||||
isLocalProvider: true,
|
||||
twoFactorEnabled: false,
|
||||
allowAccountDeletion: true,
|
||||
aboutEnabled: false,
|
||||
engineTTS: 'browser',
|
||||
langfuseConnectionAccess: false,
|
||||
};
|
||||
|
||||
describe('settings registry', () => {
|
||||
it('has unique ids', () => {
|
||||
const ids = registry.map((e) => e.id);
|
||||
|
|
@ -30,4 +48,42 @@ describe('settings registry', () => {
|
|||
expect(isValidElementType(entry.Component)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Langfuse connection visibility', () => {
|
||||
const langfuseEntry = registry.find((entry) => entry.id === 'langfuseConnection');
|
||||
|
||||
it('places the connection in the Langfuse tab', () => {
|
||||
expect(langfuseEntry).toMatchObject({
|
||||
tab: SettingsTabValues.LANGFUSE,
|
||||
section: 'langfuse',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the connection when the user can manage it', () => {
|
||||
expect(
|
||||
langfuseEntry?.show?.({
|
||||
...settingsContext,
|
||||
langfuseConnectionAccess: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the connection without Langfuse config access', () => {
|
||||
expect(
|
||||
langfuseEntry?.show?.({
|
||||
...settingsContext,
|
||||
langfuseConnectionAccess: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the connection in single-tenant mode without fanout', () => {
|
||||
expect(
|
||||
langfuseEntry?.show?.({
|
||||
...settingsContext,
|
||||
langfuseConnectionAccess: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export function useSettingsContext(): SettingsContextValue {
|
|||
});
|
||||
|
||||
const balanceEnabled = startupConfig?.balance?.enabled === true;
|
||||
const langfuseConnectionAccess = startupConfig?.langfuseConnectionAccess === true;
|
||||
const isLocalProvider = user?.provider === 'local';
|
||||
const twoFactorEnabled = user?.twoFactorEnabled === true;
|
||||
const allowAccountDeletion = startupConfig?.allowAccountDeletion !== false;
|
||||
|
|
@ -51,6 +52,7 @@ export function useSettingsContext(): SettingsContextValue {
|
|||
allowAccountDeletion,
|
||||
aboutEnabled,
|
||||
engineTTS,
|
||||
langfuseConnectionAccess,
|
||||
}),
|
||||
[
|
||||
balanceEnabled,
|
||||
|
|
@ -65,6 +67,7 @@ export function useSettingsContext(): SettingsContextValue {
|
|||
allowAccountDeletion,
|
||||
aboutEnabled,
|
||||
engineTTS,
|
||||
langfuseConnectionAccess,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import DisplayUsernameMessages from '../SettingsTabs/Account/DisplayUsernameMessages';
|
||||
import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitch';
|
||||
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
|
||||
import LangfuseConnection from '../SettingsTabs/Integrations/LangfuseConnection';
|
||||
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
|
||||
import { toggleControl, ThemeSetting, LangSetting } from './controls';
|
||||
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
|
||||
|
|
@ -500,6 +501,16 @@ export const registry: SettingEntry[] = [
|
|||
labelKey: 'com_ui_settings_label_revoke_keys',
|
||||
Component: RevokeKeys,
|
||||
},
|
||||
// Langfuse
|
||||
{
|
||||
id: 'langfuseConnection',
|
||||
tab: SettingsTabValues.LANGFUSE,
|
||||
section: 'langfuse',
|
||||
labelKey: 'com_ui_langfuse_title',
|
||||
keywords: ['langfuse', 'observability', 'tracing', 'telemetry', 'traces'],
|
||||
show: (ctx) => ctx.langfuseConnectionAccess,
|
||||
Component: LangfuseConnection,
|
||||
},
|
||||
// Data controls · Danger zone
|
||||
{
|
||||
id: 'deleteCache',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type SettingsTab =
|
|||
| SettingsTabValues.GENERAL
|
||||
| SettingsTabValues.CHAT
|
||||
| SettingsTabValues.SPEECH
|
||||
| SettingsTabValues.LANGFUSE
|
||||
| SettingsTabValues.DATA
|
||||
| SettingsTabValues.ACCOUNT
|
||||
| SettingsTabValues.ABOUT;
|
||||
|
|
@ -27,6 +28,7 @@ export type SectionId =
|
|||
| 'memory'
|
||||
| 'data'
|
||||
| 'apiKeys'
|
||||
| 'langfuse'
|
||||
| 'danger'
|
||||
| 'profile'
|
||||
| 'security'
|
||||
|
|
@ -46,6 +48,7 @@ export interface SettingsContextValue {
|
|||
allowAccountDeletion: boolean;
|
||||
aboutEnabled: boolean;
|
||||
engineTTS: string;
|
||||
langfuseConnectionAccess: boolean;
|
||||
}
|
||||
|
||||
export interface SettingEntry {
|
||||
|
|
@ -61,6 +64,7 @@ export interface SettingEntry {
|
|||
export interface SectionMeta {
|
||||
id: SectionId;
|
||||
labelKey: TranslationKeys;
|
||||
icon?: ReactNode;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +76,17 @@ export interface TabMeta {
|
|||
show?: (ctx: SettingsContextValue) => boolean;
|
||||
}
|
||||
|
||||
function createLangfuseIcon(className: string): ReactNode {
|
||||
return createElement('span', {
|
||||
className: `${className} inline-block shrink-0 bg-current`,
|
||||
'aria-hidden': true,
|
||||
style: {
|
||||
WebkitMask: 'url(/assets/langfuse-icon-monochrome.svg) center / contain no-repeat',
|
||||
mask: 'url(/assets/langfuse-icon-monochrome.svg) center / contain no-repeat',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const TABS: TabMeta[] = [
|
||||
{
|
||||
id: SettingsTabValues.GENERAL,
|
||||
|
|
@ -104,6 +119,19 @@ export const TABS: TabMeta[] = [
|
|||
{ id: 'tts', labelKey: 'com_ui_settings_section_tts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.LANGFUSE,
|
||||
labelKey: 'com_ui_settings_tab_langfuse',
|
||||
icon: createLangfuseIcon('h-4 w-4'),
|
||||
sections: [
|
||||
{
|
||||
id: 'langfuse',
|
||||
labelKey: 'com_ui_settings_section_langfuse',
|
||||
icon: createLangfuseIcon('h-3.5 w-3.5'),
|
||||
},
|
||||
],
|
||||
show: (ctx) => ctx.langfuseConnectionAccess,
|
||||
},
|
||||
{
|
||||
id: SettingsTabValues.DATA,
|
||||
labelKey: 'com_ui_settings_tab_data',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,613 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
CircleHelpIcon,
|
||||
Dropdown,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
HoverCardTrigger,
|
||||
Input,
|
||||
Label,
|
||||
SecretInput,
|
||||
Spinner,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type {
|
||||
TLangfuseConnectionStatus,
|
||||
TLangfuseConnectionTestErrorCode,
|
||||
} 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' | 'unverified' | 'checking' | 'connected' | 'failed';
|
||||
|
||||
function getStoredConnectionTestKey(status?: TLangfuseConnectionStatus): string | undefined {
|
||||
if (status?.configured !== true || !status.destination || !status.publicKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [status.destination, status.publicKey].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 'unverified':
|
||||
return 'com_ui_langfuse_status_not_verified';
|
||||
case 'idle':
|
||||
default:
|
||||
return 'com_ui_langfuse_status_not_configured';
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionTestErrorLabelKey(
|
||||
errorCode?: TLangfuseConnectionTestErrorCode,
|
||||
): TranslationKeys {
|
||||
switch (errorCode) {
|
||||
case 'invalid_credentials':
|
||||
return 'com_ui_langfuse_test_invalid_credentials';
|
||||
case 'access_denied':
|
||||
return 'com_ui_langfuse_test_access_denied';
|
||||
case 'rate_limited':
|
||||
return 'com_ui_langfuse_test_rate_limited';
|
||||
case 'server_error':
|
||||
return 'com_ui_langfuse_test_server_error';
|
||||
case 'timeout':
|
||||
return 'com_ui_langfuse_test_timeout';
|
||||
case 'missing_secret':
|
||||
return 'com_ui_langfuse_test_missing_secret';
|
||||
case 'stored_secret_unavailable':
|
||||
return 'com_ui_langfuse_test_stored_secret_unavailable';
|
||||
case 'unexpected_response':
|
||||
return 'com_ui_langfuse_test_unexpected_response';
|
||||
case 'unreachable':
|
||||
default:
|
||||
return 'com_ui_langfuse_test_error';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const { showToast } = useToastContext();
|
||||
const {
|
||||
data: status,
|
||||
isLoading: isConnectionLoading,
|
||||
isError: isConnectionError,
|
||||
isFetching: isConnectionFetching,
|
||||
refetch: refetchConnection,
|
||||
} = useGetLangfuseConnectionQuery();
|
||||
const updateMutation = useUpdateLangfuseConnectionMutation();
|
||||
const testMutation = useTestLangfuseConnectionMutation();
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<TLangfuseConnectionStatus>();
|
||||
const [destination, setDestination] = useState('');
|
||||
const [publicKey, setPublicKey] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [isEditingPublicKey, setIsEditingPublicKey] = useState(false);
|
||||
const [isEditingSecretKey, setIsEditingSecretKey] = useState(false);
|
||||
const [connectionTestState, setConnectionTestState] = useState<ConnectionTestState>('idle');
|
||||
const [connectionTestMessage, setConnectionTestMessage] = useState('');
|
||||
const autoTestedConnectionRef = useRef<string>();
|
||||
const connectionTestRequestRef = useRef(0);
|
||||
const publicKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
const secretKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingPublicKey) {
|
||||
publicKeyInputRef.current?.focus();
|
||||
}
|
||||
}, [isEditingPublicKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingSecretKey) {
|
||||
secretKeyInputRef.current?.focus();
|
||||
}
|
||||
}, [isEditingSecretKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
setConnectionStatus(status);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionStatus) {
|
||||
return;
|
||||
}
|
||||
setDestination(connectionStatus.destination ?? '');
|
||||
setPublicKey(connectionStatus.publicKey ?? '');
|
||||
}, [connectionStatus]);
|
||||
|
||||
const secretConfigured = connectionStatus?.configured === true;
|
||||
const destinations = connectionStatus?.destinations ?? [];
|
||||
const connectionDestinationAvailable = destinations.some(
|
||||
({ key }) => key === connectionStatus?.destination,
|
||||
);
|
||||
const storedDestinationUnavailable =
|
||||
secretConfigured && Boolean(connectionStatus?.destination) && !connectionDestinationAvailable;
|
||||
const destinationOptions = [
|
||||
...(storedDestinationUnavailable && connectionStatus?.destination
|
||||
? [
|
||||
{
|
||||
value: connectionStatus.destination,
|
||||
label: `${connectionStatus.destination} - ${localize(
|
||||
'com_ui_langfuse_destination_unavailable',
|
||||
)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...destinations.map(({ key, baseUrl }) => ({
|
||||
value: key,
|
||||
label: `${key} - ${baseUrl}`,
|
||||
})),
|
||||
];
|
||||
const trimmedPublicKey = publicKey.trim();
|
||||
const trimmedSecretKey = secretKey.trim();
|
||||
const publicKeyInputVisible = !secretConfigured || isEditingPublicKey;
|
||||
const secretInputVisible = !secretConfigured || isEditingSecretKey;
|
||||
const displayPublicKey = getDisplayPublicKey(publicKey);
|
||||
const connectionCredentialsChanged =
|
||||
destination !== (connectionStatus?.destination ?? '') ||
|
||||
trimmedPublicKey !== (connectionStatus?.publicKey ?? '');
|
||||
const hasUnsavedChanges = connectionCredentialsChanged || trimmedSecretKey !== '';
|
||||
const isEditing =
|
||||
!secretConfigured || isEditingPublicKey || isEditingSecretKey || hasUnsavedChanges;
|
||||
const canSubmit =
|
||||
destination !== '' &&
|
||||
trimmedPublicKey !== '' &&
|
||||
((!connectionCredentialsChanged && secretConfigured) || trimmedSecretKey !== '');
|
||||
const busy = testMutation.isLoading || updateMutation.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
const storedConnectionTestKey = getStoredConnectionTestKey(connectionStatus);
|
||||
if (!connectionStatus) {
|
||||
return;
|
||||
}
|
||||
if (!storedConnectionTestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connectionStatus.destinations?.some(({ key }) => key === connectionStatus.destination)) {
|
||||
connectionTestRequestRef.current += 1;
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_destination_removed'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoTestedConnectionRef.current === storedConnectionTestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoTestedConnectionRef.current = storedConnectionTestKey;
|
||||
const requestId = ++connectionTestRequestRef.current;
|
||||
setConnectionTestState('checking');
|
||||
testMutation.mutate(
|
||||
{
|
||||
destination: connectionStatus.destination ?? '',
|
||||
publicKey: connectionStatus.publicKey ?? '',
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (requestId !== connectionTestRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setConnectionTestState(result.success ? 'connected' : 'failed');
|
||||
setConnectionTestMessage(
|
||||
result.success ? '' : localize(getConnectionTestErrorLabelKey(result.errorCode)),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
if (requestId !== connectionTestRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
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: true,
|
||||
destination,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
};
|
||||
|
||||
connectionTestRequestRef.current += 1;
|
||||
updateMutation.mutate(payload, {
|
||||
onSuccess: (nextStatus) => {
|
||||
autoTestedConnectionRef.current = getStoredConnectionTestKey(nextStatus);
|
||||
setConnectionStatus(nextStatus);
|
||||
setConnectionTestState('connected');
|
||||
setConnectionTestMessage('');
|
||||
setSecretKey('');
|
||||
setIsEditingPublicKey(false);
|
||||
setIsEditingSecretKey(false);
|
||||
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_save_error'));
|
||||
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const storedDestination = connectionStatus?.destination;
|
||||
setDestination(storedDestination ?? '');
|
||||
setPublicKey(connectionStatus?.publicKey ?? '');
|
||||
setSecretKey('');
|
||||
setIsEditingPublicKey(false);
|
||||
setIsEditingSecretKey(false);
|
||||
|
||||
if (!storedDestination || !connectionStatus?.publicKey) {
|
||||
setConnectionTestState('idle');
|
||||
setConnectionTestMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connectionStatus.destinations?.some(({ key }) => key === storedDestination)) {
|
||||
connectionTestRequestRef.current += 1;
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_destination_removed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++connectionTestRequestRef.current;
|
||||
setConnectionTestState('checking');
|
||||
setConnectionTestMessage('');
|
||||
testMutation.mutate(
|
||||
{ destination: storedDestination, publicKey: connectionStatus.publicKey },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (requestId !== connectionTestRequestRef.current) return;
|
||||
setConnectionTestState(result.success ? 'connected' : 'failed');
|
||||
setConnectionTestMessage(
|
||||
result.success ? '' : localize(getConnectionTestErrorLabelKey(result.errorCode)),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
if (requestId !== connectionTestRequestRef.current) return;
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDestinationChange = (nextDestination: string) => {
|
||||
setDestination(nextDestination);
|
||||
const requestId = ++connectionTestRequestRef.current;
|
||||
const credentialsChanged =
|
||||
nextDestination !== (connectionStatus?.destination ?? '') ||
|
||||
trimmedPublicKey !== (connectionStatus?.publicKey ?? '');
|
||||
|
||||
if (secretConfigured && credentialsChanged) {
|
||||
setIsEditingSecretKey(true);
|
||||
}
|
||||
|
||||
if (
|
||||
nextDestination === '' ||
|
||||
trimmedPublicKey === '' ||
|
||||
((!secretConfigured || credentialsChanged) && trimmedSecretKey === '')
|
||||
) {
|
||||
setConnectionTestState(credentialsChanged ? 'unverified' : 'idle');
|
||||
setConnectionTestMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionTestState('checking');
|
||||
setConnectionTestMessage('');
|
||||
testMutation.mutate(
|
||||
{
|
||||
destination: nextDestination,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (requestId !== connectionTestRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setConnectionTestState(result.success ? 'connected' : 'failed');
|
||||
setConnectionTestMessage(
|
||||
result.success ? '' : localize(getConnectionTestErrorLabelKey(result.errorCode)),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
if (requestId !== connectionTestRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
setConnectionTestState('failed');
|
||||
setConnectionTestMessage(localize('com_ui_langfuse_test_error'));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleEnabledChange = () => {
|
||||
if (!secretConfigured || !connectionStatus?.destination || !connectionStatus.publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnabled = connectionStatus.enabled !== true;
|
||||
const requestId = ++connectionTestRequestRef.current;
|
||||
const saveEnabledState = () => {
|
||||
updateMutation.mutate(
|
||||
{
|
||||
enabled: nextEnabled,
|
||||
destination: connectionStatus.destination ?? '',
|
||||
publicKey: connectionStatus.publicKey ?? '',
|
||||
},
|
||||
{
|
||||
onSuccess: (nextStatus) => {
|
||||
if (requestId !== connectionTestRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
autoTestedConnectionRef.current = getStoredConnectionTestKey(nextStatus);
|
||||
setConnectionStatus(nextStatus);
|
||||
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
|
||||
},
|
||||
onError: () => {
|
||||
if (requestId !== connectionTestRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
saveEnabledState();
|
||||
};
|
||||
|
||||
if (isConnectionLoading && connectionStatus == null) {
|
||||
return (
|
||||
<div
|
||||
data-testid="langfuse-connection-loading"
|
||||
className="flex items-center justify-center rounded-xl border border-border-light py-12"
|
||||
>
|
||||
<Spinner className="h-6 w-6 text-text-secondary" />
|
||||
<span className="sr-only">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isConnectionError && connectionStatus == null) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-border-light px-6 py-10 text-center">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_langfuse_load_error')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchConnection()}
|
||||
disabled={isConnectionFetching}
|
||||
>
|
||||
{localize('com_ui_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div 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>
|
||||
</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>
|
||||
|
||||
<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 id="langfuse-destination-label">{localize('com_ui_langfuse_destination')}</Label>
|
||||
<Dropdown
|
||||
value={destination}
|
||||
label={destination === '' ? localize('com_ui_select') : ''}
|
||||
onChange={handleDestinationChange}
|
||||
options={destinationOptions}
|
||||
disabled={destinations.length === 0 || busy}
|
||||
className="w-full"
|
||||
sizeClasses="z-50 w-[var(--popover-anchor-width)]"
|
||||
testId="langfuse-destination"
|
||||
aria-labelledby="langfuse-destination-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-public-token">{localize('com_ui_langfuse_public_key')}</Label>
|
||||
{secretConfigured && !isEditingPublicKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-border-light px-3 py-2 text-left hover:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_public_key')}`}
|
||||
disabled={busy}
|
||||
onClick={() => setIsEditingPublicKey(true)}
|
||||
>
|
||||
<code className="block min-w-0 truncate font-mono text-sm text-text-primary">
|
||||
{displayPublicKey}
|
||||
</code>
|
||||
</button>
|
||||
)}
|
||||
{publicKeyInputVisible && (
|
||||
<Input
|
||||
ref={publicKeyInputRef}
|
||||
id="langfuse-public-token"
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
value={publicKey}
|
||||
disabled={busy}
|
||||
placeholder="pk-lf-..."
|
||||
onChange={(e) => {
|
||||
connectionTestRequestRef.current += 1;
|
||||
const nextPublicKey = e.target.value;
|
||||
setPublicKey(nextPublicKey);
|
||||
if (
|
||||
secretConfigured &&
|
||||
nextPublicKey.trim() !== (connectionStatus?.publicKey ?? '')
|
||||
) {
|
||||
setIsEditingSecretKey(true);
|
||||
}
|
||||
setConnectionTestState('unverified');
|
||||
setConnectionTestMessage('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-private-token">{localize('com_ui_langfuse_secret_key')}</Label>
|
||||
{secretConfigured && !isEditingSecretKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-border-light px-3 py-2 text-left hover:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
aria-label={`${localize('com_ui_edit')} ${localize('com_ui_langfuse_secret_key')}`}
|
||||
disabled={busy}
|
||||
onClick={() => setIsEditingSecretKey(true)}
|
||||
>
|
||||
<code className="block min-w-0 truncate font-mono text-sm text-text-primary">
|
||||
{connectionStatus?.secretKeyPreview}
|
||||
</code>
|
||||
</button>
|
||||
)}
|
||||
{secretInputVisible && (
|
||||
<SecretInput
|
||||
ref={secretKeyInputRef}
|
||||
id="langfuse-private-token"
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
value={secretKey}
|
||||
disabled={busy}
|
||||
placeholder="sk-lf-..."
|
||||
onChange={(e) => {
|
||||
connectionTestRequestRef.current += 1;
|
||||
setSecretKey(e.target.value);
|
||||
setConnectionTestState('unverified');
|
||||
setConnectionTestMessage('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-9 items-center justify-end gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="outline" disabled={busy} onClick={handleCancel}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit || busy} onClick={handleSave}>
|
||||
{testMutation.isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="h-4 w-4" />
|
||||
{localize('com_ui_langfuse_testing')}
|
||||
</span>
|
||||
) : (
|
||||
localize('com_ui_langfuse_save_and_enable')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant={connectionStatus?.enabled === true ? 'outline' : 'submit'}
|
||||
disabled={
|
||||
busy || (connectionStatus?.enabled !== true && !connectionDestinationAvailable)
|
||||
}
|
||||
onClick={handleEnabledChange}
|
||||
>
|
||||
{localize(
|
||||
connectionStatus?.enabled === true
|
||||
? 'com_ui_langfuse_disable'
|
||||
: 'com_ui_langfuse_enable',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,619 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import LangfuseConnection from '../LangfuseConnection';
|
||||
|
||||
const mockGet = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
const mockTest = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
const destinationLabels = {
|
||||
eu: 'eu - https://cloud.langfuse.com',
|
||||
us: 'us - https://us.cloud.langfuse.com',
|
||||
};
|
||||
|
||||
async function selectDestination(destination: keyof typeof destinationLabels) {
|
||||
await userEvent.click(screen.getByTestId('langfuse-destination'));
|
||||
await userEvent.click(screen.getByRole('option', { name: destinationLabels[destination] }));
|
||||
}
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetLangfuseConnectionQuery: () => mockGet(),
|
||||
useUpdateLangfuseConnectionMutation: () => ({ mutate: mockUpdate, isLoading: false }),
|
||||
useTestLangfuseConnectionMutation: () => ({ mutate: mockTest, isLoading: false }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
global.ResizeObserver = class MockedResizeObserver {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
mockGet.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockTest.mockReset();
|
||||
mockRefetch.mockReset();
|
||||
mockTest.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({ success: true });
|
||||
});
|
||||
mockGet.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
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.getByTestId('langfuse-destination')).toHaveTextContent('com_ui_select');
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
|
||||
'data-lpignore',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
|
||||
'data-1p-ignore',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
|
||||
'data-form-type',
|
||||
'other',
|
||||
);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveAttribute(
|
||||
'data-bwignore',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
|
||||
'data-lpignore',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
|
||||
'data-1p-ignore',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
|
||||
'data-form-type',
|
||||
'other',
|
||||
);
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
|
||||
'data-bwignore',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute(
|
||||
'autocomplete',
|
||||
'off',
|
||||
);
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toHaveAttribute('type', 'password');
|
||||
expect(screen.getByRole('button', { name: 'Show secret' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_langfuse_test')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_configured')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_cancel' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_langfuse_save_and_enable' })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_langfuse_enable' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_langfuse_disable' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a loading state while the stored connection is loading', () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
expect(screen.getByTestId('langfuse-connection-loading')).toBeVisible();
|
||||
expect(screen.getByText('com_ui_loading')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_langfuse_status_not_configured')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a retryable error when the stored connection cannot be loaded', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
expect(screen.getByText('com_ui_langfuse_load_error')).toBeVisible();
|
||||
expect(screen.queryByText('com_ui_langfuse_status_not_configured')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
credential: 'public key',
|
||||
editButton: 'com_ui_edit com_ui_langfuse_public_key',
|
||||
inputLabel: 'com_ui_langfuse_public_key',
|
||||
value: 'pk-lf-updated',
|
||||
},
|
||||
{
|
||||
credential: 'secret key',
|
||||
editButton: 'com_ui_edit com_ui_langfuse_secret_key',
|
||||
inputLabel: 'com_ui_langfuse_secret_key',
|
||||
value: 'sk-lf-updated',
|
||||
},
|
||||
])(
|
||||
'keeps edited $credential unverified when an earlier automatic test completes',
|
||||
async ({ editButton, inputLabel, value }) => {
|
||||
let completeTest: ((result: { success: boolean }) => void) | undefined;
|
||||
mockTest.mockImplementation((_payload, options) => {
|
||||
completeTest = options?.onSuccess;
|
||||
});
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-original',
|
||||
secretKeyPreview: 'sk-lf-...inal',
|
||||
},
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(screen.getByRole('button', { name: editButton }));
|
||||
fireEvent.change(screen.getByLabelText(inputLabel), { target: { value } });
|
||||
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
|
||||
act(() => completeTest?.({ success: true }));
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
|
||||
expect(screen.queryByText('com_ui_langfuse_status_connected')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('prefills stored values, tests on load, and keeps destination editable', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-12345678-515f',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent(destinationLabels.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();
|
||||
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('langfuse-destination')).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_langfuse_disable' })).toBeEnabled();
|
||||
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('shows a failed saved-connection status when the load-time test fails', async () => {
|
||||
mockTest.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({ success: false, errorCode: 'invalid_credentials' });
|
||||
});
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('com_ui_langfuse_test_invalid_credentials')).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText('com_ui_langfuse_test_invalid_credentials').closest('div'),
|
||||
).toHaveAttribute('title', 'com_ui_langfuse_status_failed_hover');
|
||||
});
|
||||
|
||||
it('saves the typed secret key without a duplicate preflight test', async () => {
|
||||
render(<LangfuseConnection />);
|
||||
await selectDestination('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_langfuse_save_and_enable'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toEqual({
|
||||
enabled: true,
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the display secret key immediately after saving a new connection', async () => {
|
||||
mockUpdate.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...cret',
|
||||
});
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
await selectDestination('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_langfuse_save_and_enable'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText('com_ui_langfuse_secret_key')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('sk-lf-...cret')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires secret re-entry before saving a destination change', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
mockTest.mockClear();
|
||||
|
||||
await selectDestination('us');
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText(/com_ui_langfuse_secret_key/)).toBeVisible();
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_langfuse_save_and_enable')).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
|
||||
target: { value: 'sk-lf-replacement' },
|
||||
});
|
||||
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
|
||||
destination: 'us',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-replacement',
|
||||
});
|
||||
});
|
||||
|
||||
it('opens each configured key independently when its masked value is clicked', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: '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_public_key',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'com_ui_cancel' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_langfuse_save_and_enable' })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_langfuse_disable' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-1');
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveFocus();
|
||||
expect(
|
||||
screen.queryByLabelText(/com_ui_langfuse_secret_key/, { selector: 'input' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'com_ui_edit com_ui_langfuse_secret_key',
|
||||
}),
|
||||
);
|
||||
|
||||
const secretKeyInput = screen.getByLabelText(/com_ui_langfuse_secret_key/);
|
||||
expect(secretKeyInput).toHaveValue('');
|
||||
expect(secretKeyInput).toHaveClass('w-full');
|
||||
expect(secretKeyInput).toHaveFocus();
|
||||
fireEvent.change(secretKeyInput, {
|
||||
target: { value: 'sk-lf-replacement' },
|
||||
});
|
||||
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-replacement',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the stored connection when editing is cancelled', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [
|
||||
{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' },
|
||||
{ key: 'us', baseUrl: 'https://us.cloud.langfuse.com' },
|
||||
],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-original',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
|
||||
await selectDestination('us');
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'com_ui_edit com_ui_langfuse_public_key',
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
|
||||
target: { value: 'pk-lf-edited' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
|
||||
target: { value: 'sk-lf-edited' },
|
||||
});
|
||||
mockTest.mockImplementationOnce((_payload, options) => {
|
||||
options?.onSuccess?.({ success: true });
|
||||
});
|
||||
await userEvent.click(screen.getByRole('button', { name: 'com_ui_cancel' }));
|
||||
|
||||
expect(await screen.findByText('com_ui_langfuse_status_connected')).toBeVisible();
|
||||
expect(mockTest.mock.calls.at(-1)?.[0]).toEqual({
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-original',
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'com_ui_langfuse_disable' })).toBeEnabled();
|
||||
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent(destinationLabels.eu);
|
||||
expect(screen.getByText('pk-lf-...inal')).toBeInTheDocument();
|
||||
expect(screen.getByText('sk-lf-...515f')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a save failure when mandatory server verification rejects the connection', async () => {
|
||||
mockUpdate.mockImplementation((_payload, options) => {
|
||||
options?.onError?.();
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await selectDestination('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_langfuse_save_and_enable'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('com_ui_langfuse_save_error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('replaces a connected status with a failure when an edited public key is rejected', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-valid',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(screen.getByText('com_ui_langfuse_status_connected')).toBeVisible());
|
||||
mockTest.mockClear();
|
||||
mockUpdate.mockImplementation((_payload, options) => {
|
||||
options?.onError?.();
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'com_ui_edit com_ui_langfuse_public_key',
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
|
||||
target: { value: 'pk-lf-mangled' },
|
||||
});
|
||||
expect(screen.getByText('com_ui_langfuse_status_not_verified')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText(/com_ui_langfuse_secret_key/), {
|
||||
target: { value: 'sk-lf-replacement' },
|
||||
});
|
||||
await userEvent.click(screen.getByText('com_ui_langfuse_save_and_enable'));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publicKey: 'pk-lf-mangled',
|
||||
secretKey: 'sk-lf-replacement',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(screen.getByText('com_ui_langfuse_save_error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('saves immediately without testing when disabling a configured connection', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
mockTest.mockClear();
|
||||
mockUpdate.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
updatedAt: '2026-07-10T15:30:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'com_ui_langfuse_disable' }));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({
|
||||
enabled: false,
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
});
|
||||
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_langfuse_enable' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('allows disabling a connection whose saved destination was removed', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'removed-destination',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
expect(screen.getByTestId('langfuse-destination')).toHaveTextContent(
|
||||
'removed-destination - com_ui_langfuse_destination_unavailable',
|
||||
);
|
||||
expect(screen.getByText('com_ui_langfuse_destination_removed')).toBeVisible();
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'com_ui_langfuse_disable' }));
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
{
|
||||
enabled: false,
|
||||
destination: 'removed-destination',
|
||||
publicKey: 'pk-lf-1',
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves immediately without testing when enabling a configured connection', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: false,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
},
|
||||
});
|
||||
mockUpdate.mockImplementation((_payload, options) => {
|
||||
options?.onSuccess?.({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyPreview: 'sk-lf-...515f',
|
||||
updatedAt: '2026-07-10T15:31:00.000Z',
|
||||
});
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1));
|
||||
mockTest.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'com_ui_langfuse_enable' }));
|
||||
|
||||
expect(mockTest).not.toHaveBeenCalled();
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
{ enabled: true, destination: 'eu', publicKey: 'pk-lf-1' },
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(screen.queryByText('com_ui_langfuse_save_and_enable')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_langfuse_disable' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
45
client/src/data-provider/Langfuse/index.ts
Normal file
45
client/src/data-provider/Langfuse/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dataService, QueryKeys, MutationKeys } from 'librechat-data-provider';
|
||||
import type {
|
||||
TLangfuseConnectionStatus,
|
||||
TUpdateLangfuseConnectionRequest,
|
||||
TLangfuseConnectionTestRequest,
|
||||
TLangfuseConnectionTestResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export const useGetLangfuseConnectionQuery = (
|
||||
enabled = true,
|
||||
): UseQueryResult<TLangfuseConnectionStatus> =>
|
||||
useQuery<TLangfuseConnectionStatus>(
|
||||
[QueryKeys.langfuseConnection],
|
||||
() => dataService.getLangfuseConnection(),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
|
||||
TLangfuseConnectionStatus,
|
||||
unknown,
|
||||
TUpdateLangfuseConnectionRequest
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(payload: TUpdateLangfuseConnectionRequest) => dataService.updateLangfuseConnection(payload),
|
||||
{
|
||||
mutationKey: [MutationKeys.updateLangfuseConnection],
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.langfuseConnection], data);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useTestLangfuseConnectionMutation = (): UseMutationResult<
|
||||
TLangfuseConnectionTestResponse,
|
||||
unknown,
|
||||
TLangfuseConnectionTestRequest
|
||||
> =>
|
||||
useMutation(
|
||||
(payload: TLangfuseConnectionTestRequest) => dataService.testLangfuseConnection(payload),
|
||||
{ mutationKey: [MutationKeys.testLangfuseConnection] },
|
||||
);
|
||||
|
|
@ -3,6 +3,7 @@ export * from './Agents';
|
|||
export * from './Endpoints';
|
||||
export * from './Skills';
|
||||
export * from './Files';
|
||||
export * from './Langfuse';
|
||||
/* Memories */
|
||||
export * from './Memories';
|
||||
export * from './Messages';
|
||||
|
|
|
|||
|
|
@ -1730,6 +1730,36 @@
|
|||
"com_ui_settings_search_placeholder": "Search settings",
|
||||
"com_ui_settings_section_accessibility": "Accessibility",
|
||||
"com_ui_settings_section_api_keys": "API keys",
|
||||
"com_ui_langfuse_title": "Langfuse connection",
|
||||
"com_ui_langfuse_description": "Send this organization's traces and feedback scores to your own Langfuse project.",
|
||||
"com_ui_langfuse_beta_info": "This feature is in beta. Enabling this connection will send traces from all agents in your org to Langfuse.",
|
||||
"com_ui_langfuse_destination": "Destination",
|
||||
"com_ui_langfuse_destination_unavailable": "Unavailable",
|
||||
"com_ui_langfuse_destination_removed": "The saved Langfuse destination is no longer available. Disable the connection or select another destination.",
|
||||
"com_ui_langfuse_public_key": "Public key",
|
||||
"com_ui_langfuse_secret_key": "Secret key",
|
||||
"com_ui_langfuse_status_checking": "Checking connection",
|
||||
"com_ui_langfuse_status_connected": "Verified with Langfuse",
|
||||
"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_status_not_verified": "Not verified",
|
||||
"com_ui_langfuse_testing": "Testing connection",
|
||||
"com_ui_langfuse_save_and_enable": "Save & enable",
|
||||
"com_ui_langfuse_enable": "Enable",
|
||||
"com_ui_langfuse_disable": "Disable",
|
||||
"com_ui_langfuse_saved": "Langfuse connection saved",
|
||||
"com_ui_langfuse_load_error": "Failed to load the Langfuse connection",
|
||||
"com_ui_langfuse_save_error": "Failed to save the Langfuse connection",
|
||||
"com_ui_langfuse_test_error": "Could not connect to Langfuse",
|
||||
"com_ui_langfuse_test_invalid_credentials": "Langfuse rejected these keys. Check the destination and keys",
|
||||
"com_ui_langfuse_test_access_denied": "Langfuse denied access. Check the API key type and project status.",
|
||||
"com_ui_langfuse_test_rate_limited": "Langfuse is rate limiting verification. Try again later.",
|
||||
"com_ui_langfuse_test_server_error": "Langfuse is returning server errors. This may be a Langfuse incident.",
|
||||
"com_ui_langfuse_test_timeout": "Langfuse verification timed out",
|
||||
"com_ui_langfuse_test_missing_secret": "A secret key is required to test the connection",
|
||||
"com_ui_langfuse_test_stored_secret_unavailable": "The stored secret key could not be used",
|
||||
"com_ui_langfuse_test_unexpected_response": "Langfuse returned an unexpected response",
|
||||
"com_ui_settings_section_appearance": "Appearance",
|
||||
"com_ui_settings_section_billing": "Billing",
|
||||
"com_ui_settings_section_commands": "Commands",
|
||||
|
|
@ -1745,6 +1775,8 @@
|
|||
"com_ui_settings_section_sending": "Sending",
|
||||
"com_ui_settings_section_stt": "Speech to text",
|
||||
"com_ui_settings_section_tts": "Text to speech",
|
||||
"com_ui_settings_section_langfuse": "Langfuse",
|
||||
"com_ui_settings_tab_langfuse": "Langfuse",
|
||||
"com_ui_settings_tab_data": "Data & Privacy",
|
||||
"com_ui_share": "Share",
|
||||
"com_ui_share_create_message": "Your name and any messages you add after sharing stay private.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue