Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/cache/distributedLock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const { ioredisClient, createDistributedLock } = require('@librechat/api');

/** The one `DistributedLock` for this process. Redis-backed when `ioredisClient` is set; otherwise an in-process lock that coordinates only callers holding this instance. */
const distributedLock = createDistributedLock({ redisClient: ioredisClient });

module.exports = { distributedLock };
18 changes: 18 additions & 0 deletions api/cache/distributedLock.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const mockLock = { acquire: jest.fn(), extend: jest.fn(), release: jest.fn() };
const mockCreateDistributedLock = jest.fn(() => mockLock);
const sentinelRedisClient = {};

jest.mock('@librechat/api', () => ({
createDistributedLock: mockCreateDistributedLock,
ioredisClient: sentinelRedisClient,
}));

describe('distributedLock', () => {
it('builds the process-wide lock once with the shared Redis client', () => {
const { distributedLock } = require('./distributedLock');

expect(mockCreateDistributedLock).toHaveBeenCalledTimes(1);
expect(mockCreateDistributedLock).toHaveBeenCalledWith({ redisClient: sentinelRedisClient });
expect(distributedLock).toBe(mockLock);
});
});
3 changes: 2 additions & 1 deletion api/cache/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const getLogStores = require('./getLogStores');
const logViolation = require('./logViolation');
const { distributedLock } = require('./distributedLock');

module.exports = { getLogStores, logViolation };
module.exports = { getLogStores, logViolation, distributedLock };
4 changes: 4 additions & 0 deletions api/server/routes/admin/langfuse.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
hasConfigCapability,
requireCapability,
} = require('~/server/middleware/roles/capabilities');
const { syncLangfusePromptCatalog } = require('~/server/services/LangfusePromptSync');
const { invalidateConfigCaches } = require('~/server/services/Config');
const configMiddleware = require('~/server/middleware/config/app');
const { requireJwtAuth } = require('~/server/middleware');
Expand All @@ -13,6 +14,7 @@ const db = require('~/models');
const router = express.Router();

const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const requireManagePrompts = requireCapability(SystemCapabilities.MANAGE_PROMPTS);

async function requireLangfuseManage(req, res, next) {
try {
Expand Down Expand Up @@ -40,6 +42,7 @@ const handlers = createAdminLangfuseHandlers({
patchConfigFields: db.patchConfigFields,
toggleConfigActive: db.toggleConfigActive,
getMessages: db.getMessages,
syncPromptCatalog: syncLangfusePromptCatalog,
invalidateConfigCaches,
recordAuditEntry: db.recordAuditEntry,
});
Expand All @@ -55,6 +58,7 @@ router.put('/connection', handlers.updateConnection);
router.post('/connection/test', handlers.testConnection);
router.put('/prompt-sync', handlers.updatePromptSync);
router.get('/prompts', handlers.listPrompts);
router.post('/prompts/sync', requireManagePrompts, handlers.syncPrompts);
Comment thread
AtefBellaaj marked this conversation as resolved.
router.get('/prompts/:name', handlers.getPrompt);

module.exports = router;
53 changes: 52 additions & 1 deletion api/server/routes/admin/langfuse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,27 @@ const mockHandlers = {
testConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'test' })),
updatePromptSync: jest.fn((_req, res) => res.status(200).json({ handler: 'prompt-sync' })),
listPrompts: jest.fn((_req, res) => res.status(200).json({ handler: 'prompts' })),
syncPrompts: jest.fn((_req, res) => res.status(200).json({ handler: 'sync' })),
getPrompt: jest.fn((req, res) =>
res.status(200).json({ handler: 'prompt', name: req.params.name }),
),
};
const mockSyncPromptCatalog = jest.fn();
const mockLogger = { warn: jest.fn(), error: jest.fn() };

jest.mock('@librechat/data-schemas', () => ({
SystemCapabilities: { ACCESS_ADMIN: 'access:admin' },
logger: mockLogger,
SystemCapabilities: { ACCESS_ADMIN: 'access:admin', MANAGE_PROMPTS: 'manage:prompts' },
}));

jest.mock('@librechat/api', () => ({
createAdminLangfuseHandlers: jest.fn(() => mockHandlers),
}));

jest.mock('~/server/services/LangfusePromptSync', () => ({
syncLangfusePromptCatalog: mockSyncPromptCatalog,
}));

jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: mockRequireCapability,
hasConfigCapability: mockHasConfigCapability,
Expand Down Expand Up @@ -78,6 +86,18 @@ describe('admin Langfuse routes', () => {
return app;
}

// The router module builds its handler dependencies once, at `require`
// time, not per request. `createApp()` only reloads the module the first
// time it runs in this file (later `beforeEach` clears already took effect
// by the time any other test observes it), so this captures that one
// load's dependency wiring before anything can clear it.
let handlerDepsAtLoad;
beforeAll(() => {
createApp();
const { createAdminLangfuseHandlers } = require('@librechat/api');
handlerDepsAtLoad = createAdminLangfuseHandlers.mock.calls[0]?.[0];
});

beforeEach(() => {
deniedCapability = undefined;
canManageLangfuse = true;
Expand Down Expand Up @@ -138,6 +158,7 @@ describe('admin Langfuse routes', () => {
['PUT', '/api/admin/langfuse/prompt-sync', 'updatePromptSync'],
['GET', '/api/admin/langfuse/prompts', 'listPrompts'],
['GET', '/api/admin/langfuse/prompts/greeting', 'getPrompt'],
['POST', '/api/admin/langfuse/prompts/sync', 'syncPrompts'],
])('blocks %s %s without Langfuse manage access', async (method, path, handlerName) => {
canManageLangfuse = false;

Expand All @@ -149,6 +170,7 @@ describe('admin Langfuse routes', () => {
it.each([
['GET', '/api/admin/langfuse/prompts', 'listPrompts'],
['GET', '/api/admin/langfuse/prompts/greeting', 'getPrompt'],
['POST', '/api/admin/langfuse/prompts/sync', 'syncPrompts'],
])('rejects %s %s for an unauthenticated caller', async (method, path, handlerName) => {
mockRequireJwtAuth.mockImplementationOnce((_req, res) => {
middlewareCalls.push('jwt');
Expand All @@ -163,6 +185,7 @@ describe('admin Langfuse routes', () => {
it.each([
['GET', '/api/admin/langfuse/prompts', 'listPrompts'],
['GET', '/api/admin/langfuse/prompts/greeting', 'getPrompt'],
['POST', '/api/admin/langfuse/prompts/sync', 'syncPrompts'],
])('rejects %s %s for a non-admin caller', async (method, path, handlerName) => {
deniedCapability = 'access:admin';

Expand Down Expand Up @@ -204,4 +227,32 @@ describe('admin Langfuse routes', () => {

expect(mockConfigMiddleware).not.toHaveBeenCalled();
});

it('passes the composed Langfuse prompt-sync runner to the handler factory, not its dependencies', () => {
expect(handlerDepsAtLoad).toMatchObject({ syncPromptCatalog: mockSyncPromptCatalog });
expect(handlerDepsAtLoad).not.toHaveProperty('lock');
expect(handlerDepsAtLoad).not.toHaveProperty('grantPermission');
expect(handlerDepsAtLoad).not.toHaveProperty('findLangfusePromptGroup');
expect(handlerDepsAtLoad).not.toHaveProperty('createLangfusePromptGroup');
expect(handlerDepsAtLoad).not.toHaveProperty('deletePromptGroup');
});

it('requires manage:prompts access, in addition to Langfuse manage access, for POST /prompts/sync', async () => {
const response = await request(createApp())
.post('/api/admin/langfuse/prompts/sync')
.send({})
.expect(200);

expect(response.body).toEqual({ handler: 'sync' });
expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'config', 'manage:prompts']);
expect(mockHandlers.syncPrompts).toHaveBeenCalledTimes(1);
});

it('blocks POST /prompts/sync for a caller without manage:prompts access', async () => {
deniedCapability = 'manage:prompts';

await request(createApp()).post('/api/admin/langfuse/prompts/sync').send({}).expect(403);

expect(mockHandlers.syncPrompts).not.toHaveBeenCalled();
});
});
17 changes: 17 additions & 0 deletions api/server/services/LangfusePromptSync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { createLangfusePromptSyncRunner } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { distributedLock } = require('~/cache');
const { grantPermission } = require('~/server/services/PermissionService');
const db = require('~/models');

/** The one Langfuse prompt-sync runner for this process, bound to the shared `distributedLock`. */
const syncLangfusePromptCatalog = createLangfusePromptSyncRunner({
lock: distributedLock,
findLangfusePromptGroup: db.findLangfusePromptGroup,
createLangfusePromptGroup: db.createLangfusePromptGroup,
deletePromptGroup: db.deletePromptGroup,
grantPermission,
logger,
});

module.exports = { syncLangfusePromptCatalog };
45 changes: 45 additions & 0 deletions api/server/services/LangfusePromptSync.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const mockLock = { acquire: jest.fn(), extend: jest.fn(), release: jest.fn() };
const mockSyncPromptCatalog = jest.fn();
const mockCreateLangfusePromptSyncRunner = jest.fn(() => mockSyncPromptCatalog);
const mockGrantPermission = jest.fn();
const mockLogger = { warn: jest.fn(), error: jest.fn() };

jest.mock('@librechat/api', () => ({
createLangfusePromptSyncRunner: mockCreateLangfusePromptSyncRunner,
}));

jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));

jest.mock('~/cache', () => ({
distributedLock: mockLock,
}));

jest.mock('~/server/services/PermissionService', () => ({
grantPermission: mockGrantPermission,
}));

jest.mock('~/models', () => ({
findLangfusePromptGroup: jest.fn(),
createLangfusePromptGroup: jest.fn(),
deletePromptGroup: jest.fn(),
}));

describe('LangfusePromptSync', () => {
it('composes the sync runner once with the shared lock and the db methods', () => {
const db = require('~/models');
const { syncLangfusePromptCatalog } = require('./LangfusePromptSync');

expect(mockCreateLangfusePromptSyncRunner).toHaveBeenCalledTimes(1);
expect(mockCreateLangfusePromptSyncRunner).toHaveBeenCalledWith({
lock: mockLock,
findLangfusePromptGroup: db.findLangfusePromptGroup,
createLangfusePromptGroup: db.createLangfusePromptGroup,
deletePromptGroup: db.deletePromptGroup,
grantPermission: mockGrantPermission,
logger: mockLogger,
});
expect(syncLangfusePromptCatalog).toBe(mockSyncPromptCatalog);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import {
useUpdateLangfuseConnectionMutation,
useTestLangfuseConnectionMutation,
useUpdateLangfusePromptSyncMutation,
useSyncLangfusePromptsMutation,
} from '~/data-provider';
import LangfusePromptsDialog from './LangfusePromptsDialog';
import { getPromptErrorMessage } from './promptErrors';
import { useLocalize } from '~/hooks';

type ConnectionTestState = 'idle' | 'unverified' | 'checking' | 'connected' | 'failed';
Expand Down Expand Up @@ -114,6 +116,7 @@ export default function LangfuseConnection() {
const updateMutation = useUpdateLangfuseConnectionMutation();
const testMutation = useTestLangfuseConnectionMutation();
const promptSyncMutation = useUpdateLangfusePromptSyncMutation();
const syncPromptsMutation = useSyncLangfusePromptsMutation();

const [connectionStatus, setConnectionStatus] = useState<TLangfuseConnectionStatus>();
const [destination, setDestination] = useState('');
Expand Down Expand Up @@ -196,7 +199,7 @@ export default function LangfuseConnection() {
trimmedPublicKey !== '' &&
((!connectionCredentialsChanged && secretConfigured) || trimmedSecretKey !== '');
const isSavingStatus = updateMutation.isLoading || promptSyncMutation.isLoading;
const busy = testMutation.isLoading || isSavingStatus;
const busy = testMutation.isLoading || isSavingStatus || syncPromptsMutation.isLoading;

useEffect(() => {
const storedConnectionTestKey = getStoredConnectionTestKey(connectionStatus);
Expand Down Expand Up @@ -390,6 +393,24 @@ export default function LangfuseConnection() {
);
};

const handleSyncPrompts = () => {
syncPromptsMutation.mutate(undefined, {
onSuccess: (result) => {
showToast({
message: localize('com_ui_langfuse_prompt_sync_result', {
0: result.created,
1: result.unchanged,
2: result.skipped,
}),
status: 'success',
});
},
onError: (error) => {
showToast({ message: getPromptErrorMessage(error, localize), status: 'error' });
},
});
};

if (isConnectionLoading && connectionStatus == null) {
return (
<div
Expand Down Expand Up @@ -587,7 +608,7 @@ export default function LangfuseConnection() {
<Switch
checked={connectionStatus.promptSync.enabled}
onCheckedChange={handlePromptSyncChange}
disabled={isSavingStatus}
disabled={isSavingStatus || syncPromptsMutation.isLoading}
Comment thread
AtefBellaaj marked this conversation as resolved.
aria-labelledby="langfuse-prompt-sync-label"
/>
</div>
Expand All @@ -597,8 +618,23 @@ export default function LangfuseConnection() {
</p>
)}
{connectionStatus.promptSync.enabled && (
<div>
<Button variant="outline" size="sm" onClick={() => setIsPromptsDialogOpen(true)}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="submit" size="sm" disabled={busy} onClick={handleSyncPrompts}>
{syncPromptsMutation.isLoading ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4" />
{localize('com_ui_langfuse_prompt_sync_running')}
</span>
) : (
localize('com_ui_langfuse_prompt_sync_run')
)}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => setIsPromptsDialogOpen(true)}
>
{localize('com_ui_langfuse_prompt_sync_open')}
</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useId, useState } from 'react';
import axios from 'axios';
import {
Button,
Input,
Expand All @@ -18,16 +17,10 @@ import {
TableRow,
Textarea,
} from '@librechat/client';
import type {
TLangfusePromptErrorBody,
TLangfusePromptErrorCode,
TLangfusePromptGetParams,
TLangfusePromptListParams,
} from 'librechat-data-provider';
import type { TLangfusePromptGetParams, TLangfusePromptListParams } from 'librechat-data-provider';
import type { FormEvent } from 'react';
import type { TranslationKeys } from '~/hooks';
import { useLangfusePromptQuery, useLangfusePromptsQuery } from '~/data-provider';
import { getResponseErrorCode } from '~/utils';
import { getPromptErrorMessage } from './promptErrors';
import { useLocalize } from '~/hooks';

type LangfusePromptsDialogProps = {
Expand All @@ -45,40 +38,6 @@ function isGetMode(value: string): value is GetMode {
return value === 'production' || value === 'version';
}

/** Codes whose whole copy is one localized sentence; `upstream` is handled separately below
* because its sentence needs the Langfuse HTTP status when the server sent one. */
const PROMPT_ERROR_MESSAGE_KEYS: Partial<Record<TLangfusePromptErrorCode, TranslationKeys>> = {
not_configured: 'com_ui_langfuse_prompt_error_not_configured',
unauthorized: 'com_ui_langfuse_prompt_error_unauthorized',
timeout: 'com_ui_langfuse_prompt_error_timeout',
invalid_response: 'com_ui_langfuse_prompt_error_invalid_response',
not_found: 'com_ui_langfuse_prompt_error_not_found',
unsupported_type: 'com_ui_langfuse_prompt_error_unsupported_type',
invalid_request: 'com_ui_langfuse_prompt_error_invalid_request',
};

function getUpstreamStatus(error: unknown): number | undefined {
if (!axios.isAxiosError<TLangfusePromptErrorBody>(error)) {
return undefined;
}
const status = error.response?.data?.status;
return typeof status === 'number' ? status : undefined;
}

/** Maps a failed list or get call to one short localized line, with a generic fallback for an
* unmapped or missing code (for example a bare 404 with no body, which means the feature is off). */
function getPromptErrorMessage(error: unknown, localize: ReturnType<typeof useLocalize>): string {
const code = getResponseErrorCode<TLangfusePromptErrorCode>(error);
if (code === 'upstream') {
const status = getUpstreamStatus(error);
return status != null
? localize('com_ui_langfuse_prompt_error_upstream_status', { 0: status })
: localize('com_ui_langfuse_prompt_error_upstream');
}
const messageKey = code != null ? PROMPT_ERROR_MESSAGE_KEYS[code] : undefined;
return localize(messageKey ?? 'com_ui_langfuse_prompt_error_fallback');
}

/** A whole, positive integer, the way page, limit and version numbers must read. Rejects
* fractional input (`"1.5"`) and blanks instead of silently rounding or defaulting. */
function parsePositiveInt(value: string): number | undefined {
Expand Down
Loading
Loading