🪢 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:
Ravi Kumar L 2026-08-13 04:25:23 +02:00 committed by GitHub
parent 5ff282f900
commit 9980b6221f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 377 additions and 17 deletions

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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}
/>
);
});

View file

@ -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]);
},
},
);

View file

@ -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.",

View file

@ -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();

View file

@ -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 };
}

View file

@ -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),

View file

@ -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`;

View file

@ -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));
}

View file

@ -9,6 +9,7 @@ export enum QueryKeys {
conversation = 'conversation',
searchEnabled = 'searchEnabled',
langfuseConnection = 'langfuseConnection',
langfuseSessionLink = 'langfuseSessionLink',
user = 'user',
name = 'name', // user key name
models = 'models',

View file

@ -940,3 +940,7 @@ export type TLangfuseConnectionTestErrorCode =
export type TLangfuseConnectionTestResponse =
| { success: true }
| { success: false; errorCode: TLangfuseConnectionTestErrorCode };
export type TLangfuseSessionLinkResponse = {
url: string | null;
};