diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6d30330..51063b8 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -17,6 +17,8 @@ import { resetBaseConfigFn, baseConfigOptions, saveBaseConfigFn, + getLangfuseConnectionFn, + LANGFUSE_CONNECTION_QUERY_KEY, } from '@/server'; import { flattenObject, @@ -38,6 +40,7 @@ import { buildSavePayload, mergeIndexedArrayEdits, partitionScopeResetPaths, + withLangfuseConfiguredPath, } from './utils'; import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; @@ -303,7 +306,23 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi return Array.from(mapSecretPreviewPaths(scopeChangedPaths, schemaPathSet)); }, [scopeChangedPaths, schemaPathSet]); - const activeConfiguredPaths = isEditingScope ? scopeConfiguredPaths : configuredPaths; + const { data: langfuseConnection } = useQuery({ + queryKey: LANGFUSE_CONNECTION_QUERY_KEY, + queryFn: () => getLangfuseConnectionFn(), + enabled: + !isEditingScope && + schemaTree.some((section) => section.key === 'langfuse') && + sectionPermissions.langfuse?.canEdit === true, + retry: false, + refetchOnWindowFocus: false, + }); + + const baseConfiguredPaths = useMemo( + () => withLangfuseConfiguredPath(configuredPaths, langfuseConnection?.configured === true), + [configuredPaths, langfuseConnection?.configured], + ); + + const activeConfiguredPaths = isEditingScope ? scopeConfiguredPaths : baseConfiguredPaths; const tabConfiguredCounts = useMemo(() => { if (activeConfiguredPaths.size === 0) return {}; diff --git a/src/components/configuration/configMeta.ts b/src/components/configuration/configMeta.ts index 2d93958..7e4a609 100644 --- a/src/components/configuration/configMeta.ts +++ b/src/components/configuration/configMeta.ts @@ -98,6 +98,11 @@ export const SECTION_META: Record< descriptionKey: 'com_config_section_messageFilter_desc', tab: 'features', }, + langfuse: { + titleKey: 'com_config_section_langfuse', + descriptionKey: 'com_config_section_langfuse_desc', + tab: 'features', + }, fileConfig: { titleKey: 'com_config_section_file_config', diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx new file mode 100644 index 0000000..6bf966a --- /dev/null +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -0,0 +1,459 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Select, TextField } from '@clickhouse/click-ui'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type * as t from '@/types'; +import type { LangfuseConnectionStatus } from '@/server'; +import { + getLangfuseConnectionFn, + LANGFUSE_CONNECTION_QUERY_KEY, + testLangfuseConnectionFn, + updateLangfuseConnectionFn, +} from '@/server'; +import { notifyError, notifySuccess } from '@/utils'; +import { useLocalize } from '@/hooks'; + +type VerificationState = 'idle' | 'unverified' | 'checking' | 'verified' | 'failed'; + +function getConnectionKey(status?: LangfuseConnectionStatus): string | undefined { + if (!status?.configured || !status.destination || !status.publicKey) return undefined; + return `${status.destination}\u0000${status.publicKey}`; +} + +function maskPublicKey(publicKey: string): string { + const trimmed = publicKey.trim(); + if (trimmed.length <= 12) return trimmed; + return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`; +} + +function getVerificationLabel( + state: VerificationState, + message: string, + localize: ReturnType, +): string { + switch (state) { + case 'checking': + return localize('com_config_langfuse_checking'); + case 'verified': + return localize('com_config_langfuse_verified'); + case 'failed': + return message || localize('com_config_langfuse_test_fail'); + case 'unverified': + return localize('com_config_langfuse_not_verified'); + default: + return localize('com_config_langfuse_not_configured'); + } +} + +function getVerificationDotClass(state: VerificationState): string { + switch (state) { + case 'verified': + return 'bg-(--cui-color-accent-success)'; + case 'failed': + return 'bg-(--cui-color-accent-danger)'; + case 'checking': + return 'bg-(--cui-color-accent-warning)'; + default: + return 'border border-(--cui-color-stroke-default)'; + } +} + +export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererProps) { + const localize = useLocalize(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState(); + const [destination, setDestination] = useState(''); + const [publicKey, setPublicKey] = useState(''); + const [secretKey, setSecretKey] = useState(''); + const [editingPublicKey, setEditingPublicKey] = useState(false); + const [editingSecretKey, setEditingSecretKey] = useState(false); + const [verificationState, setVerificationState] = useState('idle'); + const [verificationMessage, setVerificationMessage] = useState(''); + const testedConnectionRef = useRef(undefined); + const requestRef = useRef(0); + const hasDraftRef = useRef(false); + + const connectionQuery = useQuery({ + queryKey: LANGFUSE_CONNECTION_QUERY_KEY, + queryFn: () => getLangfuseConnectionFn(), + enabled: !isEditingScope, + retry: false, + refetchOnWindowFocus: false, + }); + const updateMutation = useMutation({ + mutationFn: (data: { + enabled: boolean; + destination: string; + publicKey: string; + secretKey?: string; + }) => updateLangfuseConnectionFn({ data }), + }); + const testMutation = useMutation({ + mutationFn: (data: { destination: string; publicKey: string; secretKey?: string }) => + testLangfuseConnectionFn({ data }), + }); + + useEffect(() => { + if (!connectionQuery.data) return; + if (hasDraftRef.current) return; + const nextStatus = connectionQuery.data; + setStatus(nextStatus); + // Preserve the stored destination for display even when the server dropped it from the + // allowlist. Blanking it made destinationChanged true, forcing edit mode and leaving an + // enabled connection impossible to disable until a replacement was picked; a de-allowlisted + // destination now simply shows as unselected in the picker while disable stays available. + setDestination(nextStatus.destination ?? ''); + setPublicKey(nextStatus.publicKey ?? ''); + }, [connectionQuery.data]); + + useEffect(() => { + const connectionKey = getConnectionKey(status); + if (!connectionKey) { + setVerificationState('idle'); + setVerificationMessage(''); + return; + } + // Read-only viewers lack manage:configs:langfuse and cannot run verification. Show the stored + // connection as unverified rather than "not configured", and clear the in-flight and + // tested-connection markers so switching back to editable re-verifies from scratch. + if (disabled) { + requestRef.current += 1; + testedConnectionRef.current = undefined; + setVerificationState('unverified'); + setVerificationMessage(''); + return; + } + if (testedConnectionRef.current === connectionKey) return; + + testedConnectionRef.current = connectionKey; + const requestId = ++requestRef.current; + setVerificationState('checking'); + setVerificationMessage(''); + testMutation.mutate( + { destination: status?.destination ?? '', publicKey: status?.publicKey ?? '' }, + { + onSuccess: (result) => { + if (requestId !== requestRef.current) return; + testedConnectionRef.current = connectionKey; + setVerificationState(result.success ? 'verified' : 'failed'); + setVerificationMessage(result.success ? '' : (result.message ?? '')); + }, + onError: (error: Error) => { + if (requestId !== requestRef.current) return; + if (testedConnectionRef.current === connectionKey) { + testedConnectionRef.current = undefined; + } + setVerificationState('failed'); + setVerificationMessage(error.message); + }, + }, + ); + }, [status, connectionQuery.dataUpdatedAt, disabled]); + + if (isEditingScope) { + return ( +

+ {localize('com_config_langfuse_tenant_wide')} +

+ ); + } + + if (connectionQuery.isPending) { + return

{localize('com_ui_loading')}

; + } + + if (connectionQuery.isError) { + return ( +

+ {connectionQuery.error.message} +

+ ); + } + + const configured = status?.configured === true; + const trimmedPublicKey = publicKey.trim(); + const trimmedSecretKey = secretKey.trim(); + const destinationChanged = destination !== (status?.destination ?? ''); + const publicKeyChanged = trimmedPublicKey !== (status?.publicKey ?? ''); + const isEditing = + !configured || + editingPublicKey || + editingSecretKey || + destinationChanged || + publicKeyChanged || + trimmedSecretKey !== ''; + const canSave = + !disabled && + destination !== '' && + trimmedPublicKey !== '' && + (configured || trimmedSecretKey !== ''); + const busy = updateMutation.isPending || testMutation.isPending; + + const markDraftUnverified = () => { + hasDraftRef.current = true; + requestRef.current += 1; + setVerificationState('unverified'); + setVerificationMessage(''); + }; + + const verify = ( + nextDestination: string, + nextPublicKey: string, + nextSecretKey: string, + onVerified?: () => void, + ) => { + const requestId = ++requestRef.current; + if (!nextDestination || !nextPublicKey || (!configured && !nextSecretKey)) { + setVerificationState('idle'); + setVerificationMessage(''); + return; + } + + setVerificationState('checking'); + setVerificationMessage(''); + testMutation.mutate( + { + destination: nextDestination, + publicKey: nextPublicKey, + ...(nextSecretKey ? { secretKey: nextSecretKey } : {}), + }, + { + onSuccess: (result) => { + if (requestId !== requestRef.current) return; + setVerificationState(result.success ? 'verified' : 'failed'); + setVerificationMessage(result.success ? '' : (result.message ?? '')); + if (result.success) onVerified?.(); + }, + onError: (error: Error) => { + if (requestId !== requestRef.current) return; + setVerificationState('failed'); + setVerificationMessage(error.message); + }, + }, + ); + }; + + const saveConnection = () => { + const payload = { + // Credential edits are committed through the explicit "Save & enable" action. + enabled: true, + destination, + publicKey: trimmedPublicKey, + ...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}), + }; + updateMutation.mutate(payload, { + onSuccess: (nextStatus) => { + hasDraftRef.current = false; + queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, nextStatus); + testedConnectionRef.current = getConnectionKey(nextStatus); + setStatus(nextStatus); + setDestination(nextStatus.destination ?? ''); + setPublicKey(nextStatus.publicKey ?? ''); + setSecretKey(''); + setEditingPublicKey(false); + setEditingSecretKey(false); + notifySuccess(localize('com_config_langfuse_saved')); + }, + onError: (error: Error) => notifyError(error.message), + }); + }; + + const handleSave = () => { + verify(destination, trimmedPublicKey, trimmedSecretKey, saveConnection); + }; + + const handleCancel = () => { + hasDraftRef.current = false; + const latestStatus = + queryClient.getQueryData(LANGFUSE_CONNECTION_QUERY_KEY) ?? status; + setStatus(latestStatus); + const storedDestination = latestStatus?.destination; + setDestination(storedDestination ?? ''); + setPublicKey(latestStatus?.publicKey ?? ''); + setSecretKey(''); + setEditingPublicKey(false); + setEditingSecretKey(false); + if (latestStatus?.configured && storedDestination && latestStatus.publicKey) { + verify(storedDestination, latestStatus.publicKey, ''); + } else { + setVerificationState('idle'); + setVerificationMessage(''); + } + }; + + const handleEnabledChange = () => { + if (!configured || !status?.destination || !status.publicKey) return; + + const nextEnabled = status.enabled !== true; + updateMutation.mutate( + { + enabled: nextEnabled, + destination: status.destination, + publicKey: status.publicKey, + }, + { + onSuccess: (nextStatus) => { + queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, nextStatus); + testedConnectionRef.current = getConnectionKey(nextStatus); + setStatus(nextStatus); + notifySuccess(localize('com_config_langfuse_saved')); + }, + onError: (error: Error) => notifyError(error.message), + }, + ); + }; + + const statusLabel = getVerificationLabel(verificationState, verificationMessage, localize); + const statusDotClass = getVerificationDotClass(verificationState); + + return ( +
+
+
+ {localize('com_config_langfuse_enabled')} + + {localize('com_config_langfuse_beta')} + +
+ + {localize('com_config_langfuse_description')} + +
+ +
+ + {statusLabel} +
+ + + +
+ {localize('com_config_langfuse_public_key')} + {configured && !editingPublicKey ? ( + + ) : ( + { + setPublicKey(value); + markDraftUnverified(); + }} + /> + )} +
+ +
+ {localize('com_config_langfuse_secret_key')} + {configured && !editingSecretKey ? ( + + ) : ( + { + setSecretKey(value); + markDraftUnverified(); + }} + /> + )} +
+ +
+ {isEditing ? ( + <> +
+
+ ); +} diff --git a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx new file mode 100644 index 0000000..0b7ae90 --- /dev/null +++ b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx @@ -0,0 +1,511 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type * as t from '@/types'; +import { LangfuseRenderer } from '../LangfuseRenderer'; +import { + getLangfuseConnectionFn, + LANGFUSE_CONNECTION_QUERY_KEY, + testLangfuseConnectionFn, + updateLangfuseConnectionFn, +} from '@/server'; + +vi.mock('@/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +vi.mock('@/utils', () => ({ + notifySuccess: vi.fn(), + notifyError: vi.fn(), + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), +})); + +vi.mock('@/server', () => ({ + LANGFUSE_CONNECTION_QUERY_KEY: ['adminLangfuseConnection'], + getLangfuseConnectionFn: vi.fn(), + testLangfuseConnectionFn: vi.fn(), + updateLangfuseConnectionFn: vi.fn(), +})); + +interface TextFieldProps { + label?: string; + value?: string; + placeholder?: string; + disabled?: boolean; + onChange?: (value: string) => void; +} + +interface ButtonProps { + label?: string; + disabled?: boolean; + onClick?: () => void; +} + +interface SelectProps { + label?: string; + value?: string; + placeholder?: string; + disabled?: boolean; + onSelect?: (value: string) => void; + children?: React.ReactNode; +} + +vi.mock('@clickhouse/click-ui', () => ({ + Badge: ({ text }: { text: string }) => {text}, + Button: ({ label, disabled, onClick }: ButtonProps) => ( + + ), + Select: Object.assign( + ({ label, value, placeholder, disabled, onSelect, children }: SelectProps) => ( + + ), + { + Item: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ), + }, + ), + TextField: ({ label, value, placeholder, disabled, onChange }: TextFieldProps) => ( + onChange?.(event.target.value)} + /> + ), + Icon: () => null, +})); + +const mockGet = vi.mocked(getLangfuseConnectionFn); +const mockTest = vi.mocked(testLangfuseConnectionFn); +const mockUpdate = vi.mocked(updateLangfuseConnectionFn); +const destinations = [ + { key: 'eu', baseUrl: 'https://cloud.langfuse.com' }, + { key: 'us', baseUrl: 'https://us.cloud.langfuse.com' }, +]; + +function renderLangfuse(overrides: Partial = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const props: t.FieldRendererProps = { + fields: [], + parentValue: {}, + parentPath: 'langfuse', + getValue: (_path, fallback) => fallback, + onChange: vi.fn(), + ...overrides, + }; + const result = render( + + + , + ); + return { ...result, queryClient }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockResolvedValue({ configured: false, enabled: false, destinations }); + mockTest.mockResolvedValue({ success: true }); +}); + +describe('LangfuseRenderer', () => { + it('loads the deployment-approved destinations from LibreChat', async () => { + renderLangfuse(); + const destination = await screen.findByLabelText('com_config_langfuse_destination'); + expect(destination).toHaveValue(''); + expect(screen.getByRole('option', { name: 'eu - https://cloud.langfuse.com' })).toBeVisible(); + expect( + screen.getByRole('option', { name: 'us - https://us.cloud.langfuse.com' }), + ).toBeVisible(); + expect(screen.getByPlaceholderText('pk-lf-...')).toBeVisible(); + expect(screen.getByPlaceholderText('sk-lf-...')).toBeVisible(); + expect(screen.getByRole('button', { name: 'com_ui_cancel' })).toBeVisible(); + expect( + screen.getByRole('button', { name: 'com_config_langfuse_save_and_enable' }), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'com_config_langfuse_enable' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'com_config_langfuse_disable' }), + ).not.toBeInTheDocument(); + }); + + it('shows masked keys and verifies a configured connection on load', async () => { + mockGet.mockResolvedValue({ + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-1234567890abcdef', + displaySecretKey: 'sk-lf-...515f', + }); + renderLangfuse(); + + expect(await screen.findByText('pk-lf-...cdef')).toBeVisible(); + expect(screen.getByText('sk-lf-...515f')).toBeVisible(); + await waitFor(() => + expect(mockTest).toHaveBeenCalledWith({ + data: { destination: 'eu', publicKey: 'pk-lf-1234567890abcdef' }, + }), + ); + expect(await screen.findByText('com_config_langfuse_verified')).toBeVisible(); + }); + + it('shows a configured connection as unverified without verifying it for read-only (disabled) viewers', async () => { + mockGet.mockResolvedValue({ + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-1234567890abcdef', + displaySecretKey: 'sk-lf-...515f', + }); + renderLangfuse({ disabled: true }); + + expect(await screen.findByText('pk-lf-...cdef')).toBeVisible(); + expect(await screen.findByText('com_config_langfuse_not_verified')).toBeVisible(); + expect(screen.queryByText('com_config_langfuse_not_configured')).not.toBeInTheDocument(); + expect(mockTest).not.toHaveBeenCalled(); + }); + + it('keeps the disable action available when the stored destination is no longer allowlisted', async () => { + mockGet.mockResolvedValue({ + configured: true, + enabled: true, + destinations, + destination: 'removed-region', + publicKey: 'pk-lf-1234567890abcdef', + displaySecretKey: 'sk-lf-...515f', + }); + renderLangfuse(); + + expect(await screen.findByRole('button', { name: 'com_config_langfuse_disable' })).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'com_config_langfuse_save_and_enable' }), + ).not.toBeInTheDocument(); + }); + + it('verifies then saves a new connection through the dedicated API', async () => { + mockUpdate.mockResolvedValue({ + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-new', + displaySecretKey: 'sk-lf-...cret', + }); + renderLangfuse(); + + fireEvent.change(await screen.findByLabelText('com_config_langfuse_destination'), { + target: { value: 'eu' }, + }); + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-new' }, + }); + fireEvent.change(screen.getByPlaceholderText('sk-lf-...'), { + target: { value: 'sk-lf-secret' }, + }); + const saveButton = screen.getByRole('button', { + name: 'com_config_langfuse_save_and_enable', + }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => + expect(mockTest).toHaveBeenLastCalledWith({ + data: { destination: 'eu', publicKey: 'pk-lf-new', secretKey: 'sk-lf-secret' }, + }), + ); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { + enabled: true, + destination: 'eu', + publicKey: 'pk-lf-new', + secretKey: 'sk-lf-secret', + }, + }), + ); + }); + + it('preserves the stored secret when only the public key is edited', async () => { + const configuredStatus = { + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-old', + displaySecretKey: 'sk-lf-...515f', + }; + mockGet.mockResolvedValue(configuredStatus); + mockUpdate.mockResolvedValue({ ...configuredStatus, publicKey: 'pk-lf-new' }); + const { queryClient } = renderLangfuse(); + + fireEvent.click( + await screen.findByRole('button', { name: 'com_ui_edit com_config_langfuse_public_key' }), + ); + expect(screen.getByRole('button', { name: 'com_ui_cancel' })).toBeVisible(); + expect( + screen.getByRole('button', { name: 'com_config_langfuse_save_and_enable' }), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'com_config_langfuse_disable' }), + ).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-new' }, + }); + expect(screen.getByText('com_config_langfuse_not_verified')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_save_and_enable' })); + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { enabled: true, destination: 'eu', publicKey: 'pk-lf-new' }, + }), + ); + await waitFor(() => + expect(queryClient.getQueryData(LANGFUSE_CONNECTION_QUERY_KEY)).toEqual({ + ...configuredStatus, + publicKey: 'pk-lf-new', + }), + ); + }); + + it('preserves draft fields when shared connection data refreshes', async () => { + const configuredStatus = { + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-stored', + displaySecretKey: 'sk-lf-...515f', + }; + mockGet.mockResolvedValue(configuredStatus); + const { queryClient } = renderLangfuse(); + expect(await screen.findByText('com_config_langfuse_verified')).toBeVisible(); + expect(mockTest).toHaveBeenCalledTimes(1); + + fireEvent.click( + await screen.findByRole('button', { name: 'com_ui_edit com_config_langfuse_public_key' }), + ); + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-draft' }, + }); + + act(() => { + queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, { + ...configuredStatus, + destination: 'us', + publicKey: 'pk-lf-refetched', + }); + }); + + expect(screen.getByPlaceholderText('pk-lf-...')).toHaveValue('pk-lf-draft'); + expect(screen.getByLabelText('com_config_langfuse_destination')).toHaveValue('eu'); + expect(screen.getByText('com_config_langfuse_not_verified')).toBeVisible(); + expect(mockTest).toHaveBeenCalledTimes(1); + }); + + it('retries a transient mount verification failure after the connection refreshes', async () => { + const configuredStatus = { + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-stored', + displaySecretKey: 'sk-lf-...515f', + }; + mockGet.mockResolvedValue(configuredStatus); + mockTest.mockRejectedValueOnce(new Error('Langfuse is temporarily unavailable')); + mockTest.mockResolvedValueOnce({ success: true }); + const { queryClient } = renderLangfuse(); + + expect(await screen.findByText('Langfuse is temporarily unavailable')).toBeVisible(); + expect(mockTest).toHaveBeenCalledTimes(1); + + act(() => { + queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, { ...configuredStatus }); + }); + + expect(await screen.findByText('com_config_langfuse_verified')).toBeVisible(); + expect(mockTest).toHaveBeenCalledTimes(2); + }); + + it('ignores an in-flight verification result after a key edit', async () => { + let resolveVerification: ((result: { success: boolean }) => void) | undefined; + mockTest.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + renderLangfuse(); + + fireEvent.change(await screen.findByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-old' }, + }); + fireEvent.change(screen.getByPlaceholderText('sk-lf-...'), { + target: { value: 'sk-lf-secret' }, + }); + fireEvent.change(screen.getByLabelText('com_config_langfuse_destination'), { + target: { value: 'eu' }, + }); + await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-new' }, + }); + expect(screen.getByText('com_config_langfuse_not_verified')).toBeVisible(); + + await act(async () => resolveVerification?.({ success: true })); + + expect(screen.getByText('com_config_langfuse_not_verified')).toBeVisible(); + expect(screen.queryByText('com_config_langfuse_verified')).not.toBeInTheDocument(); + }); + + it('disables a saved connection without re-verifying credentials', async () => { + const configuredStatus = { + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-existing', + displaySecretKey: 'sk-lf-...515f', + }; + mockGet.mockResolvedValue(configuredStatus); + mockUpdate.mockResolvedValue({ ...configuredStatus, enabled: false }); + renderLangfuse(); + await screen.findByText('sk-lf-...515f'); + await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1)); + mockTest.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_disable' })); + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { enabled: false, destination: 'eu', publicKey: 'pk-lf-existing' }, + }), + ); + expect(mockTest).not.toHaveBeenCalled(); + expect(await screen.findByRole('button', { name: 'com_config_langfuse_enable' })).toBeEnabled(); + }); + + it('enables a saved connection without re-verifying credentials', async () => { + const configuredStatus = { + configured: true, + enabled: false, + destinations, + destination: 'eu', + publicKey: 'pk-lf-existing', + displaySecretKey: 'sk-lf-...515f', + }; + mockGet.mockResolvedValue(configuredStatus); + mockUpdate.mockResolvedValue({ ...configuredStatus, enabled: true }); + renderLangfuse(); + await screen.findByText('sk-lf-...515f'); + await waitFor(() => expect(mockTest).toHaveBeenCalledTimes(1)); + mockTest.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_enable' })); + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { enabled: true, destination: 'eu', publicKey: 'pk-lf-existing' }, + }), + ); + expect(mockTest).not.toHaveBeenCalled(); + expect( + await screen.findByRole('button', { name: 'com_config_langfuse_disable' }), + ).toBeEnabled(); + }); + + it('enables a disabled connection when credential edits are saved with Save & enable', async () => { + const configuredStatus = { + configured: true, + enabled: false, + destinations, + destination: 'eu', + publicKey: 'pk-lf-existing', + displaySecretKey: 'sk-lf-...515f', + }; + mockGet.mockResolvedValue(configuredStatus); + mockUpdate.mockResolvedValue({ ...configuredStatus, enabled: true, publicKey: 'pk-lf-new' }); + renderLangfuse(); + await screen.findByText('sk-lf-...515f'); + + fireEvent.click( + screen.getByRole('button', { name: 'com_ui_edit com_config_langfuse_public_key' }), + ); + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-new' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_save_and_enable' })); + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { enabled: true, destination: 'eu', publicKey: 'pk-lf-new' }, + }), + ); + }); + + it('re-verifies the stored connection when an invalid key edit is cancelled', async () => { + mockGet.mockResolvedValue({ + configured: true, + enabled: true, + destinations, + destination: 'eu', + publicKey: 'pk-lf-existing', + displaySecretKey: 'sk-lf-...515f', + }); + renderLangfuse(); + await screen.findByText('com_config_langfuse_verified'); + mockTest.mockResolvedValueOnce({ success: false, message: 'invalid keys' }); + + fireEvent.click( + screen.getByRole('button', { name: 'com_ui_edit com_config_langfuse_public_key' }), + ); + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-invalid' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_save_and_enable' })); + expect(await screen.findByText('invalid keys')).toBeVisible(); + + mockTest.mockResolvedValueOnce({ success: true }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_cancel' })); + + expect(await screen.findByText('com_config_langfuse_verified')).toBeVisible(); + expect(mockTest).toHaveBeenLastCalledWith({ + data: { destination: 'eu', publicKey: 'pk-lf-existing' }, + }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('does not expose tenant-wide connection controls in a scoped editor', () => { + renderLangfuse({ isEditingScope: true }); + expect(screen.getByText('com_config_langfuse_tenant_wide')).toBeVisible(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('disables actions when the section is read-only', async () => { + renderLangfuse({ disabled: true }); + + expect(await screen.findByRole('button', { name: 'com_ui_cancel' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'com_config_langfuse_save_and_enable' }), + ).toBeDisabled(); + }); +}); diff --git a/src/components/configuration/sections/index.ts b/src/components/configuration/sections/index.ts index f3837de..6afbc44 100644 --- a/src/components/configuration/sections/index.ts +++ b/src/components/configuration/sections/index.ts @@ -13,12 +13,14 @@ import type React from 'react'; import type * as t from '@/types'; import { CustomEndpointsRenderer, ProvidersRenderer } from './EndpointsRenderer'; import { McpServersRenderer } from './McpServersRenderer'; +import { LangfuseRenderer } from './LangfuseRenderer'; export const SECTION_RENDERERS: Partial>> = { endpoints: CustomEndpointsRenderer, endpointsProviders: ProvidersRenderer, mcpServers: McpServersRenderer, + langfuse: LangfuseRenderer, }; /** Sections whose custom renderer handles its own accordion — they are rendered diff --git a/src/components/configuration/utils.test.ts b/src/components/configuration/utils.test.ts index 469644d..62dbd17 100644 --- a/src/components/configuration/utils.test.ts +++ b/src/components/configuration/utils.test.ts @@ -9,6 +9,7 @@ import { mergeIndexedArrayEdits, buildSavePayload, applyConfigEdit, + withLangfuseConfiguredPath, } from './utils'; import { createField } from '@/test/fixtures'; import { flattenObject } from '@/utils'; @@ -223,6 +224,23 @@ describe('splitUnionTypes', () => { }); }); +describe('withLangfuseConfiguredPath', () => { + it('includes a configured dedicated connection without mutating base paths', () => { + const basePaths = new Set(['interface.theme']); + + const paths = withLangfuseConfiguredPath(basePaths, true); + + expect(paths).toEqual(new Set(['interface.theme', 'langfuse.enabled'])); + expect(basePaths).toEqual(new Set(['interface.theme'])); + }); + + it('does not mark an unconfigured connection', () => { + expect(withLangfuseConfiguredPath(new Set(['interface.theme']), false)).toEqual( + new Set(['interface.theme']), + ); + }); +}); + describe('getControlType — union(literal(...)) as select', () => { it('returns select for union of literals', () => { const field = createField({ diff --git a/src/components/configuration/utils.ts b/src/components/configuration/utils.ts index d858cec..c6f37e4 100644 --- a/src/components/configuration/utils.ts +++ b/src/components/configuration/utils.ts @@ -250,6 +250,16 @@ export function hasDescendant(path: string, paths?: Set): boolean { return false; } +/** Include the dedicated Langfuse connection in generic configured-state UI. */ +export function withLangfuseConfiguredPath( + configuredPaths: Set, + configured: boolean, +): Set { + const paths = new Set(configuredPaths); + if (configured) paths.add('langfuse.enabled'); + return paths; +} + export function isMcpEntryPath(path: string): boolean { if (!path.startsWith('mcpServers.')) return false; const key = path.slice('mcpServers.'.length); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a00cd99..e392295 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -766,6 +766,25 @@ "com_config_section_includedTools_desc": "Tools explicitly included for availability", "com_config_section_messageFilter": "Message filter", "com_config_section_messageFilter_desc": "Filter or block patterns in user messages", + "com_config_section_langfuse": "Langfuse", + "com_config_section_langfuse_desc": "Connect this tenant to a Langfuse project to export traces and feedback scores", + "com_config_langfuse_enabled": "Langfuse connection", + "com_config_langfuse_description": "Export traces and feedback scores from all agents in this organization.", + "com_config_langfuse_beta": "Beta", + "com_config_langfuse_destination": "Destination", + "com_config_langfuse_select_destination": "Select a destination", + "com_config_langfuse_public_key": "Public key", + "com_config_langfuse_secret_key": "Secret key", + "com_config_langfuse_checking": "Verifying with Langfuse...", + "com_config_langfuse_save_and_enable": "Save & enable", + "com_config_langfuse_enable": "Enable", + "com_config_langfuse_disable": "Disable", + "com_config_langfuse_verified": "Verified with Langfuse", + "com_config_langfuse_not_configured": "Not configured", + "com_config_langfuse_not_verified": "Not verified", + "com_config_langfuse_test_fail": "Could not connect to Langfuse", + "com_config_langfuse_saved": "Langfuse connection saved", + "com_config_langfuse_tenant_wide": "Langfuse connection is tenant-wide and can only be managed from the base configuration.", "com_config_section_secureImageLinks": "Secure image links", "com_config_section_secureImageLinks_desc": "Enable secure, authenticated image URL delivery", "com_config_section_skillSync": "Skill sync", diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 931bc7a..3361e67 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -1,13 +1,6 @@ import { createRequire } from 'module'; import { describe, it, expect } from 'vitest'; import type * as t from '@/types'; -import { - coerceEnumValue, - getControlType, - getEnumOptions, - getArrayItemType, - splitUnionTypes, -} from '@/components/configuration/utils'; import { extractSchemaTree, getZodTypeName, @@ -19,7 +12,15 @@ import { normalizeAppServiceKeys, mergeConfigArraySources, mergeIndexedArrayEntriesIntoBase, + applyLangfuseSchemaVisibility, } from './config'; +import { + coerceEnumValue, + getControlType, + getEnumOptions, + getArrayItemType, + splitUnionTypes, +} from '@/components/configuration/utils'; interface ZodV3Schema extends t.ZodSchemaLike { object: (shape: Record) => ZodV3Schema; @@ -77,6 +78,57 @@ function findField(fields: t.SchemaField[], key: string): t.SchemaField | undefi return undefined; } +describe('applyLangfuseSchemaVisibility', () => { + it('injects the Langfuse section when fanout is enabled and the pinned schema lacks it', () => { + const tree = extractSchemaTree(z3.object({ interface: z3.object({ theme: z3.string() }) })); + + applyLangfuseSchemaVisibility(tree, true); + + expect(findField(tree, 'langfuse')).toMatchObject({ + key: 'langfuse', + isObject: true, + }); + expect(findField(tree, 'destination')?.path).toBe('langfuse.destination'); + }); + + it('removes a Langfuse section supplied by the schema when fanout is disabled', () => { + const tree = extractSchemaTree(z3.object({ langfuse: z3.object({ enabled: z3.boolean() }) })); + + applyLangfuseSchemaVisibility(tree, false); + + expect(findField(tree, 'langfuse')).toBeUndefined(); + }); + + it('preserves the schema-provided Langfuse section when fanout is enabled', () => { + const tree = extractSchemaTree( + z3.object({ langfuse: z3.object({ schemaOnlyField: z3.string() }) }), + ); + + applyLangfuseSchemaVisibility(tree, true); + + expect(findField(tree, 'schemaOnlyField')).toBeDefined(); + expect(tree.filter((field) => field.key === 'langfuse')).toHaveLength(1); + }); + + it('preserves a schema-provided Langfuse section when fanout state is unknown', () => { + const tree = extractSchemaTree( + z3.object({ langfuse: z3.object({ schemaOnlyField: z3.string() }) }), + ); + + applyLangfuseSchemaVisibility(tree, undefined); + + expect(findField(tree, 'schemaOnlyField')).toBeDefined(); + }); + + it('does not inject the compatibility shim when fanout state is unknown', () => { + const tree = extractSchemaTree(z3.object({ interface: z3.object({ theme: z3.string() }) })); + + applyLangfuseSchemaVisibility(tree, undefined); + + expect(findField(tree, 'langfuse')).toBeUndefined(); + }); +}); + describe('extractSchemaTree', () => { it('extracts basic scalar types', () => { const schema = z3.object({ diff --git a/src/server/config.ts b/src/server/config.ts index 3263515..d5dbd74 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; import yaml from 'js-yaml'; import { queryOptions } from '@tanstack/react-query'; -import { configSchema } from 'librechat-data-provider'; import { createServerFn } from '@tanstack/react-start'; +import { configSchema } from 'librechat-data-provider'; import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; import type { AdminConfigResponse } from '@librechat/data-schemas'; import type * as t from '@/types'; @@ -22,6 +22,52 @@ import { safeFieldPath } from './utils/validation'; import { flattenObject } from '@/utils/format'; import { apiFetch } from './utils/api'; +/** + * Forward-compat shim: the pinned `librechat-data-provider@^0.8.509` predates the + * `langfuse` config group. Inject the section node so the custom renderer remains + * discoverable until a data-provider release containing the group is pinned. The + * renderer persists through LibreChat's dedicated Langfuse connection API. + */ +const LANGFUSE_SHIM_FIELD: t.SchemaField = { + path: 'langfuse', + key: 'langfuse', + type: 'object', + isOptional: true, + isNullable: false, + isArray: false, + isObject: true, + depth: 0, + children: (['enabled', 'destination', 'publicKey', 'secretKey', 'displaySecretKey'] as const).map( + (key) => ({ + path: `langfuse.${key}`, + key, + type: key === 'enabled' ? 'boolean' : 'string', + isOptional: true, + isNullable: false, + isArray: false, + isObject: false, + depth: 1, + }), + ), +}; + +export function applyLangfuseSchemaVisibility( + tree: t.SchemaField[], + fanoutEnabled: boolean | undefined, +): t.SchemaField[] { + const langfuseIndex = tree.findIndex((section) => section.key === 'langfuse'); + if (fanoutEnabled === false) { + if (langfuseIndex >= 0) { + tree.splice(langfuseIndex, 1); + } + return tree; + } + if (fanoutEnabled === true && langfuseIndex < 0) { + tree.push(LANGFUSE_SHIM_FIELD); + } + return tree; +} + const WRAPPER_TYPES = new Set([ 'ZodOptional', 'ZodDefault', @@ -653,6 +699,11 @@ export const configSchemaTreeOptions = queryOptions({ export const getConfigSchemaFields = createServerFn({ method: 'GET' }).handler(async () => { try { const tree = extractSchemaTree(configSchema); + const startupConfigResponse = await apiFetch('/api/config'); + const startupConfig = startupConfigResponse.ok + ? ((await startupConfigResponse.json()) as { langfuseFanoutEnabled?: boolean }) + : undefined; + applyLangfuseSchemaVisibility(tree, startupConfig?.langfuseFanoutEnabled); for (const section of tree) { if (section.key === 'interface' && section.children) { section.children = filterInterfacePermissionChildren(section.children); diff --git a/src/server/index.ts b/src/server/index.ts index 25f28eb..ad8cf1d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,6 +2,7 @@ export * from './auth'; export * from './capabilities'; export * from './config'; export * from './groups'; +export * from './langfuse'; export * from './roles'; export * from './scopes'; export * from './users'; diff --git a/src/server/langfuse.test.ts b/src/server/langfuse.test.ts new file mode 100644 index 0000000..159c17f --- /dev/null +++ b/src/server/langfuse.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetchMock = vi.fn(); +const requireAllSectionCapabilitiesMock = vi.fn(); + +vi.mock('./utils/api', () => ({ + apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init), + extractApiError: vi.fn(async (_response: Response, message: string) => { + throw new Error(message); + }), +})); + +vi.mock('./capabilities', () => ({ + requireAllSectionCapabilities: (sections: string[]) => + requireAllSectionCapabilitiesMock(sections), +})); + +vi.mock('@tanstack/react-start', () => ({ + createServerFn: () => ({ + handler: (fn: (...args: never[]) => unknown) => fn, + inputValidator: () => ({ + handler: (fn: (...args: never[]) => unknown) => fn, + }), + }), +})); + +import { + getLangfuseConnectionFn, + testLangfuseConnectionFn, + updateLangfuseConnectionFn, +} from './langfuse'; + +const status = { + configured: true, + enabled: true, + destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }], + destination: 'eu', + publicKey: 'pk-lf-public', + displaySecretKey: 'sk-lf-...515f', +}; + +beforeEach(() => { + vi.clearAllMocks(); + requireAllSectionCapabilitiesMock.mockResolvedValue(undefined); +}); + +describe('Langfuse connection server functions', () => { + it('reads connection status through LibreChat', async () => { + apiFetchMock.mockResolvedValue(new Response(JSON.stringify(status), { status: 200 })); + + await expect(getLangfuseConnectionFn()).resolves.toEqual(status); + expect(requireAllSectionCapabilitiesMock).toHaveBeenCalledWith(['langfuse']); + expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/langfuse/connection', undefined); + }); + + it('updates the connection without exposing or reconstructing a stored secret', async () => { + apiFetchMock.mockResolvedValue(new Response(JSON.stringify(status), { status: 200 })); + const data = { enabled: false, destination: 'eu', publicKey: 'pk-lf-public' }; + + await expect(updateLangfuseConnectionFn({ data })).resolves.toEqual(status); + expect(requireAllSectionCapabilitiesMock).toHaveBeenCalledWith(['langfuse']); + expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/langfuse/connection', { + method: 'PUT', + body: JSON.stringify(data), + }); + }); + + it('delegates credential verification to LibreChat', async () => { + apiFetchMock.mockResolvedValue( + new Response(JSON.stringify({ success: false, message: 'Langfuse rejected these keys' }), { + status: 200, + }), + ); + const data = { destination: 'eu', publicKey: 'pk-lf-public', secretKey: 'sk-lf-secret' }; + + await expect(testLangfuseConnectionFn({ data })).resolves.toEqual({ + success: false, + message: 'Langfuse rejected these keys', + }); + expect(requireAllSectionCapabilitiesMock).toHaveBeenCalledWith(['langfuse']); + expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/langfuse/connection/test', { + method: 'POST', + body: JSON.stringify(data), + }); + }); +}); diff --git a/src/server/langfuse.ts b/src/server/langfuse.ts new file mode 100644 index 0000000..265a639 --- /dev/null +++ b/src/server/langfuse.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import { createServerFn } from '@tanstack/react-start'; +import { requireAllSectionCapabilities } from './capabilities'; +import { apiFetch, extractApiError } from './utils/api'; + +export interface LangfuseDestinationOption { + key: string; + baseUrl: string; +} + +export interface LangfuseConnectionStatus { + configured: boolean; + enabled: boolean; + destinations: LangfuseDestinationOption[]; + destination?: string; + publicKey?: string; + displaySecretKey?: string; + updatedAt?: string; +} + +export interface LangfuseConnectionTestResponse { + success: boolean; + message?: string; +} + +export const LANGFUSE_CONNECTION_QUERY_KEY = ['adminLangfuseConnection'] as const; + +const connectionInputSchema = z.object({ + enabled: z.boolean(), + destination: z.string(), + publicKey: z.string(), + secretKey: z.string().optional(), +}); + +const connectionTestInputSchema = connectionInputSchema.omit({ enabled: true }); + +/** + * Proxy the dedicated LibreChat Langfuse connection API. LibreChat owns the + * destination allowlist, encrypted-secret handling, and credential checks. + */ +export const getLangfuseConnectionFn = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + await requireAllSectionCapabilities(['langfuse']); + const response = await apiFetch('/api/admin/langfuse/connection'); + if (!response.ok) { + return extractApiError(response, 'Failed to read Langfuse connection'); + } + return (await response.json()) as LangfuseConnectionStatus; + }, +); + +export const updateLangfuseConnectionFn = createServerFn({ method: 'POST' }) + .inputValidator(connectionInputSchema) + .handler(async ({ data }): Promise => { + await requireAllSectionCapabilities(['langfuse']); + const response = await apiFetch('/api/admin/langfuse/connection', { + method: 'PUT', + body: JSON.stringify(data), + }); + if (!response.ok) { + return extractApiError(response, 'Failed to update Langfuse connection'); + } + return (await response.json()) as LangfuseConnectionStatus; + }); + +export const testLangfuseConnectionFn = createServerFn({ method: 'POST' }) + .inputValidator(connectionTestInputSchema) + .handler(async ({ data }): Promise => { + await requireAllSectionCapabilities(['langfuse']); + const response = await apiFetch('/api/admin/langfuse/connection/test', { + method: 'POST', + body: JSON.stringify(data), + }); + if (!response.ok) { + return extractApiError(response, 'Failed to verify Langfuse connection'); + } + return (await response.json()) as LangfuseConnectionTestResponse; + });