From 1d0f402eaa7d4b4b3d148945ad54cc9e160b0946 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/18] 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 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/locales/en/translation.json b/src/locales/en/translation.json index 8a8cb05..41a8385 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 b568b1e..d96701f 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 { @@ -21,6 +21,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 e6ba788c4de4c3fa5bbb6a981fbd839b570fac62 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/18] 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 0000000..5dffef2 --- /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 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/locales/en/translation.json b/src/locales/en/translation.json index 41a8385..fb46826 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 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.ts b/src/server/langfuse.ts new file mode 100644 index 0000000..e569be9 --- /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 537b008cdc3a4acec938719b8e4c464a6fcee5a6 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/18] 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 5dffef2..8093b3b 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 d96701f..c796f37 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 { @@ -24,30 +24,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', @@ -680,6 +682,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 d2af2408642bd1cd0bbdc5302becfda1e09f2fa0 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/18] 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 fb46826..b50a4cc 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 787bbca3ce1f2cd77fecacd75cd6fa446d8d8c0a 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/18] 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 8093b3b..82bfc62 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 6233bffd3f788e7cf7e0711900e9f7acf3b71808 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 16:59:12 +0200 Subject: [PATCH 08/18] 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 b66a039..b563870 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 a76bdb1..e831ada 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 21d7990..54e6c7d 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 c796f37..53115f4 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -23,12 +23,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', @@ -39,16 +36,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 0000000..43acc95 --- /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 e569be9..56a8962 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 662a093d389e083db51b6d448f91c9a44de10df9 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:12:13 +0200 Subject: [PATCH 09/18] 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 ec25c06..c441f8a 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 53115f4..f2552d5 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 { @@ -50,6 +50,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', @@ -681,9 +698,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 bad3709a7855f9c83bbe43468b6573dfc21b8c8b Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:15:01 +0200 Subject: [PATCH 10/18] 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 43acc95..159c17f 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 56a8962..44a4a4f 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 0952ee1f429046b67de5a203be131f8c5ca580c5 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:20:07 +0200 Subject: [PATCH 11/18] fix(langfuse): reflect connection in configured state --- src/components/configuration/ConfigPage.tsx | 26 +++++++++++++++++-- .../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, 71 insertions(+), 6 deletions(-) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 5dcc345..7cbee6a 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, @@ -30,7 +32,12 @@ import { } from '@/utils'; import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; -import { applyConfigEdit, mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; +import { + applyConfigEdit, + mergeIndexedArrayEdits, + partitionScopeResetPaths, + withLangfuseConfiguredPath, +} from './utils'; import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { StickyActionBar } from '@/components/shared'; @@ -286,7 +293,22 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi return new Set(scopeChangedPaths); }, [scopeChangedPaths]); - 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 b563870..517ac8a 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 e831ada..67b1fc0 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 7921d2a..17f90e4 100644 --- a/src/components/configuration/utils.test.ts +++ b/src/components/configuration/utils.test.ts @@ -7,6 +7,7 @@ import { partitionScopeResetPaths, mergeIndexedArrayEdits, applyConfigEdit, + withLangfuseConfiguredPath, } from './utils'; import { createField } from '@/test/fixtures'; @@ -220,6 +221,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 729159b..4dd5e55 100644 --- a/src/components/configuration/utils.ts +++ b/src/components/configuration/utils.ts @@ -225,6 +225,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 44a4a4f..265a639 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 0ce9f4d6679f59a2a6955e7f18bb79920da71448 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Fri, 10 Jul 2026 17:48:15 +0200 Subject: [PATCH 12/18] 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 517ac8a..a39a18e 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 67b1fc0..4aec585 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) => ( -