mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🪢 feat: add Langfuse session links (#14776)
* feat: add Langfuse session links * fix: tighten Langfuse session link resolution * fix: clear stale Langfuse session links * test: verify tenant Langfuse session links * fix: align Langfuse link with client conventions
This commit is contained in:
parent
5ff282f900
commit
9980b6221f
14 changed files with 377 additions and 17 deletions
|
|
@ -38,12 +38,14 @@ const handlers = createAdminLangfuseHandlers({
|
|||
findConfigByPrincipal: db.findConfigByPrincipal,
|
||||
patchConfigFields: db.patchConfigFields,
|
||||
toggleConfigActive: db.toggleConfigActive,
|
||||
getMessages: db.getMessages,
|
||||
invalidateConfigCaches,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth, requireAdminAccess, requireLangfuseManage);
|
||||
|
||||
router.get('/connection', handlers.getConnection);
|
||||
router.get('/connection/session/:conversationId', handlers.getSessionLink);
|
||||
router.put('/connection', handlers.updateConnection);
|
||||
router.post('/connection/test', handlers.testConnection);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const mockRequireCapability = jest.fn((capability) => (req, res, next) => {
|
|||
});
|
||||
const mockHandlers = {
|
||||
getConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'get' })),
|
||||
getSessionLink: jest.fn((_req, res) => res.status(200).json({ handler: 'session' })),
|
||||
updateConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'update' })),
|
||||
testConnection: jest.fn((_req, res) => res.status(200).json({ handler: 'test' })),
|
||||
};
|
||||
|
|
@ -48,6 +49,7 @@ jest.mock('~/models', () => ({
|
|||
findConfigByPrincipal: jest.fn(),
|
||||
patchConfigFields: jest.fn(),
|
||||
toggleConfigActive: jest.fn(),
|
||||
getMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('admin Langfuse routes', () => {
|
||||
|
|
@ -85,24 +87,32 @@ describe('admin Langfuse routes', () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
['GET', '/api/admin/langfuse/connection/session/conversation-1', 'getSessionLink'],
|
||||
['PUT', '/api/admin/langfuse/connection', 'updateConnection'],
|
||||
['POST', '/api/admin/langfuse/connection/test', 'testConnection'],
|
||||
])('requires Langfuse manage access for %s %s', async (method, path, handlerName) => {
|
||||
const app = createApp();
|
||||
const response = await request(app)[method.toLowerCase()](path).send({}).expect(200);
|
||||
const expectedHandlers = {
|
||||
getSessionLink: 'session',
|
||||
updateConnection: 'update',
|
||||
testConnection: 'test',
|
||||
};
|
||||
|
||||
expect(response.body).toEqual({
|
||||
handler: handlerName === 'updateConnection' ? 'update' : 'test',
|
||||
});
|
||||
expect(response.body).toEqual({ handler: expectedHandlers[handlerName] });
|
||||
expect(middlewareCalls).toEqual(['jwt', 'access:admin']);
|
||||
expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('blocks updates when the user lacks Langfuse manage access', async () => {
|
||||
it.each([
|
||||
['GET', '/api/admin/langfuse/connection/session/conversation-1', 'getSessionLink'],
|
||||
['PUT', '/api/admin/langfuse/connection', 'updateConnection'],
|
||||
['POST', '/api/admin/langfuse/connection/test', 'testConnection'],
|
||||
])('blocks %s %s without Langfuse manage access', async (method, path, handlerName) => {
|
||||
canManageLangfuse = false;
|
||||
|
||||
await request(createApp()).put('/api/admin/langfuse/connection').send({}).expect(403);
|
||||
await request(createApp())[method.toLowerCase()](path).send({}).expect(403);
|
||||
|
||||
expect(mockHandlers.updateConnection).not.toHaveBeenCalled();
|
||||
expect(mockHandlers[handlerName]).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
|
||||
import Breakdown from './Breakdown';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const view = {
|
||||
usedTokens: 10,
|
||||
percent: 0,
|
||||
isEstimate: true,
|
||||
snapshot: null,
|
||||
snapshotActive: false,
|
||||
branchTotals: {
|
||||
input: 10,
|
||||
output: 0,
|
||||
counted: 1,
|
||||
total: 1,
|
||||
estTokens: 0,
|
||||
tailEstTokens: 0,
|
||||
containsAnchor: false,
|
||||
summaryBaseline: 0,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costKnown: true },
|
||||
tailId: null,
|
||||
},
|
||||
branchUsage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costKnown: true },
|
||||
totalUsage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, costKnown: true },
|
||||
hasUsage: true,
|
||||
branchCost: 0,
|
||||
totalCost: 0,
|
||||
liveTokens: 0,
|
||||
estimatedTokens: 0,
|
||||
overheadTokens: 0,
|
||||
messageTokens: 10,
|
||||
messagesPruned: false,
|
||||
} as TokenUsageView;
|
||||
|
||||
describe('TokenUsage Breakdown', () => {
|
||||
it('renders the Langfuse session as an external link when available', () => {
|
||||
const url = 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1';
|
||||
|
||||
render(<Breakdown view={view} showCost={false} langfuseSessionUrl={url} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'com_ui_langfuse_view_session' })).toHaveAttribute(
|
||||
'href',
|
||||
url,
|
||||
);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('omits the Langfuse session link when no traced message is available', () => {
|
||||
render(<Breakdown view={view} showCost={false} />);
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { Button } from '@librechat/client';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage';
|
||||
import type { CurrencyConfig } from '~/utils';
|
||||
import { groupToolTokens, formatTokens, formatCost } from '~/utils';
|
||||
|
|
@ -30,9 +32,15 @@ interface BreakdownProps {
|
|||
view: TokenUsageView;
|
||||
showCost: boolean;
|
||||
currency?: CurrencyConfig;
|
||||
langfuseSessionUrl?: string;
|
||||
}
|
||||
|
||||
export default function Breakdown({ view, showCost, currency }: BreakdownProps) {
|
||||
export default function Breakdown({
|
||||
view,
|
||||
showCost,
|
||||
currency,
|
||||
langfuseSessionUrl,
|
||||
}: BreakdownProps) {
|
||||
const localize = useLocalize();
|
||||
const { usedTokens, maxTokens, percent, snapshot, snapshotActive, branchUsage, hasUsage } = view;
|
||||
/** Show the all-branches total only when it (a) exceeds the active branch —
|
||||
|
|
@ -215,6 +223,18 @@ export default function Breakdown({ view, showCost, currency }: BreakdownProps)
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{langfuseSessionUrl && (
|
||||
<>
|
||||
<div className="border-t border-border-light" role="separator" />
|
||||
<Button asChild variant="link" className="h-auto w-full justify-between gap-2 p-0">
|
||||
<a href={langfuseSessionUrl} target="_blank" rel="noopener noreferrer">
|
||||
<span>{localize('com_ui_langfuse_view_session')}</span>
|
||||
<ExternalLink className="size-4 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { memo, useRef } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { CurrencyConfig } from '~/utils';
|
||||
import { useGetLangfuseSessionLinkQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import { formatTokens, formatCost, cn } from '~/utils';
|
||||
import useTokenUsage from '~/hooks/Chat/useTokenUsage';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Breakdown from './Breakdown';
|
||||
import Gauge from './Gauge';
|
||||
|
|
@ -22,14 +23,29 @@ function TokenUsageIndicator({
|
|||
isSubmitting,
|
||||
showCost,
|
||||
currency,
|
||||
langfuseConnectionAccess,
|
||||
}: TokenUsageProps & {
|
||||
showCost: boolean;
|
||||
currency?: CurrencyConfig;
|
||||
langfuseConnectionAccess: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const view = useTokenUsage({ index, conversation, isSubmitting });
|
||||
const popover = Ariakit.usePopoverStore({ placement: 'top' });
|
||||
const popoverOpen = Ariakit.useStoreState(popover, 'open');
|
||||
const disclosureRef = useRef<HTMLButtonElement>(null);
|
||||
const conversationId = conversation?.conversationId ?? '';
|
||||
const canResolveLangfuseSession =
|
||||
langfuseConnectionAccess &&
|
||||
popoverOpen &&
|
||||
!isSubmitting &&
|
||||
conversationId !== '' &&
|
||||
conversationId !== Constants.NEW_CONVO &&
|
||||
conversationId !== Constants.PENDING_CONVO;
|
||||
const { data: langfuseSession } = useGetLangfuseSessionLinkQuery(
|
||||
conversationId,
|
||||
canResolveLangfuseSession,
|
||||
);
|
||||
|
||||
/** Hide until the branch has data — keeps a fresh, message-less chat clean and
|
||||
* lets the indicator animate into view once the first tokens land. */
|
||||
|
|
@ -104,7 +120,12 @@ function TokenUsageIndicator({
|
|||
aria-label={localize('com_ui_context_usage')}
|
||||
className="z-[200] rounded-xl border border-border-medium bg-surface-secondary p-3 shadow-lg focus:outline-none"
|
||||
>
|
||||
<Breakdown view={view} showCost={showCost} currency={currency} />
|
||||
<Breakdown
|
||||
view={view}
|
||||
showCost={showCost}
|
||||
currency={currency}
|
||||
langfuseSessionUrl={langfuseSession?.url ?? undefined}
|
||||
/>
|
||||
</Ariakit.Popover>
|
||||
</>
|
||||
);
|
||||
|
|
@ -124,6 +145,7 @@ const TokenUsage = memo(function TokenUsage(props: TokenUsageProps) {
|
|||
{...props}
|
||||
showCost={startupConfig.interface?.contextCost === true}
|
||||
currency={startupConfig.interface?.currency}
|
||||
langfuseConnectionAccess={startupConfig.langfuseConnectionAccess === true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
TUpdateLangfuseConnectionRequest,
|
||||
TLangfuseConnectionTestRequest,
|
||||
TLangfuseConnectionTestResponse,
|
||||
TLangfuseSessionLinkResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
|
|
@ -17,6 +18,16 @@ export const useGetLangfuseConnectionQuery = (
|
|||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
export const useGetLangfuseSessionLinkQuery = (
|
||||
conversationId: string,
|
||||
enabled = true,
|
||||
): UseQueryResult<TLangfuseSessionLinkResponse> =>
|
||||
useQuery<TLangfuseSessionLinkResponse>(
|
||||
[QueryKeys.langfuseSessionLink, conversationId],
|
||||
() => dataService.getLangfuseSessionLink(conversationId),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
|
||||
TLangfuseConnectionStatus,
|
||||
unknown,
|
||||
|
|
@ -29,6 +40,7 @@ export const useUpdateLangfuseConnectionMutation = (): UseMutationResult<
|
|||
mutationKey: [MutationKeys.updateLangfuseConnection],
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.langfuseConnection], data);
|
||||
queryClient.removeQueries([QueryKeys.langfuseSessionLink]);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1410,6 +1410,7 @@
|
|||
"com_ui_langfuse_test_unexpected_response": "Langfuse returned an unexpected response",
|
||||
"com_ui_langfuse_testing": "Testing connection",
|
||||
"com_ui_langfuse_title": "Langfuse connection",
|
||||
"com_ui_langfuse_view_session": "View session in Langfuse",
|
||||
"com_ui_latest": "latest",
|
||||
"com_ui_latest_activity": "Latest activity",
|
||||
"com_ui_latest_footer": "Every AI for Everyone.",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { ServerRequest } from '~/types/http';
|
|||
// 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;
|
||||
let getLangfuseDestinationId: typeof import('../langfuse/destinations').getLangfuseDestinationId;
|
||||
const realFetch = global.fetch;
|
||||
|
||||
function projectResponse(projectId = 'project-1') {
|
||||
|
|
@ -21,6 +22,7 @@ function projectResponse(projectId = 'project-1') {
|
|||
beforeAll(async () => {
|
||||
({ encryptV3 } = await import('@librechat/data-schemas'));
|
||||
({ createAdminLangfuseHandlers } = await import('./langfuse'));
|
||||
({ getLangfuseDestinationId } = await import('../langfuse/destinations'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -33,6 +35,7 @@ beforeEach(() => {
|
|||
afterEach(() => {
|
||||
delete process.env.LANGFUSE_FANOUT_ENABLED;
|
||||
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
|
||||
delete process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL;
|
||||
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
|
|
@ -101,6 +104,7 @@ function createHandlers(overrides = {}) {
|
|||
isActive,
|
||||
}),
|
||||
),
|
||||
getMessages: jest.fn().mockResolvedValue([]),
|
||||
invalidateConfigCaches: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -297,6 +301,160 @@ describe('createAdminLangfuseHandlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getSessionLink', () => {
|
||||
const storedConnection = {
|
||||
enabled: true,
|
||||
destination: 'eu',
|
||||
projectId: 'project-1',
|
||||
publicKey: 'pk-lf-1',
|
||||
secretKey: 'encrypted-secret',
|
||||
};
|
||||
|
||||
it('returns the session URL when this user has a sampled message for the project', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(storedConnection)),
|
||||
getMessages: jest.fn().mockResolvedValue([{ _id: 'message-1' }]),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
url: 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1',
|
||||
});
|
||||
expect(deps.getMessages).toHaveBeenCalledWith(
|
||||
{
|
||||
user: 'u1',
|
||||
conversationId: 'conversation-1',
|
||||
langfuseSampled: true,
|
||||
langfuseDestinationIds: getLangfuseDestinationId(
|
||||
'https://cloud.langfuse.com',
|
||||
'project-1',
|
||||
),
|
||||
},
|
||||
'_id',
|
||||
{ sort: false, limit: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("links to the tenant project resolved from that tenant's API keys in fanout mode", async () => {
|
||||
let persistedConfig: ReturnType<typeof baseConfigDoc> | null = null;
|
||||
const findConfigByPrincipal = jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(persistedConfig));
|
||||
const patchConfigFields = jest.fn().mockImplementation((_pt, _pid, _pm, fields) => {
|
||||
persistedConfig = baseConfigDoc(rehydrate(fields));
|
||||
return Promise.resolve(persistedConfig);
|
||||
});
|
||||
const getMessages = jest.fn().mockResolvedValue([{ _id: 'message-1' }]);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(projectResponse('tenant-project-1')) as unknown as typeof fetch;
|
||||
const { handlers } = createHandlers({
|
||||
findConfigByPrincipal,
|
||||
patchConfigFields,
|
||||
getMessages,
|
||||
});
|
||||
|
||||
const updateRes = mockRes();
|
||||
await handlers.updateConnection(
|
||||
mockReq({
|
||||
body: {
|
||||
enabled: true,
|
||||
destination: 'eu',
|
||||
publicKey: 'pk-lf-tenant',
|
||||
secretKey: 'sk-lf-tenant',
|
||||
},
|
||||
}),
|
||||
updateRes,
|
||||
);
|
||||
|
||||
expect(updateRes.statusCode).toBe(200);
|
||||
const [projectsUrl, projectsInit] = (global.fetch as unknown as jest.Mock).mock.calls[0];
|
||||
expect(projectsUrl).toBe('https://cloud.langfuse.com/api/public/projects');
|
||||
expect(
|
||||
Buffer.from(projectsInit.headers.Authorization.replace('Basic ', ''), 'base64').toString(),
|
||||
).toBe('pk-lf-tenant:sk-lf-tenant');
|
||||
expect(patchConfigFields.mock.calls[0][3]['langfuse.projectId']).toBe('tenant-project-1');
|
||||
|
||||
const linkRes = mockRes();
|
||||
await handlers.getSessionLink(
|
||||
mockReq({ params: { conversationId: 'conversation-1' } }),
|
||||
linkRes,
|
||||
);
|
||||
|
||||
expect(linkRes.body).toEqual({
|
||||
url: 'https://cloud.langfuse.com/project/tenant-project-1/sessions/conversation-1',
|
||||
});
|
||||
expect(getMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
langfuseDestinationIds: getLangfuseDestinationId(
|
||||
'https://cloud.langfuse.com',
|
||||
'tenant-project-1',
|
||||
),
|
||||
}),
|
||||
'_id',
|
||||
{ sort: false, limit: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a destination base path in the session URL', async () => {
|
||||
process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL = 'https://langfuse.example/base/path';
|
||||
const { handlers } = createHandlers({
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(storedConnection)),
|
||||
getMessages: jest.fn().mockResolvedValue([{ _id: 'message-1' }]),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
|
||||
|
||||
expect(res.body).toEqual({
|
||||
url: 'https://langfuse.example/base/path/project/project-1/sessions/conversation-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 when the authenticated user is missing', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getSessionLink(
|
||||
mockReq({ user: undefined, params: { conversationId: 'conversation-1' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Authentication required' });
|
||||
});
|
||||
|
||||
it('does not link a conversation without a sampled message for the current project', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(baseConfigDoc(storedConnection)),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ url: null });
|
||||
expect(deps.getMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not query messages when the saved connection is disabled', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
findConfigByPrincipal: jest
|
||||
.fn()
|
||||
.mockResolvedValue(baseConfigDoc({ ...storedConnection, enabled: false })),
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.getSessionLink(mockReq({ params: { conversationId: 'conversation-1' } }), res);
|
||||
|
||||
expect(res.body).toEqual({ url: null });
|
||||
expect(deps.getMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConnection', () => {
|
||||
it('requires destination', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import type {
|
|||
TLangfuseConnectionTestErrorCode,
|
||||
TLangfuseConnectionTestRequest,
|
||||
TLangfuseConnectionTestResponse,
|
||||
TLangfuseSessionLinkResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IConfig } from '@librechat/data-schemas';
|
||||
import type { IConfig, MessageMethods } from '@librechat/data-schemas';
|
||||
import type { Types, ClientSession } from 'mongoose';
|
||||
import type { Response } from 'express';
|
||||
import type { LangfuseTenantDestination } from '~/langfuse/tenantDestinations';
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
resolveLangfuseTenantDestination,
|
||||
} from '~/langfuse/tenantDestinations';
|
||||
import { decryptConfigSecret, encryptConfigSecretFields } from './secrets';
|
||||
import { getLangfuseDestinationId } from '~/langfuse/destinations';
|
||||
import { isLangfuseConnectionAvailable } from '~/langfuse/policy';
|
||||
|
||||
const DEFAULT_PRIORITY = 10;
|
||||
|
|
@ -46,6 +48,7 @@ export interface AdminLangfuseDeps {
|
|||
isActive: boolean,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
getMessages: MessageMethods['getMessages'];
|
||||
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -223,11 +226,17 @@ async function verifyLangfuseCredentials(
|
|||
*/
|
||||
export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
||||
getConnection: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
getSessionLink: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
updateConnection: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
testConnection: (req: ServerRequest, res: Response) => Promise<Response>;
|
||||
} {
|
||||
const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } =
|
||||
deps;
|
||||
const {
|
||||
findConfigByPrincipal,
|
||||
patchConfigFields,
|
||||
toggleConfigActive,
|
||||
getMessages,
|
||||
invalidateConfigCaches,
|
||||
} = deps;
|
||||
|
||||
function findBaseConfig(options?: { includeInactive?: boolean }): Promise<IConfig | null> {
|
||||
return options
|
||||
|
|
@ -250,6 +259,57 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
}
|
||||
}
|
||||
|
||||
async function getSessionLink(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const disabledResponse = rejectWhenConnectionUnavailable(res);
|
||||
if (disabledResponse) {
|
||||
return disabledResponse;
|
||||
}
|
||||
|
||||
const conversationId = (req.params as { conversationId?: string }).conversationId?.trim();
|
||||
const userId = req.user?.id ?? req.user?._id?.toString();
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
if (!conversationId) {
|
||||
return res.status(400).json({ error: 'conversationId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = readStoredLangfuse(await findBaseConfig());
|
||||
const destination = resolveLangfuseTenantDestination(stored?.destination);
|
||||
const projectId = stored?.projectId?.trim();
|
||||
if (stored?.enabled !== true || !destination || !projectId) {
|
||||
const response: TLangfuseSessionLinkResponse = { url: null };
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
|
||||
const destinationId = getLangfuseDestinationId(destination.baseUrl, projectId);
|
||||
const messages = await getMessages(
|
||||
{
|
||||
user: userId,
|
||||
conversationId,
|
||||
langfuseSampled: true,
|
||||
langfuseDestinationIds: destinationId,
|
||||
},
|
||||
'_id',
|
||||
{ sort: false, limit: 1 },
|
||||
);
|
||||
if (messages.length === 0) {
|
||||
const response: TLangfuseSessionLinkResponse = { url: null };
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
|
||||
const sessionUrl = new URL(destination.baseUrl);
|
||||
const basePath = sessionUrl.pathname.replace(/\/+$/, '');
|
||||
sessionUrl.pathname = `${basePath}/project/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(conversationId)}`;
|
||||
const response: TLangfuseSessionLinkResponse = { url: sessionUrl.toString() };
|
||||
return res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error('[adminLangfuse] getSessionLink error:', error);
|
||||
return res.status(500).json({ error: 'Failed to resolve Langfuse session' });
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConnection(req: ServerRequest, res: Response): Promise<Response> {
|
||||
const disabledResponse = rejectWhenConnectionUnavailable(res);
|
||||
if (disabledResponse) {
|
||||
|
|
@ -418,5 +478,5 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): {
|
|||
}
|
||||
}
|
||||
|
||||
return { getConnection, updateConnection, testConnection };
|
||||
return { getConnection, getSessionLink, updateConnection, testConnection };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export type LangfuseScoreDestination = {
|
|||
authorization: string;
|
||||
};
|
||||
|
||||
function getDestinationId(baseUrl: string, projectId: string): string {
|
||||
export function getLangfuseDestinationId(baseUrl: string, projectId: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${baseUrl.replace(/\/+$/, '')}\n${projectId}`)
|
||||
.digest('hex');
|
||||
|
|
@ -127,7 +127,7 @@ async function getCentralScoreDestination(
|
|||
const baseUrl = getCentralEnvBaseUrl();
|
||||
const projectId = await resolveCentralProjectId(baseUrl, publicKey, secretKey, waitForProjectId);
|
||||
return {
|
||||
id: projectId ? getDestinationId(baseUrl, projectId) : undefined,
|
||||
id: projectId ? getLangfuseDestinationId(baseUrl, projectId) : undefined,
|
||||
name: 'central',
|
||||
baseUrl,
|
||||
authorization: toBasicAuthorization(publicKey, secretKey),
|
||||
|
|
@ -161,7 +161,9 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat
|
|||
}
|
||||
|
||||
return {
|
||||
id: config?.projectId ? getDestinationId(destination.baseUrl, config.projectId) : undefined,
|
||||
id: config?.projectId
|
||||
? getLangfuseDestinationId(destination.baseUrl, config.projectId)
|
||||
: undefined,
|
||||
name: 'tenant',
|
||||
baseUrl: destination.baseUrl,
|
||||
authorization: toBasicAuthorization(tenantCredentials.publicKey, tenantCredentials.secretKey),
|
||||
|
|
@ -183,7 +185,9 @@ function getConfiguredScoreDestination(
|
|||
}
|
||||
|
||||
return {
|
||||
id: config?.projectId ? getDestinationId(destination.baseUrl, config.projectId) : undefined,
|
||||
id: config?.projectId
|
||||
? getLangfuseDestinationId(destination.baseUrl, config.projectId)
|
||||
: undefined,
|
||||
name: 'connection',
|
||||
baseUrl: destination.baseUrl,
|
||||
authorization: toBasicAuthorization(credentials.publicKey, credentials.secretKey),
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@ 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`;
|
||||
export const adminLangfuseSessionLink = (conversationId: string) =>
|
||||
`${adminLangfuseConnection()}/session/${encodeURIComponent(conversationId)}`;
|
||||
|
||||
/* Tool favorites (starred marketplace items) */
|
||||
export const toolFavorites = () => `${BASE_URL}/api/user/settings/favorites/tools`;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export function testLangfuseConnection(
|
|||
return request.post(endpoints.adminLangfuseConnectionTest(), payload);
|
||||
}
|
||||
|
||||
export function getLangfuseSessionLink(
|
||||
conversationId: string,
|
||||
): Promise<t.TLangfuseSessionLinkResponse> {
|
||||
return request.get(endpoints.adminLangfuseSessionLink(conversationId));
|
||||
}
|
||||
|
||||
export function revokeUserKey(name: string): Promise<unknown> {
|
||||
return request.delete(endpoints.revokeUserKey(name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export enum QueryKeys {
|
|||
conversation = 'conversation',
|
||||
searchEnabled = 'searchEnabled',
|
||||
langfuseConnection = 'langfuseConnection',
|
||||
langfuseSessionLink = 'langfuseSessionLink',
|
||||
user = 'user',
|
||||
name = 'name', // user key name
|
||||
models = 'models',
|
||||
|
|
|
|||
|
|
@ -940,3 +940,7 @@ export type TLangfuseConnectionTestErrorCode =
|
|||
export type TLangfuseConnectionTestResponse =
|
||||
| { success: true }
|
||||
| { success: false; errorCode: TLangfuseConnectionTestErrorCode };
|
||||
|
||||
export type TLangfuseSessionLinkResponse = {
|
||||
url: string | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue