mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat: in-app Langfuse connection settings panel
Add a discoverable, admin-gated Langfuse connection panel inside LibreChat Settings (Dify-style): enable toggle, host, public key, masked write-only secret, configured-key fingerprint, and a test-connection action. Backed by a dedicated /api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns metadata plus fingerprint on read, and validates credentials. Builds on the per-field encryption and fanout decrypt from the langfuse-config-encryption branch.
This commit is contained in:
parent
dc7f4808e4
commit
fbe3cbe5c1
20 changed files with 939 additions and 4 deletions
|
|
@ -249,6 +249,7 @@ const startServer = async () => {
|
|||
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
|
||||
app.use('/api/admin', routes.adminAuth);
|
||||
app.use('/api/admin/config', routes.adminConfig);
|
||||
app.use('/api/admin/langfuse', routes.adminLangfuse);
|
||||
app.use('/api/admin/grants', routes.adminGrants);
|
||||
app.use('/api/admin/groups', routes.adminGroups);
|
||||
app.use('/api/admin/roles', routes.adminRoles);
|
||||
|
|
|
|||
25
api/server/routes/admin/langfuse.js
Normal file
25
api/server/routes/admin/langfuse.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const express = require('express');
|
||||
const { createAdminLangfuseHandlers } = require('@librechat/api');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { invalidateConfigCaches } = require('~/server/services/Config');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const db = require('~/models');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
|
||||
|
||||
const handlers = createAdminLangfuseHandlers({
|
||||
findConfigByPrincipal: db.findConfigByPrincipal,
|
||||
patchConfigFields: db.patchConfigFields,
|
||||
invalidateConfigCaches,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth, requireAdminAccess);
|
||||
|
||||
router.get('/connection', handlers.getConnection);
|
||||
router.put('/connection', handlers.updateConnection);
|
||||
router.post('/connection/test', handlers.testConnection);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -3,6 +3,7 @@ const assistants = require('./assistants');
|
|||
const categories = require('./categories');
|
||||
const adminAuth = require('./admin/auth');
|
||||
const adminConfig = require('./admin/config');
|
||||
const adminLangfuse = require('./admin/langfuse');
|
||||
const adminGrants = require('./admin/grants');
|
||||
const adminGroups = require('./admin/groups');
|
||||
const adminRoles = require('./admin/roles');
|
||||
|
|
@ -43,6 +44,7 @@ module.exports = {
|
|||
auth,
|
||||
adminAuth,
|
||||
adminConfig,
|
||||
adminLangfuse,
|
||||
adminGrants,
|
||||
adminGroups,
|
||||
adminRoles,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ctx: SettingsContextValue = {
|
|||
allowAccountDeletion: true,
|
||||
aboutEnabled: false,
|
||||
engineTTS: 'browser',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider';
|
||||
import type { SettingsContextValue } from './types';
|
||||
import useProviderKeys from '../SettingsTabs/ProviderKeys/useProviderKeys';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
|
|
@ -27,6 +27,7 @@ export function useSettingsContext(): SettingsContextValue {
|
|||
});
|
||||
|
||||
const balanceEnabled = startupConfig?.balance?.enabled === true;
|
||||
const isAdmin = user?.role === SystemRoles.ADMIN;
|
||||
const isLocalProvider = user?.provider === 'local';
|
||||
const twoFactorEnabled = user?.twoFactorEnabled === true;
|
||||
const allowAccountDeletion = startupConfig?.allowAccountDeletion !== false;
|
||||
|
|
@ -51,6 +52,7 @@ export function useSettingsContext(): SettingsContextValue {
|
|||
allowAccountDeletion,
|
||||
aboutEnabled,
|
||||
engineTTS,
|
||||
isAdmin,
|
||||
}),
|
||||
[
|
||||
balanceEnabled,
|
||||
|
|
@ -65,6 +67,7 @@ export function useSettingsContext(): SettingsContextValue {
|
|||
allowAccountDeletion,
|
||||
aboutEnabled,
|
||||
engineTTS,
|
||||
isAdmin,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import DisplayUsernameMessages from '../SettingsTabs/Account/DisplayUsernameMessages';
|
||||
import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitch';
|
||||
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
|
||||
import LangfuseConnection from '../SettingsTabs/Integrations/LangfuseConnection';
|
||||
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
|
||||
import { toggleControl, ThemeSetting, LangSetting } from './controls';
|
||||
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
|
||||
|
|
@ -491,6 +492,16 @@ export const registry: SettingEntry[] = [
|
|||
labelKey: 'com_ui_settings_label_revoke_keys',
|
||||
Component: RevokeKeys,
|
||||
},
|
||||
// Data controls · Integrations
|
||||
{
|
||||
id: 'langfuseConnection',
|
||||
tab: DATA,
|
||||
section: 'integrations',
|
||||
labelKey: 'com_ui_langfuse_title',
|
||||
keywords: ['langfuse', 'observability', 'tracing', 'telemetry', 'traces'],
|
||||
show: (ctx) => ctx.isAdmin,
|
||||
Component: LangfuseConnection,
|
||||
},
|
||||
// Data controls · Danger zone
|
||||
{
|
||||
id: 'deleteCache',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type SectionId =
|
|||
| 'memory'
|
||||
| 'data'
|
||||
| 'apiKeys'
|
||||
| 'integrations'
|
||||
| 'danger'
|
||||
| 'profile'
|
||||
| 'security'
|
||||
|
|
@ -46,6 +47,7 @@ export interface SettingsContextValue {
|
|||
allowAccountDeletion: boolean;
|
||||
aboutEnabled: boolean;
|
||||
engineTTS: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface SettingEntry {
|
||||
|
|
@ -112,6 +114,7 @@ export const TABS: TabMeta[] = [
|
|||
{ id: 'memory', labelKey: 'com_ui_settings_section_memory' },
|
||||
{ id: 'data', labelKey: 'com_ui_settings_section_data' },
|
||||
{ id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' },
|
||||
{ id: 'integrations', labelKey: 'com_ui_settings_section_integrations' },
|
||||
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Button, Input, Label, Switch, useToastContext } from '@librechat/client';
|
||||
import {
|
||||
useGetLangfuseConnectionQuery,
|
||||
useUpdateLangfuseConnectionMutation,
|
||||
useTestLangfuseConnectionMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function LangfuseConnection() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: status } = useGetLangfuseConnectionQuery();
|
||||
const updateMutation = useUpdateLangfuseConnectionMutation();
|
||||
const testMutation = useTestLangfuseConnectionMutation();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [publicKey, setPublicKey] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
setEnabled(status.enabled === true);
|
||||
setBaseUrl(status.baseUrl ?? '');
|
||||
setPublicKey(status.publicKey ?? '');
|
||||
}, [status]);
|
||||
|
||||
const secretConfigured = status?.configured === true;
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
const trimmedPublicKey = publicKey.trim();
|
||||
const trimmedSecretKey = secretKey.trim();
|
||||
const canSubmit =
|
||||
trimmedBaseUrl !== '' &&
|
||||
trimmedPublicKey !== '' &&
|
||||
(trimmedSecretKey !== '' || secretConfigured);
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate(
|
||||
{
|
||||
enabled,
|
||||
baseUrl: trimmedBaseUrl,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSecretKey('');
|
||||
showToast({ message: localize('com_ui_langfuse_saved'), status: 'success' });
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_langfuse_save_error'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate(
|
||||
{
|
||||
baseUrl: trimmedBaseUrl,
|
||||
publicKey: trimmedPublicKey,
|
||||
...(trimmedSecretKey ? { secretKey: trimmedSecretKey } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) =>
|
||||
showToast({
|
||||
message: result.success
|
||||
? localize('com_ui_langfuse_test_success')
|
||||
: (result.message ?? localize('com_ui_langfuse_test_error')),
|
||||
status: result.success ? 'success' : 'error',
|
||||
}),
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_langfuse_test_error'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div id="langfuse-enabled-label" className="font-medium">
|
||||
{localize('com_ui_langfuse_title')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_langfuse_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-labelledby="langfuse-enabled-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-base-url">{localize('com_ui_langfuse_base_url')}</Label>
|
||||
<Input
|
||||
id="langfuse-base-url"
|
||||
value={baseUrl}
|
||||
placeholder="https://cloud.langfuse.com"
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-public-key">{localize('com_ui_langfuse_public_key')}</Label>
|
||||
<Input
|
||||
id="langfuse-public-key"
|
||||
value={publicKey}
|
||||
placeholder="pk-lf-..."
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="langfuse-secret-key">{localize('com_ui_langfuse_secret_key')}</Label>
|
||||
<Input
|
||||
id="langfuse-secret-key"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={secretKey}
|
||||
placeholder={secretConfigured ? localize('com_ui_langfuse_secret_key_set') : 'sk-lf-...'}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{localize('com_ui_langfuse_secret_key_hint')}
|
||||
</span>
|
||||
{secretConfigured && status?.secretKeyFingerprint != null && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{localize('com_ui_langfuse_secret_key_fingerprint')}{' '}
|
||||
<code>{status.secretKeyFingerprint}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canSubmit || testMutation.isLoading}
|
||||
onClick={handleTest}
|
||||
>
|
||||
{localize('com_ui_langfuse_test')}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit || updateMutation.isLoading} onClick={handleSave}>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import LangfuseConnection from '../LangfuseConnection';
|
||||
|
||||
const mockGet = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
const mockTest = jest.fn();
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetLangfuseConnectionQuery: () => mockGet(),
|
||||
useUpdateLangfuseConnectionMutation: () => ({ mutate: mockUpdate, isLoading: false }),
|
||||
useTestLangfuseConnectionMutation: () => ({ mutate: mockTest, isLoading: false }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockTest.mockReset();
|
||||
mockGet.mockReturnValue({ data: undefined });
|
||||
});
|
||||
|
||||
describe('LangfuseConnection', () => {
|
||||
it('renders the connection form fields', () => {
|
||||
render(<LangfuseConnection />);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_langfuse_secret_key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills stored values and shows the key fingerprint without the secret', () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
expect(screen.getByLabelText('com_ui_langfuse_base_url')).toHaveValue(
|
||||
'https://cloud.langfuse.com',
|
||||
);
|
||||
expect(screen.getByLabelText('com_ui_langfuse_public_key')).toHaveValue('pk-lf-1');
|
||||
expect(screen.getByLabelText('com_ui_langfuse_secret_key')).toHaveValue('');
|
||||
expect(screen.getByText('abc123def456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes the typed secret key when saving a new connection', async () => {
|
||||
render(<LangfuseConnection />);
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_base_url'), {
|
||||
target: { value: 'https://cloud.langfuse.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_public_key'), {
|
||||
target: { value: 'pk-lf-1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('com_ui_langfuse_secret_key'), {
|
||||
target: { value: 'sk-lf-secret' },
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).toEqual({
|
||||
enabled: false,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the secret key when saving without re-entering it for an already-configured connection', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
await userEvent.click(screen.getByText('com_ui_save'));
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate.mock.calls[0][0]).not.toHaveProperty('secretKey');
|
||||
expect(mockUpdate.mock.calls[0][0]).toMatchObject({ publicKey: 'pk-lf-1' });
|
||||
});
|
||||
|
||||
it('triggers a connection test', async () => {
|
||||
mockGet.mockReturnValue({
|
||||
data: {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
},
|
||||
});
|
||||
render(<LangfuseConnection />);
|
||||
|
||||
await userEvent.click(screen.getByText('com_ui_langfuse_test'));
|
||||
|
||||
expect(mockTest).toHaveBeenCalledTimes(1);
|
||||
expect(mockTest.mock.calls[0][0]).toMatchObject({
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
45
client/src/data-provider/Langfuse/index.ts
Normal file
45
client/src/data-provider/Langfuse/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dataService, QueryKeys, MutationKeys } from 'librechat-data-provider';
|
||||
import type {
|
||||
TLangfuseConnectionStatus,
|
||||
TUpdateLangfuseConnectionRequest,
|
||||
TLangfuseConnectionTestRequest,
|
||||
TLangfuseConnectionTestResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
export const useGetLangfuseConnectionQuery = (
|
||||
enabled = true,
|
||||
): UseQueryResult<TLangfuseConnectionStatus> =>
|
||||
useQuery<TLangfuseConnectionStatus>(
|
||||
[QueryKeys.langfuseConnection],
|
||||
() => dataService.getLangfuseConnection(),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
|
||||
TLangfuseConnectionStatus,
|
||||
unknown,
|
||||
TUpdateLangfuseConnectionRequest
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(payload: TUpdateLangfuseConnectionRequest) => dataService.updateLangfuseConnection(payload),
|
||||
{
|
||||
mutationKey: [MutationKeys.updateLangfuseConnection],
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.langfuseConnection], data);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useTestLangfuseConnectionMutation = (): UseMutationResult<
|
||||
TLangfuseConnectionTestResponse,
|
||||
unknown,
|
||||
TLangfuseConnectionTestRequest
|
||||
> =>
|
||||
useMutation(
|
||||
(payload: TLangfuseConnectionTestRequest) => dataService.testLangfuseConnection(payload),
|
||||
{ mutationKey: [MutationKeys.testLangfuseConnection] },
|
||||
);
|
||||
|
|
@ -3,6 +3,7 @@ export * from './Agents';
|
|||
export * from './Endpoints';
|
||||
export * from './Skills';
|
||||
export * from './Files';
|
||||
export * from './Langfuse';
|
||||
/* Memories */
|
||||
export * from './Memories';
|
||||
export * from './Messages';
|
||||
|
|
|
|||
|
|
@ -1640,6 +1640,20 @@
|
|||
"com_ui_settings_search_placeholder": "Search settings",
|
||||
"com_ui_settings_section_accessibility": "Accessibility",
|
||||
"com_ui_settings_section_api_keys": "API keys",
|
||||
"com_ui_settings_section_integrations": "Integrations",
|
||||
"com_ui_langfuse_title": "Langfuse connection",
|
||||
"com_ui_langfuse_description": "Send this organization's traces and feedback scores to your own Langfuse project.",
|
||||
"com_ui_langfuse_base_url": "Host",
|
||||
"com_ui_langfuse_public_key": "Public key",
|
||||
"com_ui_langfuse_secret_key": "Secret key",
|
||||
"com_ui_langfuse_secret_key_set": "Saved — enter a new key to replace it",
|
||||
"com_ui_langfuse_secret_key_hint": "The secret key is encrypted at rest and is never shown again after saving.",
|
||||
"com_ui_langfuse_secret_key_fingerprint": "Configured key fingerprint:",
|
||||
"com_ui_langfuse_test": "Test connection",
|
||||
"com_ui_langfuse_saved": "Langfuse connection saved",
|
||||
"com_ui_langfuse_save_error": "Failed to save the Langfuse connection",
|
||||
"com_ui_langfuse_test_success": "Connected to Langfuse successfully",
|
||||
"com_ui_langfuse_test_error": "Could not connect to Langfuse",
|
||||
"com_ui_settings_section_appearance": "Appearance",
|
||||
"com_ui_settings_section_billing": "Billing",
|
||||
"com_ui_settings_section_commands": "Commands",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { createAdminConfigHandlers } from './config';
|
||||
export { createAdminLangfuseHandlers } from './langfuse';
|
||||
export { createAdminGrantsHandlers } from './grants';
|
||||
export { createAdminGroupsHandlers } from './groups';
|
||||
export { createAdminRolesHandlers } from './roles';
|
||||
|
|
@ -6,6 +7,7 @@ export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './sk
|
|||
export { createAdminUsersHandlers } from './users';
|
||||
export { createAdminAuditLogHandlers } from './auditLog';
|
||||
export type { AdminConfigDeps } from './config';
|
||||
export type { AdminLangfuseDeps } from './langfuse';
|
||||
export type { AdminGrantsDeps, GrantPrincipalType } from './grants';
|
||||
export type { AdminGroupsDeps } from './groups';
|
||||
export type { AdminRolesDeps } from './roles';
|
||||
|
|
|
|||
294
packages/api/src/admin/langfuse.handler.spec.ts
Normal file
294
packages/api/src/admin/langfuse.handler.spec.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
process.env.CREDS_KEY =
|
||||
process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
// Loaded via dynamic import in beforeAll so the crypto module initializes
|
||||
// after CREDS_KEY is set above (encryptV3 reads the key at module load).
|
||||
let encryptV3: typeof import('@librechat/data-schemas').encryptV3;
|
||||
let createAdminLangfuseHandlers: typeof import('./langfuse').createAdminLangfuseHandlers;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ encryptV3 } = await import('@librechat/data-schemas'));
|
||||
({ createAdminLangfuseHandlers } = await import('./langfuse'));
|
||||
});
|
||||
|
||||
function mockReq(overrides = {}) {
|
||||
return {
|
||||
user: { id: 'u1', role: 'ADMIN', tenantId: 't1' },
|
||||
params: {},
|
||||
body: {},
|
||||
query: {},
|
||||
...overrides,
|
||||
} as Partial<ServerRequest> as ServerRequest;
|
||||
}
|
||||
|
||||
interface MockRes {
|
||||
statusCode: number;
|
||||
body: undefined | Record<string, unknown>;
|
||||
status: jest.Mock;
|
||||
json: jest.Mock;
|
||||
}
|
||||
|
||||
function mockRes() {
|
||||
const res: MockRes = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
status: jest.fn((code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
json: jest.fn((data: MockRes['body']) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return res as Partial<Response> as Response & MockRes;
|
||||
}
|
||||
|
||||
function baseConfigDoc(langfuse: Record<string, unknown>) {
|
||||
return {
|
||||
_id: 'cfg1',
|
||||
principalType: 'role',
|
||||
principalId: '__base__',
|
||||
priority: 10,
|
||||
overrides: { langfuse },
|
||||
updatedAt: new Date('2026-06-29T00:00:00.000Z'),
|
||||
};
|
||||
}
|
||||
|
||||
function createHandlers(overrides = {}) {
|
||||
const deps = {
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(null),
|
||||
patchConfigFields: jest
|
||||
.fn()
|
||||
.mockImplementation((_pt, _pid, _pm, fields) =>
|
||||
Promise.resolve(baseConfigDoc(rehydrate(fields))),
|
||||
),
|
||||
invalidateConfigCaches: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
const handlers = createAdminLangfuseHandlers(deps);
|
||||
return { handlers, deps };
|
||||
}
|
||||
|
||||
/** Turn dot-path field entries into a nested langfuse object for the fake DB. */
|
||||
function rehydrate(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const langfuse: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(fields)) {
|
||||
langfuse[path.replace(/^langfuse\./, '')] = value;
|
||||
}
|
||||
return langfuse;
|
||||
}
|
||||
|
||||
describe('createAdminLangfuseHandlers', () => {
|
||||
describe('getConnection', () => {
|
||||
it('reports not configured when no base config exists', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getConnection(mockReq(), res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toMatchObject({ configured: false, enabled: false });
|
||||
expect(res.body?.secretKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns metadata only and never the secret key', async () => {
|
||||
const { handlers } = createHandlers({
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(
|
||||
baseConfigDoc({
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: encryptV3('sk-lf-secret'),
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
}),
|
||||
),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getConnection(mockReq(), res);
|
||||
|
||||
expect(res.body).toMatchObject({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKeyFingerprint: 'abc123def456',
|
||||
});
|
||||
expect(res.body?.secretKey).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain('sk-lf-secret');
|
||||
expect(JSON.stringify(res.body)).not.toContain('v3:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConnection', () => {
|
||||
it('requires baseUrl', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(mockReq({ body: { publicKey: 'pk' } }), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requires publicKey', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an invalid baseUrl', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { baseUrl: 'not-a-url', publicKey: 'pk', secretKey: 'sk' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requires a secret key on first-time configuration', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(deps.patchConfigFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encrypts the secret key, stores a fingerprint, and never returns the secret', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.updateConnection(
|
||||
mockReq({
|
||||
body: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'sk-lf-secret',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const fields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(fields['langfuse.secretKey']).toMatch(/^v3:/);
|
||||
expect(fields['langfuse.secretKey']).not.toContain('sk-lf-secret');
|
||||
expect(fields['langfuse.secretKeyFingerprint']).toMatch(/^[a-f0-9]{12}$/);
|
||||
expect(fields['langfuse.enabled']).toBe(true);
|
||||
expect(fields['langfuse.publicKey']).toBe('pk-lf-1');
|
||||
expect(res.body?.secretKey).toBeUndefined();
|
||||
expect(deps.invalidateConfigCaches).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('allows updating metadata without resupplying the secret when one is stored', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
findConfigByPrincipal: jest
|
||||
.fn()
|
||||
.mockResolvedValue(baseConfigDoc({ secretKey: encryptV3('sk-lf-secret') })),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.updateConnection(
|
||||
mockReq({
|
||||
body: { enabled: false, baseUrl: 'https://us.cloud.langfuse.com', publicKey: 'pk-2' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const fields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(fields['langfuse.secretKey']).toBeUndefined();
|
||||
expect(fields['langfuse.publicKey']).toBe('pk-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
const realFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('requires baseUrl and publicKey', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
await handlers.testConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com' } }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns success when Langfuse responds ok', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, status: 200 }) as unknown as typeof fetch;
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.testConnection(
|
||||
mockReq({
|
||||
body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const [url, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];
|
||||
expect(url).toBe('https://cloud.langfuse.com/api/public/projects');
|
||||
expect(init.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it('returns failure with status when Langfuse rejects the credentials', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 401 }) as unknown as typeof fetch;
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.testConnection(
|
||||
mockReq({
|
||||
body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk', secretKey: 'sk' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.body?.success).toBe(false);
|
||||
expect(res.body?.message).toContain('401');
|
||||
});
|
||||
|
||||
it('falls back to the stored (decrypted) secret when none is supplied', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, status: 200 }) as unknown as typeof fetch;
|
||||
const { handlers } = createHandlers({
|
||||
findConfigByPrincipal: jest
|
||||
.fn()
|
||||
.mockResolvedValue(baseConfigDoc({ secretKey: encryptV3('sk-stored') })),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.testConnection(
|
||||
mockReq({ body: { baseUrl: 'https://cloud.langfuse.com', publicKey: 'pk' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const [, init] = (global.fetch as unknown as jest.Mock).mock.calls[0];
|
||||
const decoded = Buffer.from(
|
||||
init.headers.Authorization.replace('Basic ', ''),
|
||||
'base64',
|
||||
).toString();
|
||||
expect(decoded).toBe('pk:sk-stored');
|
||||
});
|
||||
});
|
||||
});
|
||||
213
packages/api/src/admin/langfuse.ts
Normal file
213
packages/api/src/admin/langfuse.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import crypto from 'node:crypto';
|
||||
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
|
||||
import { logger, BASE_CONFIG_PRINCIPAL_ID, encryptV3, decryptV3 } from '@librechat/data-schemas';
|
||||
import type {
|
||||
TCustomConfig,
|
||||
LangfuseConfig,
|
||||
TLangfuseConnectionStatus,
|
||||
TUpdateLangfuseConnectionRequest,
|
||||
TLangfuseConnectionTestRequest,
|
||||
TLangfuseConnectionTestResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IConfig } from '@librechat/data-schemas';
|
||||
import type { Types, ClientSession } from 'mongoose';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
const DEFAULT_PRIORITY = 10;
|
||||
const FINGERPRINT_LENGTH = 12;
|
||||
|
||||
/** Short, non-reversible fingerprint of a secret so reads can show which key is
|
||||
* configured without exposing it. */
|
||||
function fingerprintSecret(secret: string): string {
|
||||
return crypto.createHash('sha256').update(secret).digest('hex').slice(0, FINGERPRINT_LENGTH);
|
||||
}
|
||||
|
||||
export interface AdminLangfuseDeps {
|
||||
findConfigByPrincipal: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
options?: { includeInactive?: boolean },
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
patchConfigFields: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
principalModel: PrincipalModel,
|
||||
fields: Record<string, unknown>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function getTenantId(req: ServerRequest): string | undefined {
|
||||
return (req.user as { tenantId?: string } | undefined)?.tenantId;
|
||||
}
|
||||
|
||||
function readStoredLangfuse(config: IConfig | null): LangfuseConfig | undefined {
|
||||
const overrides = config?.overrides as Partial<TCustomConfig> | undefined;
|
||||
return overrides?.langfuse;
|
||||
}
|
||||
|
||||
function buildStatus(config: IConfig | null): TLangfuseConnectionStatus {
|
||||
const stored = readStoredLangfuse(config);
|
||||
return {
|
||||
configured: Boolean(stored?.publicKey && stored?.secretKey),
|
||||
enabled: stored?.enabled === true,
|
||||
baseUrl: stored?.baseUrl,
|
||||
publicKey: stored?.publicKey,
|
||||
secretKeyFingerprint: stored?.secretKeyFingerprint,
|
||||
updatedAt: config?.updatedAt ? new Date(config.updatedAt).toISOString() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStoredSecret(secret?: string): string | undefined {
|
||||
if (!secret) {
|
||||
return undefined;
|
||||
}
|
||||
if (!secret.startsWith('v3:')) {
|
||||
return secret;
|
||||
}
|
||||
return decryptV3(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin handlers for the per-tenant Langfuse connection.
|
||||
*
|
||||
* The connection is stored as a `langfuse` override on the base config so it is
|
||||
* resolved for every user in the tenant. The secret key is encrypted at rest and
|
||||
* never returned by read endpoints; reads expose only non-secret metadata.
|
||||
*/
|
||||
export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
||||
getConnection: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
updateConnection: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
testConnection: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
} {
|
||||
const { findConfigByPrincipal, patchConfigFields, invalidateConfigCaches } = deps;
|
||||
|
||||
function findBaseConfig(): Promise<IConfig | null> {
|
||||
return findConfigByPrincipal(PrincipalType.ROLE, BASE_CONFIG_PRINCIPAL_ID, {
|
||||
includeInactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function getConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
try {
|
||||
const config = await findBaseConfig();
|
||||
return res.status(200).json(buildStatus(config));
|
||||
} catch (error) {
|
||||
logger.error('[adminLangfuse] getConnection error:', error);
|
||||
return res.status(500).json({ error: 'Failed to read Langfuse connection' });
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
try {
|
||||
const body = (req.body ?? {}) as TUpdateLangfuseConnectionRequest;
|
||||
const enabled = body.enabled === true;
|
||||
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
|
||||
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
|
||||
const secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
|
||||
|
||||
if (!baseUrl) {
|
||||
return res.status(400).json({ error: 'baseUrl is required' });
|
||||
}
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({ error: 'publicKey is required' });
|
||||
}
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'baseUrl must be a valid URL' });
|
||||
}
|
||||
|
||||
const existing = await findBaseConfig();
|
||||
const hasStoredSecret = Boolean(readStoredLangfuse(existing)?.secretKey);
|
||||
if (!secretKey && !hasStoredSecret) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'secretKey is required for first-time configuration' });
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {
|
||||
'langfuse.enabled': enabled,
|
||||
'langfuse.baseUrl': baseUrl,
|
||||
'langfuse.publicKey': publicKey,
|
||||
};
|
||||
if (secretKey) {
|
||||
fields['langfuse.secretKey'] = encryptV3(secretKey);
|
||||
fields['langfuse.secretKeyFingerprint'] = fingerprintSecret(secretKey);
|
||||
}
|
||||
|
||||
const updated = await patchConfigFields(
|
||||
PrincipalType.ROLE,
|
||||
BASE_CONFIG_PRINCIPAL_ID,
|
||||
PrincipalModel.ROLE,
|
||||
fields,
|
||||
existing?.priority ?? DEFAULT_PRIORITY,
|
||||
);
|
||||
|
||||
invalidateConfigCaches?.(getTenantId(req))?.catch((err) =>
|
||||
logger.error('[adminLangfuse] Cache invalidation failed after update:', err),
|
||||
);
|
||||
|
||||
return res.status(200).json(buildStatus(updated ?? existing));
|
||||
} catch (error) {
|
||||
logger.error('[adminLangfuse] updateConnection error:', error);
|
||||
return res.status(500).json({ error: 'Failed to update Langfuse connection' });
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
try {
|
||||
const body = (req.body ?? {}) as TLangfuseConnectionTestRequest;
|
||||
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
|
||||
const publicKey = typeof body.publicKey === 'string' ? body.publicKey.trim() : '';
|
||||
let secretKey = typeof body.secretKey === 'string' ? body.secretKey.trim() : '';
|
||||
|
||||
if (!baseUrl || !publicKey) {
|
||||
return res.status(400).json({ error: 'baseUrl and publicKey are required' });
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
const existing = await findBaseConfig();
|
||||
try {
|
||||
secretKey = resolveStoredSecret(readStoredLangfuse(existing)?.secretKey) ?? '';
|
||||
} catch {
|
||||
const failed: TLangfuseConnectionTestResponse = {
|
||||
success: false,
|
||||
message: 'Stored secret key could not be decrypted',
|
||||
};
|
||||
return res.status(200).json(failed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
const failed: TLangfuseConnectionTestResponse = {
|
||||
success: false,
|
||||
message: 'secretKey is required to test the connection',
|
||||
};
|
||||
return res.status(200).json(failed);
|
||||
}
|
||||
|
||||
const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64');
|
||||
const url = `${baseUrl.replace(/\/+$/, '')}/api/public/projects`;
|
||||
const response = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
|
||||
|
||||
const result: TLangfuseConnectionTestResponse = response.ok
|
||||
? { success: true }
|
||||
: { success: false, message: `Langfuse responded with status ${response.status}` };
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('[adminLangfuse] testConnection error:', error);
|
||||
const result: TLangfuseConnectionTestResponse = {
|
||||
success: false,
|
||||
message: 'Could not reach the Langfuse host',
|
||||
};
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
return { getConnection, updateConnection, testConnection };
|
||||
}
|
||||
|
|
@ -43,9 +43,6 @@ describe('buildLangfuseConfig', () => {
|
|||
publicKey: 'pk-tenant-1',
|
||||
secretKey: encryptV3('sk-tenant-1'),
|
||||
destination: 'eu',
|
||||
fanout: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as unknown as AppConfig,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -438,6 +438,10 @@ export const skillTree = ({ skillId, path = '' }: { skillId: string; path?: stri
|
|||
/* Skill active states (per-user overrides) */
|
||||
export const skillStates = () => `${BASE_URL}/api/user/settings/skills/active`;
|
||||
|
||||
/* Langfuse connection (admin) */
|
||||
export const adminLangfuseConnection = () => `${BASE_URL}/api/admin/langfuse/connection`;
|
||||
export const adminLangfuseConnectionTest = () => `${adminLangfuseConnection()}/test`;
|
||||
|
||||
/* Roles */
|
||||
export const roles = () => `${BASE_URL}/api/roles`;
|
||||
export const adminRoles = () => `${BASE_URL}/api/admin/roles`;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,22 @@ import request from './request';
|
|||
import * as s from './schemas';
|
||||
import * as r from './roles';
|
||||
|
||||
export function getLangfuseConnection(): Promise<t.TLangfuseConnectionStatus> {
|
||||
return request.get(endpoints.adminLangfuseConnection());
|
||||
}
|
||||
|
||||
export function updateLangfuseConnection(
|
||||
payload: t.TUpdateLangfuseConnectionRequest,
|
||||
): Promise<t.TLangfuseConnectionStatus> {
|
||||
return request.put(endpoints.adminLangfuseConnection(), payload);
|
||||
}
|
||||
|
||||
export function testLangfuseConnection(
|
||||
payload: t.TLangfuseConnectionTestRequest,
|
||||
): Promise<t.TLangfuseConnectionTestResponse> {
|
||||
return request.post(endpoints.adminLangfuseConnectionTest(), payload);
|
||||
}
|
||||
|
||||
export function revokeUserKey(name: string): Promise<unknown> {
|
||||
return request.delete(endpoints.revokeUserKey(name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export enum QueryKeys {
|
|||
searchConversations = 'searchConversations',
|
||||
conversation = 'conversation',
|
||||
searchEnabled = 'searchEnabled',
|
||||
langfuseConnection = 'langfuseConnection',
|
||||
user = 'user',
|
||||
name = 'name', // user key name
|
||||
models = 'models',
|
||||
|
|
@ -93,6 +94,8 @@ export const DynamicQueryKeys = {
|
|||
} as const;
|
||||
|
||||
export enum MutationKeys {
|
||||
updateLangfuseConnection = 'updateLangfuseConnection',
|
||||
testLangfuseConnection = 'testLangfuseConnection',
|
||||
createAgentApiKey = 'createAgentApiKey',
|
||||
deleteAgentApiKey = 'deleteAgentApiKey',
|
||||
fileUpload = 'fileUpload',
|
||||
|
|
|
|||
|
|
@ -830,3 +830,30 @@ export type TUpdateSkillNodeRequest = {
|
|||
parentId?: string | null;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type TLangfuseConnectionStatus = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
baseUrl?: string;
|
||||
publicKey?: string;
|
||||
secretKeyFingerprint?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type TUpdateLangfuseConnectionRequest = {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
publicKey: string;
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
export type TLangfuseConnectionTestRequest = {
|
||||
baseUrl: string;
|
||||
publicKey: string;
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
export type TLangfuseConnectionTestResponse = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue