From f92c60b76e4db13243dcae6cdbb924d81177f92b Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:08:45 -0700 Subject: [PATCH 01/21] feat: add tenant Langfuse config section Surface a per-tenant Langfuse connection section (enabled, base URL, public key, secret key) in the admin config UI. It saves through the existing /api/admin/config field API. The pinned librechat-data-provider (0.8.509) predates the langfuse config group, so a forward-compat shim extends configSchema locally until a data-provider version defining langfuse is published and pinned, mirroring the READ_AUDIT_LOG capability shim. The shim no-ops once upstream ships it. --- src/components/configuration/configMeta.ts | 5 ++++ src/locales/en/translation.json | 2 ++ src/server/config.ts | 30 +++++++++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/configuration/configMeta.ts b/src/components/configuration/configMeta.ts index 2d939589..7e4a6090 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/locales/en/translation.json b/src/locales/en/translation.json index a00cd996..383b229f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -766,6 +766,8 @@ "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_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.ts b/src/server/config.ts index 32635157..70499a7b 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,9 +1,9 @@ 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 { SystemCapabilities } from '@librechat/data-schemas/capabilities'; +import { configSchema as upstreamConfigSchema } from 'librechat-data-provider'; import type { AdminConfigResponse } from '@librechat/data-schemas'; import type * as t from '@/types'; import { @@ -22,6 +22,34 @@ 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 (tenant Langfuse connection: baseUrl / public key / + * secret key / enabled). The LibreChat sibling PR adds `langfuse` to + * `configSchema` and publishes a bumped data-provider; until that version is + * pinned here, extend the schema locally so the section renders in the config UI + * and `langfuse.*` field saves validate. Drops to a no-op passthrough once the + * pinned data-provider already defines `langfuse`; remove this shim then. + */ +const LANGFUSE_SHIM = z + .object({ + enabled: z.boolean().optional(), + baseUrl: z.string().optional(), + publicKey: z.string().optional(), + secretKey: z.string().optional(), + }) + .optional(); + +type UpstreamConfigSchema = typeof upstreamConfigSchema; +type UpstreamShapeValue = UpstreamConfigSchema extends { shape: infer S } ? S[keyof S] : never; + +const configSchema: UpstreamConfigSchema = + 'shape' in upstreamConfigSchema && 'langfuse' in upstreamConfigSchema.shape + ? upstreamConfigSchema + : (upstreamConfigSchema.extend({ + langfuse: LANGFUSE_SHIM as unknown as UpstreamShapeValue, + }) as UpstreamConfigSchema); + const WRAPPER_TYPES = new Set([ 'ZodOptional', 'ZodDefault', From 34672249fb4787048c467fdb69e391ffb58df7c2 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:23:53 -0700 Subject: [PATCH 02/21] feat: branded Langfuse connection renderer with test + masked secret Custom section renderer for the Langfuse config: enable toggle, host, public key, a masked (PasswordInput) secret that is write-only and only sent on change, the configured-key fingerprint returned by the backend, and a Test connection action. Adds an admin-gated testLangfuseConnectionFn that validates credentials against the Langfuse public projects endpoint. --- .../sections/LangfuseRenderer.tsx | 97 +++++++++++++++++++ .../configuration/sections/index.ts | 2 + src/locales/en/translation.json | 9 ++ src/server/index.ts | 1 + src/server/langfuse.ts | 46 +++++++++ 5 files changed, 155 insertions(+) create mode 100644 src/components/configuration/sections/LangfuseRenderer.tsx create mode 100644 src/server/langfuse.ts diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx new file mode 100644 index 00000000..5dffef2b --- /dev/null +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { Button, Switch, TextField } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { PasswordInput } from '@/components/PasswordInput'; +import { testLangfuseConnectionFn } from '@/server'; +import { useLocalize } from '@/hooks'; + +type TestState = 'idle' | 'testing' | 'ok' | 'fail'; + +function asString(value: t.ConfigValue): string { + return typeof value === 'string' ? value : ''; +} + +export function LangfuseRenderer(props: t.FieldRendererProps) { + const { parentPath, getValue, onChange, disabled } = props; + const localize = useLocalize(); + + const enabled = getValue(`${parentPath}.enabled`, false) === true; + const baseUrl = asString(getValue(`${parentPath}.baseUrl`, '')); + const publicKey = asString(getValue(`${parentPath}.publicKey`, '')); + const fingerprint = asString(getValue(`${parentPath}.secretKeyFingerprint`, '')); + const configured = fingerprint !== ''; + + const [secret, setSecret] = useState(''); + const [testState, setTestState] = useState('idle'); + const [testMessage, setTestMessage] = useState(''); + + const canTest = !disabled && baseUrl !== '' && publicKey !== '' && secret !== ''; + + const handleTest = async () => { + setTestState('testing'); + setTestMessage(''); + try { + const result = await testLangfuseConnectionFn({ + data: { baseUrl, publicKey, secretKey: secret }, + }); + setTestState(result.success ? 'ok' : 'fail'); + setTestMessage( + result.success + ? localize('com_config_langfuse_test_ok') + : (result.message ?? localize('com_config_langfuse_test_fail')), + ); + } catch { + setTestState('fail'); + setTestMessage(localize('com_config_langfuse_test_fail')); + } + }; + + return ( +
+ onChange(`${parentPath}.enabled`, value)} + /> + onChange(`${parentPath}.baseUrl`, value)} + /> + onChange(`${parentPath}.publicKey`, value)} + /> + { + setSecret(value); + onChange(`${parentPath}.secretKey`, value); + }} + /> + {configured && ( + + {localize('com_config_langfuse_fingerprint')} {fingerprint} + + )} +
+
+
+ ); +} diff --git a/src/components/configuration/sections/index.ts b/src/components/configuration/sections/index.ts index f3837de6..6afbc44b 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/locales/en/translation.json b/src/locales/en/translation.json index 383b229f..08efed7a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -768,6 +768,15 @@ "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": "Enable Langfuse export", + "com_config_langfuse_base_url": "Host", + "com_config_langfuse_public_key": "Public key", + "com_config_langfuse_secret_key": "Secret key", + "com_config_langfuse_secret_set": "Saved — enter a new key to replace it", + "com_config_langfuse_fingerprint": "Configured key fingerprint:", + "com_config_langfuse_test": "Test connection", + "com_config_langfuse_test_ok": "Connected to Langfuse successfully", + "com_config_langfuse_test_fail": "Could not connect to Langfuse", "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/index.ts b/src/server/index.ts index 25f28eb0..ad8cf1d7 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.ts b/src/server/langfuse.ts new file mode 100644 index 00000000..e569be93 --- /dev/null +++ b/src/server/langfuse.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { createServerFn } from '@tanstack/react-start'; +import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; +import { requireCapability } from './capabilities'; + +/** + * Validate a tenant Langfuse connection by calling the Langfuse public projects + * endpoint with the supplied credentials. Admin-gated because it makes an + * outbound request to a caller-supplied host. + */ +export const testLangfuseConnectionFn = createServerFn({ method: 'POST' }) + .inputValidator( + z.object({ + baseUrl: z.string(), + publicKey: z.string(), + secretKey: z.string(), + }), + ) + .handler(async ({ data }): Promise<{ success: boolean; message?: string }> => { + await requireCapability(SystemCapabilities.MANAGE_CONFIGS); + + const baseUrl = data.baseUrl.trim().replace(/\/+$/, ''); + if (!baseUrl || !data.publicKey || !data.secretKey) { + return { success: false, message: 'Base URL, public key, and secret key are required' }; + } + + let url: URL; + try { + url = new URL(`${baseUrl}/api/public/projects`); + } catch { + return { success: false, message: 'Base URL is not a valid URL' }; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return { success: false, message: 'Base URL must use http or https' }; + } + + try { + const auth = Buffer.from(`${data.publicKey}:${data.secretKey}`).toString('base64'); + const response = await fetch(url, { headers: { Authorization: `Basic ${auth}` } }); + return response.ok + ? { success: true } + : { success: false, message: `Langfuse responded with status ${response.status}` }; + } catch { + return { success: false, message: 'Could not reach the Langfuse host' }; + } + }); From a7b8efcc4090f8470dd9334a11e83ecd8b4c9835 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:50:57 -0700 Subject: [PATCH 03/21] fix: render Langfuse section via custom renderer and read saved values Inject the langfuse section as a SchemaField instead of extending the pinned data-provider schema, which mixed zod v4 (app) with v3 (data-provider) and left the section unintrospected so it fell back to the generic renderer. Read saved values from parentValue (the base config slice) rather than getValue leaf paths, which only resolve edited/scope values, so a configured connection repopulates on reload with the secret redacted and its fingerprint shown. --- .../sections/LangfuseRenderer.tsx | 15 ++++-- src/server/config.ts | 53 ++++++++++--------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx index 5dffef2b..8093b3b8 100644 --- a/src/components/configuration/sections/LangfuseRenderer.tsx +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -12,13 +12,18 @@ function asString(value: t.ConfigValue): string { } export function LangfuseRenderer(props: t.FieldRendererProps) { - const { parentPath, getValue, onChange, disabled } = props; + const { parentPath, parentValue, getValue, onChange, disabled } = props; const localize = useLocalize(); - const enabled = getValue(`${parentPath}.enabled`, false) === true; - const baseUrl = asString(getValue(`${parentPath}.baseUrl`, '')); - const publicKey = asString(getValue(`${parentPath}.publicKey`, '')); - const fingerprint = asString(getValue(`${parentPath}.secretKeyFingerprint`, '')); + const stored: Record = + parentValue && typeof parentValue === 'object' && !Array.isArray(parentValue) + ? (parentValue as Record) + : {}; + + const enabled = getValue(`${parentPath}.enabled`, stored.enabled ?? false) === true; + const baseUrl = asString(getValue(`${parentPath}.baseUrl`, stored.baseUrl ?? '')); + const publicKey = asString(getValue(`${parentPath}.publicKey`, stored.publicKey ?? '')); + const fingerprint = asString(stored.secretKeyFingerprint ?? ''); const configured = fingerprint !== ''; const [secret, setSecret] = useState(''); diff --git a/src/server/config.ts b/src/server/config.ts index 70499a7b..18d9d44c 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -3,7 +3,7 @@ import yaml from 'js-yaml'; import { queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; -import { configSchema as upstreamConfigSchema } from 'librechat-data-provider'; +import { configSchema } from 'librechat-data-provider'; import type { AdminConfigResponse } from '@librechat/data-schemas'; import type * as t from '@/types'; import { @@ -25,30 +25,32 @@ import { apiFetch } from './utils/api'; /** * Forward-compat shim: the pinned `librechat-data-provider@^0.8.509` predates the * `langfuse` config group (tenant Langfuse connection: baseUrl / public key / - * secret key / enabled). The LibreChat sibling PR adds `langfuse` to - * `configSchema` and publishes a bumped data-provider; until that version is - * pinned here, extend the schema locally so the section renders in the config UI - * and `langfuse.*` field saves validate. Drops to a no-op passthrough once the - * pinned data-provider already defines `langfuse`; remove this shim then. + * secret key / enabled). Inject the section node into the schema tree so it renders + * through the custom LangfuseRenderer and `langfuse.*` values save via the config + * API (validateFieldValue tolerates paths absent from the schema). Injected only + * when the pinned data-provider does not already define `langfuse`; remove this + * shim once a data-provider that defines it is pinned. */ -const LANGFUSE_SHIM = z - .object({ - enabled: z.boolean().optional(), - baseUrl: z.string().optional(), - publicKey: z.string().optional(), - secretKey: z.string().optional(), - }) - .optional(); - -type UpstreamConfigSchema = typeof upstreamConfigSchema; -type UpstreamShapeValue = UpstreamConfigSchema extends { shape: infer S } ? S[keyof S] : never; - -const configSchema: UpstreamConfigSchema = - 'shape' in upstreamConfigSchema && 'langfuse' in upstreamConfigSchema.shape - ? upstreamConfigSchema - : (upstreamConfigSchema.extend({ - langfuse: LANGFUSE_SHIM as unknown as UpstreamShapeValue, - }) as UpstreamConfigSchema); +const LANGFUSE_SHIM_FIELD: t.SchemaField = { + path: 'langfuse', + key: 'langfuse', + type: 'object', + isOptional: true, + isNullable: false, + isArray: false, + isObject: true, + depth: 0, + children: (['enabled', 'baseUrl', 'publicKey', 'secretKey'] as const).map((key) => ({ + path: `langfuse.${key}`, + key, + type: key === 'enabled' ? 'boolean' : 'string', + isOptional: true, + isNullable: false, + isArray: false, + isObject: false, + depth: 1, + })), +}; const WRAPPER_TYPES = new Set([ 'ZodOptional', @@ -681,6 +683,9 @@ export const configSchemaTreeOptions = queryOptions({ export const getConfigSchemaFields = createServerFn({ method: 'GET' }).handler(async () => { try { const tree = extractSchemaTree(configSchema); + if (!tree.some((section) => section.key === 'langfuse')) { + tree.push(LANGFUSE_SHIM_FIELD); + } for (const section of tree) { if (section.key === 'interface' && section.children) { section.children = filterInterfacePermissionChildren(section.children); From fb8112e4d62fd55cc15af7a10dd6126b83da51fa Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:15:13 -0700 Subject: [PATCH 04/21] fix: drop em dash from saved-secret placeholder --- src/locales/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 08efed7a..248f48f8 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -772,7 +772,7 @@ "com_config_langfuse_base_url": "Host", "com_config_langfuse_public_key": "Public key", "com_config_langfuse_secret_key": "Secret key", - "com_config_langfuse_secret_set": "Saved — enter a new key to replace it", + "com_config_langfuse_secret_set": "Saved. Enter a new key to replace it", "com_config_langfuse_fingerprint": "Configured key fingerprint:", "com_config_langfuse_test": "Test connection", "com_config_langfuse_test_ok": "Connected to Langfuse successfully", From 4f8d91d627dd700e6a1f30faa22b539836f4af54 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:23:35 -0700 Subject: [PATCH 05/21] feat: show loading state on Langfuse test connection button --- src/components/configuration/sections/LangfuseRenderer.tsx | 7 ++++++- src/locales/en/translation.json | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx index 8093b3b8..82bfc629 100644 --- a/src/components/configuration/sections/LangfuseRenderer.tsx +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -91,7 +91,12 @@ export function LangfuseRenderer(props: t.FieldRendererProps) {
+ ), +})); + +vi.mock('@/components/PasswordInput', () => ({ + PasswordInput: ({ label, value, placeholder, disabled, onChange }: TextFieldProps) => ( + onChange?.(e.target.value)} + /> + ), +})); + +const mockTest = vi.mocked(testLangfuseConnectionFn); + +function renderLangfuse({ + parentValue = {}, + editedValues = {}, + disabled, + onChange = vi.fn(), +}: { + parentValue?: Record; + editedValues?: t.FlatConfigMap; + disabled?: boolean; + onChange?: (path: string, value: t.ConfigValue) => void; +} = {}) { + const props: t.FieldRendererProps = { + fields: [], + parentValue, + parentPath: 'langfuse', + getValue: (path, fallback) => (path in editedValues ? (editedValues[path] ?? fallback) : fallback), + onChange, + editedValues, + disabled, + }; + return { ...render(), onChange }; +} + +beforeEach(() => { + mockTest.mockReset(); +}); + +describe('LangfuseRenderer', () => { + it('renders the enable toggle with a ConfigRow label alongside host, public key, and secret fields', () => { + renderLangfuse(); + expect(screen.getByText('com_config_langfuse_enabled')).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + expect(screen.getByLabelText('com_config_langfuse_base_url')).toBeInTheDocument(); + expect(screen.getByLabelText('com_config_langfuse_public_key')).toBeInTheDocument(); + expect(screen.getByLabelText('com_config_langfuse_secret_key')).toBeInTheDocument(); + }); + + it('leaves Test connection disabled until host, public key, and secret are all present', () => { + renderLangfuse({ parentValue: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1' } }); + expect(screen.getByRole('button', { name: 'com_config_langfuse_test' })).toBeDisabled(); + fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { + target: { value: 'sk-lf-secret' }, + }); + expect(screen.getByRole('button', { name: 'com_config_langfuse_test' })).toBeEnabled(); + }); + + it('prefills stored host and public key, shows the fingerprint, and never renders the secret', () => { + renderLangfuse({ + parentValue: { + enabled: true, + baseUrl: 'https://cloud.langfuse.com', + publicKey: 'pk-lf-1', + secretKeyFingerprint: 'abc123def456', + }, + }); + expect(screen.getByLabelText('com_config_langfuse_base_url')).toHaveValue( + 'https://cloud.langfuse.com', + ); + expect(screen.getByLabelText('com_config_langfuse_public_key')).toHaveValue('pk-lf-1'); + expect(screen.getByLabelText('com_config_langfuse_secret_key')).toHaveValue(''); + expect(screen.getByText('abc123def456')).toBeInTheDocument(); + }); + + it('does not show a fingerprint when the connection is unconfigured', () => { + renderLangfuse(); + expect(screen.queryByText('com_config_langfuse_fingerprint')).not.toBeInTheDocument(); + }); + + it('propagates edits to the correct config paths', () => { + const { onChange } = renderLangfuse(); + fireEvent.click(screen.getByRole('switch')); + fireEvent.change(screen.getByLabelText('com_config_langfuse_base_url'), { + target: { value: 'https://cloud.langfuse.com' }, + }); + fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { + target: { value: 'sk-lf-secret' }, + }); + expect(onChange).toHaveBeenCalledWith('langfuse.enabled', true); + expect(onChange).toHaveBeenCalledWith('langfuse.baseUrl', 'https://cloud.langfuse.com'); + expect(onChange).toHaveBeenCalledWith('langfuse.secretKey', 'sk-lf-secret'); + }); + + it('runs a connection test with the entered credentials and reports success', async () => { + mockTest.mockResolvedValue({ success: true }); + renderLangfuse({ parentValue: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1' } }); + fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { + target: { value: 'sk-lf-secret' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_test' })); + expect(mockTest).toHaveBeenCalledWith({ + data: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1', secretKey: 'sk-lf-secret' }, + }); + expect(await screen.findByText('com_config_langfuse_test_ok')).toBeInTheDocument(); + }); + + it('surfaces the failure message when the connection test fails', async () => { + mockTest.mockResolvedValue({ success: false, message: 'invalid credentials' }); + renderLangfuse({ parentValue: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1' } }); + fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { + target: { value: 'sk-lf-bad' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_test' })); + expect(await screen.findByText('invalid credentials')).toBeInTheDocument(); + }); +}); From b68e6b1d6895390db8497a33e905ccd5450dc55d Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 16:59:12 +0200 Subject: [PATCH 08/21] fix(langfuse): align connection UI with LibreChat API --- .../sections/LangfuseRenderer.tsx | 478 ++++++++++++++---- .../__tests__/LangfuseRenderer.test.tsx | 323 ++++++++---- src/locales/en/translation.json | 17 +- src/server/config.ts | 31 +- src/server/langfuse.test.ts | 84 +++ src/server/langfuse.ts | 95 ++-- 6 files changed, 787 insertions(+), 241 deletions(-) create mode 100644 src/server/langfuse.test.ts diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx index b66a039a..b5638709 100644 --- a/src/components/configuration/sections/LangfuseRenderer.tsx +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -1,111 +1,415 @@ -import { useState } from 'react'; -import { Button, TextField } from '@clickhouse/click-ui'; +import { useEffect, useRef, useState } from 'react'; +import { Button, Select, TextField } from '@clickhouse/click-ui'; +import { useMutation, useQuery } from '@tanstack/react-query'; import type * as t from '@/types'; -import { PasswordInput } from '@/components/PasswordInput'; -import { testLangfuseConnectionFn } from '@/server'; +import type { LangfuseConnectionStatus } from '@/server'; +import { + getLangfuseConnectionFn, + testLangfuseConnectionFn, + updateLangfuseConnectionFn, +} from '@/server'; +import { notifyError, notifySuccess } from '@/utils'; import { ToggleField } from '../fields/ToggleField'; import { ConfigRow } from '../ConfigRow'; import { useLocalize } from '@/hooks'; -type TestState = 'idle' | 'testing' | 'ok' | 'fail'; +type VerificationState = 'idle' | 'checking' | 'verified' | 'failed'; -function asString(value: t.ConfigValue): string { - return typeof value === 'string' ? value : ''; +function getConnectionKey(status?: LangfuseConnectionStatus): string | undefined { + if (!status?.configured || !status.destination || !status.publicKey) return undefined; + return `${status.destination}\u0000${status.publicKey}`; } -export function LangfuseRenderer(props: t.FieldRendererProps) { - const { parentPath, parentValue, getValue, onChange, disabled } = props; +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'); + 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 [status, setStatus] = useState(); + const [enabled, setEnabled] = useState(false); + 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 connectionQuery = useQuery({ + queryKey: ['adminLangfuseConnection'], + queryFn: () => getLangfuseConnectionFn(), + enabled: !isEditingScope, + retry: 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; + const nextStatus = connectionQuery.data; + setStatus(nextStatus); + setEnabled(nextStatus.enabled); + setDestination( + nextStatus.destinations.some(({ key }) => key === nextStatus.destination) + ? (nextStatus.destination ?? '') + : '', + ); + setPublicKey(nextStatus.publicKey ?? ''); + }, [connectionQuery.data]); - const stored: Record = - parentValue && typeof parentValue === 'object' && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; - - const enabled = getValue(`${parentPath}.enabled`, stored.enabled ?? false) === true; - const baseUrl = asString(getValue(`${parentPath}.baseUrl`, stored.baseUrl ?? '')); - const publicKey = asString(getValue(`${parentPath}.publicKey`, stored.publicKey ?? '')); - const fingerprint = asString(stored.secretKeyFingerprint ?? ''); - const configured = fingerprint !== ''; - - const [secret, setSecret] = useState(''); - const [testState, setTestState] = useState('idle'); - const [testMessage, setTestMessage] = useState(''); - - const canTest = !disabled && baseUrl !== '' && publicKey !== '' && secret !== ''; - - const handleTest = async () => { - setTestState('testing'); - setTestMessage(''); - try { - const result = await testLangfuseConnectionFn({ - data: { baseUrl, publicKey, secretKey: secret }, - }); - setTestState(result.success ? 'ok' : 'fail'); - setTestMessage( - result.success - ? localize('com_config_langfuse_test_ok') - : (result.message ?? localize('com_config_langfuse_test_fail')), - ); - } catch { - setTestState('fail'); - setTestMessage(localize('com_config_langfuse_test_fail')); + useEffect(() => { + const connectionKey = getConnectionKey(status); + if (!connectionKey) { + setVerificationState('idle'); + 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; + setVerificationState(result.success ? 'verified' : 'failed'); + setVerificationMessage(result.success ? '' : (result.message ?? '')); + }, + onError: (error: Error) => { + if (requestId !== requestRef.current) return; + setVerificationState('failed'); + setVerificationMessage(error.message); + }, + }, + ); + }, [status]); + + 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 hasCredentialEdits = + !configured || editingPublicKey || editingSecretKey || destinationChanged; + const showActions = hasCredentialEdits || publicKeyChanged || trimmedSecretKey !== ''; + const canSave = + !disabled && + destination !== '' && + trimmedPublicKey !== '' && + (configured || trimmedSecretKey !== ''); + const busy = updateMutation.isPending || testMutation.isPending; + + 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 = { + enabled, + destination, + publicKey: trimmedPublicKey, + ...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}), + }; + updateMutation.mutate(payload, { + onSuccess: (nextStatus) => { + testedConnectionRef.current = getConnectionKey(nextStatus); + setStatus(nextStatus); + setEnabled(nextStatus.enabled); + 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 = () => { + if (!enabled) { + saveConnection(); + return; + } + verify(destination, trimmedPublicKey, trimmedSecretKey, saveConnection); + }; + + const handleCancel = () => { + setEnabled(status?.enabled === true); + setDestination(status?.destination ?? ''); + setPublicKey(status?.publicKey ?? ''); + setSecretKey(''); + setEditingPublicKey(false); + setEditingSecretKey(false); + if (configured && status?.destination && status.publicKey) { + verify(status.destination, status.publicKey, ''); + } else { + setVerificationState('idle'); + setVerificationMessage(''); + } + }; + + const handleEnabledChange = (nextEnabled: boolean) => { + setEnabled(nextEnabled); + if (!configured || !status?.destination || !status.publicKey) return; + + const previousEnabled = status.enabled; + updateMutation.mutate( + { + enabled: nextEnabled, + destination: status.destination, + publicKey: status.publicKey, + }, + { + onSuccess: (nextStatus) => { + testedConnectionRef.current = getConnectionKey(nextStatus); + setStatus(nextStatus); + notifySuccess(localize('com_config_langfuse_saved')); + }, + onError: (error: Error) => { + setEnabled(previousEnabled); + notifyError(error.message); + }, + }, + ); + }; + + const statusLabel = getVerificationLabel(verificationState, verificationMessage, localize); + const statusDotClass = getVerificationDotClass(verificationState); + return ( -
- +
+ + {localize('com_config_langfuse_beta')} + + } + fieldId="langfuse-enabled" + > onChange(`${parentPath}.enabled`, value)} + disabled={disabled || busy} + onChange={handleEnabledChange} aria-label={localize('com_config_langfuse_enabled')} /> - onChange(`${parentPath}.baseUrl`, value)} - /> - onChange(`${parentPath}.publicKey`, value)} - /> - { - setSecret(value); - onChange(`${parentPath}.secretKey`, value); + +
+ + {statusLabel} +
+ + + +
+ {localize('com_config_langfuse_public_key')} + {configured && !editingPublicKey ? ( + + ) : ( + + )} +
+ +
+ {localize('com_config_langfuse_secret_key')} + {configured && !editingSecretKey ? ( + + ) : ( + + )} +
+ +
+ {showActions && ( + <> + {configured && ( +
); diff --git a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx index a76bdb17..e831ada1 100644 --- a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx +++ b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx @@ -1,38 +1,88 @@ -import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { 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 { testLangfuseConnectionFn } from '@/server'; +import { + getLangfuseConnectionFn, + 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', () => ({ + getLangfuseConnectionFn: vi.fn(), testLangfuseConnectionFn: vi.fn(), + updateLangfuseConnectionFn: vi.fn(), })); interface SwitchProps { checked: boolean; disabled?: boolean; - onCheckedChange?: (v: boolean) => void; + onCheckedChange?: (value: boolean) => void; 'aria-label'?: string; } + interface TextFieldProps { label?: string; value?: string; placeholder?: string; disabled?: boolean; - onChange?: (v: string) => void; + onChange?: (value: string) => void; } + interface ButtonProps { label?: string; disabled?: boolean; - loading?: 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 }) => ( + + ), + }, + ), Switch: (props: SwitchProps) => ( - ), -})); - -vi.mock('@/components/PasswordInput', () => ({ - PasswordInput: ({ label, value, placeholder, disabled, onChange }: TextFieldProps) => ( - onChange?.(e.target.value)} + onChange={(event) => 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({ - parentValue = {}, - editedValues = {}, - disabled, - onChange = vi.fn(), -}: { - parentValue?: Record; - editedValues?: t.FlatConfigMap; - disabled?: boolean; - onChange?: (path: string, value: t.ConfigValue) => void; -} = {}) { +function renderLangfuse(overrides: Partial = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const props: t.FieldRendererProps = { fields: [], - parentValue, + parentValue: {}, parentPath: 'langfuse', - getValue: (path, fallback) => (path in editedValues ? (editedValues[path] ?? fallback) : fallback), - onChange, - editedValues, - disabled, + getValue: (_path, fallback) => fallback, + onChange: vi.fn(), + ...overrides, }; - return { ...render(), onChange }; + return render( + + + , + ); } beforeEach(() => { - mockTest.mockReset(); + vi.clearAllMocks(); + mockGet.mockResolvedValue({ configured: false, enabled: false, destinations }); + mockTest.mockResolvedValue({ success: true }); }); describe('LangfuseRenderer', () => { - it('renders the enable toggle with a ConfigRow label alongside host, public key, and secret fields', () => { + it('loads the deployment-approved destinations from LibreChat', async () => { renderLangfuse(); - expect(screen.getByText('com_config_langfuse_enabled')).toBeInTheDocument(); - expect(screen.getByRole('switch')).toBeInTheDocument(); - expect(screen.getByLabelText('com_config_langfuse_base_url')).toBeInTheDocument(); - expect(screen.getByLabelText('com_config_langfuse_public_key')).toBeInTheDocument(); - expect(screen.getByLabelText('com_config_langfuse_secret_key')).toBeInTheDocument(); + 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(); }); - it('leaves Test connection disabled until host, public key, and secret are all present', () => { - renderLangfuse({ parentValue: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1' } }); - expect(screen.getByRole('button', { name: 'com_config_langfuse_test' })).toBeDisabled(); - fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { - target: { value: 'sk-lf-secret' }, + 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', }); - expect(screen.getByRole('button', { name: 'com_config_langfuse_test' })).toBeEnabled(); + 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('prefills stored host and public key, shows the fingerprint, and never renders the secret', () => { - renderLangfuse({ - parentValue: { - enabled: true, - baseUrl: 'https://cloud.langfuse.com', - publicKey: 'pk-lf-1', - secretKeyFingerprint: 'abc123def456', - }, + 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' }, }); - expect(screen.getByLabelText('com_config_langfuse_base_url')).toHaveValue( - 'https://cloud.langfuse.com', + fireEvent.change(screen.getByPlaceholderText('pk-lf-...'), { + target: { value: 'pk-lf-new' }, + }); + fireEvent.change(screen.getByPlaceholderText('sk-lf-...'), { + target: { value: 'sk-lf-secret' }, + }); + fireEvent.click(screen.getByRole('switch')); + const saveButton = screen.getByRole('button', { name: 'com_ui_save' }); + 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', + }, + }), ); - expect(screen.getByLabelText('com_config_langfuse_public_key')).toHaveValue('pk-lf-1'); - expect(screen.getByLabelText('com_config_langfuse_secret_key')).toHaveValue(''); - expect(screen.getByText('abc123def456')).toBeInTheDocument(); }); - it('does not show a fingerprint when the connection is unconfigured', () => { + 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' }); renderLangfuse(); - expect(screen.queryByText('com_config_langfuse_fingerprint')).not.toBeInTheDocument(); + + 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-new' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { enabled: true, destination: 'eu', publicKey: 'pk-lf-new' }, + }), + ); }); - it('propagates edits to the correct config paths', () => { - const { onChange } = renderLangfuse(); + it('persists the enable toggle 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('switch')); - fireEvent.change(screen.getByLabelText('com_config_langfuse_base_url'), { - target: { value: 'https://cloud.langfuse.com' }, - }); - fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { - target: { value: 'sk-lf-secret' }, - }); - expect(onChange).toHaveBeenCalledWith('langfuse.enabled', true); - expect(onChange).toHaveBeenCalledWith('langfuse.baseUrl', 'https://cloud.langfuse.com'); - expect(onChange).toHaveBeenCalledWith('langfuse.secretKey', 'sk-lf-secret'); + + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith({ + data: { enabled: false, destination: 'eu', publicKey: 'pk-lf-existing' }, + }), + ); + expect(mockTest).not.toHaveBeenCalled(); }); - it('runs a connection test with the entered credentials and reports success', async () => { - mockTest.mockResolvedValue({ success: true }); - renderLangfuse({ parentValue: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1' } }); - fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { - target: { value: 'sk-lf-secret' }, + 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', }); - fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_test' })); - expect(mockTest).toHaveBeenCalledWith({ - data: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1', secretKey: 'sk-lf-secret' }, + 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' }, }); - expect(await screen.findByText('com_config_langfuse_test_ok')).toBeInTheDocument(); - }); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + expect(await screen.findByText('invalid keys')).toBeVisible(); - it('surfaces the failure message when the connection test fails', async () => { - mockTest.mockResolvedValue({ success: false, message: 'invalid credentials' }); - renderLangfuse({ parentValue: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk-lf-1' } }); - fireEvent.change(screen.getByLabelText('com_config_langfuse_secret_key'), { - target: { value: 'sk-lf-bad' }, + 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' }, }); - fireEvent.click(screen.getByRole('button', { name: 'com_config_langfuse_test' })); - expect(await screen.findByText('invalid credentials')).toBeInTheDocument(); + 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(); }); }); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2cdb5800..2ba9694c 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -768,16 +768,19 @@ "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": "Enable Langfuse export", - "com_config_langfuse_base_url": "Host", + "com_config_langfuse_enabled": "Langfuse export", + "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_secret_set": "Saved. Enter a new key to replace it", - "com_config_langfuse_fingerprint": "Configured key fingerprint:", - "com_config_langfuse_test": "Test connection", - "com_config_langfuse_testing": "Testing connection", - "com_config_langfuse_test_ok": "Connected to Langfuse successfully", + "com_config_langfuse_checking": "Verifying with Langfuse...", + "com_config_langfuse_verified": "Verified with Langfuse", + "com_config_langfuse_not_configured": "Not configured", "com_config_langfuse_test_fail": "Could not connect to Langfuse", + "com_config_langfuse_saved": "Langfuse export saved", + "com_config_langfuse_tenant_wide": "Langfuse export 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.ts b/src/server/config.ts index 18d9d44c..a89a9366 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -24,12 +24,9 @@ import { apiFetch } from './utils/api'; /** * Forward-compat shim: the pinned `librechat-data-provider@^0.8.509` predates the - * `langfuse` config group (tenant Langfuse connection: baseUrl / public key / - * secret key / enabled). Inject the section node into the schema tree so it renders - * through the custom LangfuseRenderer and `langfuse.*` values save via the config - * API (validateFieldValue tolerates paths absent from the schema). Injected only - * when the pinned data-provider does not already define `langfuse`; remove this - * shim once a data-provider that defines it is pinned. + * `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', @@ -40,16 +37,18 @@ const LANGFUSE_SHIM_FIELD: t.SchemaField = { isArray: false, isObject: true, depth: 0, - children: (['enabled', 'baseUrl', 'publicKey', 'secretKey'] as const).map((key) => ({ - path: `langfuse.${key}`, - key, - type: key === 'enabled' ? 'boolean' : 'string', - isOptional: true, - isNullable: false, - isArray: false, - isObject: false, - depth: 1, - })), + 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, + }), + ), }; const WRAPPER_TYPES = new Set([ diff --git a/src/server/langfuse.test.ts b/src/server/langfuse.test.ts new file mode 100644 index 00000000..43acc95b --- /dev/null +++ b/src/server/langfuse.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; + +const apiFetchMock = vi.fn(); +const requireCapabilityMock = 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', () => ({ + requireCapability: (capability: string) => requireCapabilityMock(capability), +})); + +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(); + requireCapabilityMock.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(requireCapabilityMock).toHaveBeenCalledWith(SystemCapabilities.MANAGE_CONFIGS); + 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(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(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 index e569be93..56a8962a 100644 --- a/src/server/langfuse.ts +++ b/src/server/langfuse.ts @@ -2,45 +2,76 @@ import { z } from 'zod'; import { createServerFn } from '@tanstack/react-start'; import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; import { requireCapability } 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; +} + +const connectionInputSchema = z.object({ + enabled: z.boolean(), + destination: z.string(), + publicKey: z.string(), + secretKey: z.string().optional(), +}); + +const connectionTestInputSchema = connectionInputSchema.omit({ enabled: true }); /** - * Validate a tenant Langfuse connection by calling the Langfuse public projects - * endpoint with the supplied credentials. Admin-gated because it makes an - * outbound request to a caller-supplied host. + * Proxy the dedicated LibreChat Langfuse connection API. LibreChat owns the + * destination allowlist, encrypted-secret handling, and credential checks. */ -export const testLangfuseConnectionFn = createServerFn({ method: 'POST' }) - .inputValidator( - z.object({ - baseUrl: z.string(), - publicKey: z.string(), - secretKey: z.string(), - }), - ) - .handler(async ({ data }): Promise<{ success: boolean; message?: string }> => { +export const getLangfuseConnectionFn = createServerFn({ method: 'GET' }).handler( + async (): Promise => { await requireCapability(SystemCapabilities.MANAGE_CONFIGS); - - const baseUrl = data.baseUrl.trim().replace(/\/+$/, ''); - if (!baseUrl || !data.publicKey || !data.secretKey) { - return { success: false, message: 'Base URL, public key, and secret key are required' }; + 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; + }, +); - let url: URL; - try { - url = new URL(`${baseUrl}/api/public/projects`); - } catch { - return { success: false, message: 'Base URL is not a valid URL' }; - } - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return { success: false, message: 'Base URL must use http or https' }; +export const updateLangfuseConnectionFn = createServerFn({ method: 'POST' }) + .inputValidator(connectionInputSchema) + .handler(async ({ data }): Promise => { + await requireCapability(SystemCapabilities.MANAGE_CONFIGS); + 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; + }); - try { - const auth = Buffer.from(`${data.publicKey}:${data.secretKey}`).toString('base64'); - const response = await fetch(url, { headers: { Authorization: `Basic ${auth}` } }); - return response.ok - ? { success: true } - : { success: false, message: `Langfuse responded with status ${response.status}` }; - } catch { - return { success: false, message: 'Could not reach the Langfuse host' }; +export const testLangfuseConnectionFn = createServerFn({ method: 'POST' }) + .inputValidator(connectionTestInputSchema) + .handler(async ({ data }): Promise => { + await requireCapability(SystemCapabilities.MANAGE_CONFIGS); + 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; }); From 9002566615e0b7e17f2ab672a1a93135d1f3ee42 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:12:13 +0200 Subject: [PATCH 09/21] fix(langfuse): gate admin settings on fanout --- src/server/config.test.ts | 48 +++++++++++++++++++++++++++++++++------ src/server/config.ts | 27 ++++++++++++++++++---- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 931bc7a5..96e6f5d1 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,39 @@ 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); + }); +}); + describe('extractSchemaTree', () => { it('extracts basic scalar types', () => { const schema = z3.object({ diff --git a/src/server/config.ts b/src/server/config.ts index a89a9366..1cd66020 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -2,8 +2,8 @@ import { z } from 'zod'; import yaml from 'js-yaml'; import { queryOptions } from '@tanstack/react-query'; import { createServerFn } from '@tanstack/react-start'; -import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; 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'; import { @@ -51,6 +51,23 @@ const LANGFUSE_SHIM_FIELD: t.SchemaField = { ), }; +export function applyLangfuseSchemaVisibility( + tree: t.SchemaField[], + fanoutEnabled: boolean, +): t.SchemaField[] { + const langfuseIndex = tree.findIndex((section) => section.key === 'langfuse'); + if (!fanoutEnabled) { + if (langfuseIndex >= 0) { + tree.splice(langfuseIndex, 1); + } + return tree; + } + if (langfuseIndex < 0) { + tree.push(LANGFUSE_SHIM_FIELD); + } + return tree; +} + const WRAPPER_TYPES = new Set([ 'ZodOptional', 'ZodDefault', @@ -682,9 +699,11 @@ export const configSchemaTreeOptions = queryOptions({ export const getConfigSchemaFields = createServerFn({ method: 'GET' }).handler(async () => { try { const tree = extractSchemaTree(configSchema); - if (!tree.some((section) => section.key === 'langfuse')) { - tree.push(LANGFUSE_SHIM_FIELD); - } + const startupConfigResponse = await apiFetch('/api/config'); + const startupConfig = startupConfigResponse.ok + ? ((await startupConfigResponse.json()) as { langfuseFanoutEnabled?: boolean }) + : undefined; + applyLangfuseSchemaVisibility(tree, startupConfig?.langfuseFanoutEnabled === true); for (const section of tree) { if (section.key === 'interface' && section.children) { section.children = filterInterfacePermissionChildren(section.children); From 59b623f5459eff95acc029dff1cda14a862fcbb0 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:15:01 +0200 Subject: [PATCH 10/21] fix(langfuse): allow section-scoped config admins --- src/server/langfuse.test.ts | 12 +++++++----- src/server/langfuse.ts | 9 ++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/server/langfuse.test.ts b/src/server/langfuse.test.ts index 43acc95b..159c17fa 100644 --- a/src/server/langfuse.test.ts +++ b/src/server/langfuse.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; const apiFetchMock = vi.fn(); -const requireCapabilityMock = vi.fn(); +const requireAllSectionCapabilitiesMock = vi.fn(); vi.mock('./utils/api', () => ({ apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init), @@ -12,7 +11,8 @@ vi.mock('./utils/api', () => ({ })); vi.mock('./capabilities', () => ({ - requireCapability: (capability: string) => requireCapabilityMock(capability), + requireAllSectionCapabilities: (sections: string[]) => + requireAllSectionCapabilitiesMock(sections), })); vi.mock('@tanstack/react-start', () => ({ @@ -41,7 +41,7 @@ const status = { beforeEach(() => { vi.clearAllMocks(); - requireCapabilityMock.mockResolvedValue(undefined); + requireAllSectionCapabilitiesMock.mockResolvedValue(undefined); }); describe('Langfuse connection server functions', () => { @@ -49,7 +49,7 @@ describe('Langfuse connection server functions', () => { apiFetchMock.mockResolvedValue(new Response(JSON.stringify(status), { status: 200 })); await expect(getLangfuseConnectionFn()).resolves.toEqual(status); - expect(requireCapabilityMock).toHaveBeenCalledWith(SystemCapabilities.MANAGE_CONFIGS); + expect(requireAllSectionCapabilitiesMock).toHaveBeenCalledWith(['langfuse']); expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/langfuse/connection', undefined); }); @@ -58,6 +58,7 @@ describe('Langfuse connection server functions', () => { 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), @@ -76,6 +77,7 @@ describe('Langfuse connection server functions', () => { 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 index 56a8962a..44a4a4f3 100644 --- a/src/server/langfuse.ts +++ b/src/server/langfuse.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { createServerFn } from '@tanstack/react-start'; -import { SystemCapabilities } from '@librechat/data-schemas/capabilities'; -import { requireCapability } from './capabilities'; +import { requireAllSectionCapabilities } from './capabilities'; import { apiFetch, extractApiError } from './utils/api'; export interface LangfuseDestinationOption { @@ -39,7 +38,7 @@ const connectionTestInputSchema = connectionInputSchema.omit({ enabled: true }); */ export const getLangfuseConnectionFn = createServerFn({ method: 'GET' }).handler( async (): Promise => { - await requireCapability(SystemCapabilities.MANAGE_CONFIGS); + await requireAllSectionCapabilities(['langfuse']); const response = await apiFetch('/api/admin/langfuse/connection'); if (!response.ok) { return extractApiError(response, 'Failed to read Langfuse connection'); @@ -51,7 +50,7 @@ export const getLangfuseConnectionFn = createServerFn({ method: 'GET' }).handler export const updateLangfuseConnectionFn = createServerFn({ method: 'POST' }) .inputValidator(connectionInputSchema) .handler(async ({ data }): Promise => { - await requireCapability(SystemCapabilities.MANAGE_CONFIGS); + await requireAllSectionCapabilities(['langfuse']); const response = await apiFetch('/api/admin/langfuse/connection', { method: 'PUT', body: JSON.stringify(data), @@ -65,7 +64,7 @@ export const updateLangfuseConnectionFn = createServerFn({ method: 'POST' }) export const testLangfuseConnectionFn = createServerFn({ method: 'POST' }) .inputValidator(connectionTestInputSchema) .handler(async ({ data }): Promise => { - await requireCapability(SystemCapabilities.MANAGE_CONFIGS); + await requireAllSectionCapabilities(['langfuse']); const response = await apiFetch('/api/admin/langfuse/connection/test', { method: 'POST', body: JSON.stringify(data), From 11f7ffdf12e4805a95e531e0a9d0fd293f26afb8 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:20:07 +0200 Subject: [PATCH 11/21] fix(langfuse): reflect connection in configured state --- src/components/configuration/ConfigPage.tsx | 20 ++++++++++++++++++- .../sections/LangfuseRenderer.tsx | 8 ++++++-- .../__tests__/LangfuseRenderer.test.tsx | 13 ++++++++++-- src/components/configuration/utils.test.ts | 18 +++++++++++++++++ src/components/configuration/utils.ts | 10 ++++++++++ src/server/langfuse.ts | 2 ++ 6 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6d30330d..826c470b 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,22 @@ 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, + }); + + 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/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx index b5638709..517ac8aa 100644 --- a/src/components/configuration/sections/LangfuseRenderer.tsx +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Select, TextField } from '@clickhouse/click-ui'; -import { useMutation, useQuery } from '@tanstack/react-query'; +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'; @@ -58,6 +59,7 @@ function getVerificationDotClass(state: VerificationState): string { export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererProps) { const localize = useLocalize(); + const queryClient = useQueryClient(); const [status, setStatus] = useState(); const [enabled, setEnabled] = useState(false); const [destination, setDestination] = useState(''); @@ -71,7 +73,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr const requestRef = useRef(0); const connectionQuery = useQuery({ - queryKey: ['adminLangfuseConnection'], + queryKey: LANGFUSE_CONNECTION_QUERY_KEY, queryFn: () => getLangfuseConnectionFn(), enabled: !isEditingScope, retry: false, @@ -213,6 +215,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr }; updateMutation.mutate(payload, { onSuccess: (nextStatus) => { + queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, nextStatus); testedConnectionRef.current = getConnectionKey(nextStatus); setStatus(nextStatus); setEnabled(nextStatus.enabled); @@ -263,6 +266,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr }, { onSuccess: (nextStatus) => { + queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, nextStatus); testedConnectionRef.current = getConnectionKey(nextStatus); setStatus(nextStatus); notifySuccess(localize('com_config_langfuse_saved')); diff --git a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx index e831ada1..67b1fc08 100644 --- a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx +++ b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx @@ -5,6 +5,7 @@ import type * as t from '@/types'; import { LangfuseRenderer } from '../LangfuseRenderer'; import { getLangfuseConnectionFn, + LANGFUSE_CONNECTION_QUERY_KEY, testLangfuseConnectionFn, updateLangfuseConnectionFn, } from '@/server'; @@ -20,6 +21,7 @@ vi.mock('@/utils', () => ({ })); vi.mock('@/server', () => ({ + LANGFUSE_CONNECTION_QUERY_KEY: ['adminLangfuseConnection'], getLangfuseConnectionFn: vi.fn(), testLangfuseConnectionFn: vi.fn(), updateLangfuseConnectionFn: vi.fn(), @@ -122,11 +124,12 @@ function renderLangfuse(overrides: Partial = {}) { onChange: vi.fn(), ...overrides, }; - return render( + const result = render( , ); + return { ...result, queryClient }; } beforeEach(() => { @@ -222,7 +225,7 @@ describe('LangfuseRenderer', () => { }; mockGet.mockResolvedValue(configuredStatus); mockUpdate.mockResolvedValue({ ...configuredStatus, publicKey: 'pk-lf-new' }); - renderLangfuse(); + const { queryClient } = renderLangfuse(); fireEvent.click( await screen.findByRole('button', { name: 'com_ui_edit com_config_langfuse_public_key' }), @@ -237,6 +240,12 @@ describe('LangfuseRenderer', () => { 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('persists the enable toggle without re-verifying credentials', async () => { diff --git a/src/components/configuration/utils.test.ts b/src/components/configuration/utils.test.ts index 469644d4..62dbd175 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 d858cecc..c6f37e49 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/server/langfuse.ts b/src/server/langfuse.ts index 44a4a4f3..265a6392 100644 --- a/src/server/langfuse.ts +++ b/src/server/langfuse.ts @@ -23,6 +23,8 @@ export interface LangfuseConnectionTestResponse { message?: string; } +export const LANGFUSE_CONNECTION_QUERY_KEY = ['adminLangfuseConnection'] as const; + const connectionInputSchema = z.object({ enabled: z.boolean(), destination: z.string(), From de45d2e04dc6d06d55a19ce1a26dbb7afec0554f Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:48:15 +0200 Subject: [PATCH 12/21] fix(langfuse): simplify export enablement controls --- .../sections/LangfuseRenderer.tsx | 111 ++++++++++-------- .../__tests__/LangfuseRenderer.test.tsx | 86 ++++++++++---- src/locales/en/translation.json | 4 + 3 files changed, 127 insertions(+), 74 deletions(-) diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx index 517ac8aa..a39a18e6 100644 --- a/src/components/configuration/sections/LangfuseRenderer.tsx +++ b/src/components/configuration/sections/LangfuseRenderer.tsx @@ -10,11 +10,9 @@ import { updateLangfuseConnectionFn, } from '@/server'; import { notifyError, notifySuccess } from '@/utils'; -import { ToggleField } from '../fields/ToggleField'; -import { ConfigRow } from '../ConfigRow'; import { useLocalize } from '@/hooks'; -type VerificationState = 'idle' | 'checking' | 'verified' | 'failed'; +type VerificationState = 'idle' | 'unverified' | 'checking' | 'verified' | 'failed'; function getConnectionKey(status?: LangfuseConnectionStatus): string | undefined { if (!status?.configured || !status.destination || !status.publicKey) return undefined; @@ -39,6 +37,8 @@ function getVerificationLabel( 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'); } @@ -61,7 +61,6 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr const localize = useLocalize(); const queryClient = useQueryClient(); const [status, setStatus] = useState(); - const [enabled, setEnabled] = useState(false); const [destination, setDestination] = useState(''); const [publicKey, setPublicKey] = useState(''); const [secretKey, setSecretKey] = useState(''); @@ -77,6 +76,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr queryFn: () => getLangfuseConnectionFn(), enabled: !isEditingScope, retry: false, + refetchOnWindowFocus: false, }); const updateMutation = useMutation({ mutationFn: (data: { @@ -95,7 +95,6 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr if (!connectionQuery.data) return; const nextStatus = connectionQuery.data; setStatus(nextStatus); - setEnabled(nextStatus.enabled); setDestination( nextStatus.destinations.some(({ key }) => key === nextStatus.destination) ? (nextStatus.destination ?? '') @@ -159,9 +158,13 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr const trimmedSecretKey = secretKey.trim(); const destinationChanged = destination !== (status?.destination ?? ''); const publicKeyChanged = trimmedPublicKey !== (status?.publicKey ?? ''); - const hasCredentialEdits = - !configured || editingPublicKey || editingSecretKey || destinationChanged; - const showActions = hasCredentialEdits || publicKeyChanged || trimmedSecretKey !== ''; + const isEditing = + !configured || + editingPublicKey || + editingSecretKey || + destinationChanged || + publicKeyChanged || + trimmedSecretKey !== ''; const canSave = !disabled && destination !== '' && @@ -208,7 +211,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr const saveConnection = () => { const payload = { - enabled, + enabled: true, destination, publicKey: trimmedPublicKey, ...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}), @@ -218,7 +221,6 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr queryClient.setQueryData(LANGFUSE_CONNECTION_QUERY_KEY, nextStatus); testedConnectionRef.current = getConnectionKey(nextStatus); setStatus(nextStatus); - setEnabled(nextStatus.enabled); setDestination(nextStatus.destination ?? ''); setPublicKey(nextStatus.publicKey ?? ''); setSecretKey(''); @@ -231,33 +233,30 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr }; const handleSave = () => { - if (!enabled) { - saveConnection(); - return; - } verify(destination, trimmedPublicKey, trimmedSecretKey, saveConnection); }; const handleCancel = () => { - setEnabled(status?.enabled === true); - setDestination(status?.destination ?? ''); + const storedDestination = status?.destinations.some(({ key }) => key === status.destination) + ? status.destination + : undefined; + setDestination(storedDestination ?? ''); setPublicKey(status?.publicKey ?? ''); setSecretKey(''); setEditingPublicKey(false); setEditingSecretKey(false); - if (configured && status?.destination && status.publicKey) { - verify(status.destination, status.publicKey, ''); + if (configured && storedDestination && status?.publicKey) { + verify(storedDestination, status.publicKey, ''); } else { setVerificationState('idle'); setVerificationMessage(''); } }; - const handleEnabledChange = (nextEnabled: boolean) => { - setEnabled(nextEnabled); + const handleEnabledChange = () => { if (!configured || !status?.destination || !status.publicKey) return; - const previousEnabled = status.enabled; + const nextEnabled = status.enabled !== true; updateMutation.mutate( { enabled: nextEnabled, @@ -271,10 +270,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr setStatus(nextStatus); notifySuccess(localize('com_config_langfuse_saved')); }, - onError: (error: Error) => { - setEnabled(previousEnabled); - notifyError(error.message); - }, + onError: (error: Error) => notifyError(error.message), }, ); }; @@ -284,24 +280,17 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr return (
- +
+ {localize('com_config_langfuse_enabled')} {localize('com_config_langfuse_beta')} - } - fieldId="langfuse-enabled" - > - - +
+ + {localize('com_config_langfuse_description')} + +
{ + setPublicKey(value); + setVerificationState('unverified'); + setVerificationMessage(''); + }} /> )}
@@ -385,34 +378,48 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr value={secretKey} disabled={disabled || busy} placeholder="sk-lf-..." - onChange={setSecretKey} + onChange={(value) => { + setSecretKey(value); + setVerificationState('unverified'); + setVerificationMessage(''); + }} /> )}
- {showActions && ( + {isEditing ? ( <> - {configured && ( -
diff --git a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx index 67b1fc08..4aec5850 100644 --- a/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx +++ b/src/components/configuration/sections/__tests__/LangfuseRenderer.test.tsx @@ -27,13 +27,6 @@ vi.mock('@/server', () => ({ updateLangfuseConnectionFn: vi.fn(), })); -interface SwitchProps { - checked: boolean; - disabled?: boolean; - onCheckedChange?: (value: boolean) => void; - 'aria-label'?: string; -} - interface TextFieldProps { label?: string; value?: string; @@ -85,15 +78,6 @@ vi.mock('@clickhouse/click-ui', () => ({ ), }, ), - Switch: (props: SwitchProps) => ( -