diff --git a/api/server/routes/admin/langfuse.js b/api/server/routes/admin/langfuse.js index 2508af2a7c..a8ab284dfd 100644 --- a/api/server/routes/admin/langfuse.js +++ b/api/server/routes/admin/langfuse.js @@ -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); diff --git a/api/server/routes/admin/langfuse.test.js b/api/server/routes/admin/langfuse.test.js index 76221f05cd..10b4d26629 100644 --- a/api/server/routes/admin/langfuse.test.js +++ b/api/server/routes/admin/langfuse.test.js @@ -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(); }); }); diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx new file mode 100644 index 0000000000..cf5eff2c97 --- /dev/null +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx @@ -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(); + + 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(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx index 6d1f4d9afb..ec9a7ee462 100644 --- a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx @@ -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) )} + + {langfuseSessionUrl && ( + <> +
+ + + )}
); } diff --git a/client/src/components/Chat/Input/TokenUsage/index.tsx b/client/src/components/Chat/Input/TokenUsage/index.tsx index b8118e0c4b..21128dc78b 100644 --- a/client/src/components/Chat/Input/TokenUsage/index.tsx +++ b/client/src/components/Chat/Input/TokenUsage/index.tsx @@ -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(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" > - + ); @@ -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} /> ); }); diff --git a/client/src/data-provider/Langfuse/index.ts b/client/src/data-provider/Langfuse/index.ts index 75edb51236..7c267134ed 100644 --- a/client/src/data-provider/Langfuse/index.ts +++ b/client/src/data-provider/Langfuse/index.ts @@ -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 => + useQuery( + [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]); }, }, ); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 8db48f731a..1685bfcb4b 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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.", diff --git a/packages/api/src/admin/langfuse.handler.spec.ts b/packages/api/src/admin/langfuse.handler.spec.ts index c08b9af7f7..b316aca328 100644 --- a/packages/api/src/admin/langfuse.handler.spec.ts +++ b/packages/api/src/admin/langfuse.handler.spec.ts @@ -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 | 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(); diff --git a/packages/api/src/admin/langfuse.ts b/packages/api/src/admin/langfuse.ts index 8ae3c6dbc9..f7906d34f4 100644 --- a/packages/api/src/admin/langfuse.ts +++ b/packages/api/src/admin/langfuse.ts @@ -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; + getMessages: MessageMethods['getMessages']; invalidateConfigCaches?: (tenantId?: string) => Promise; } @@ -223,11 +226,17 @@ async function verifyLangfuseCredentials( */ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { getConnection: (req: ServerRequest, res: Response) => Promise; + getSessionLink: (req: ServerRequest, res: Response) => Promise; updateConnection: (req: ServerRequest, res: Response) => Promise; testConnection: (req: ServerRequest, res: Response) => Promise; } { - const { findConfigByPrincipal, patchConfigFields, toggleConfigActive, invalidateConfigCaches } = - deps; + const { + findConfigByPrincipal, + patchConfigFields, + toggleConfigActive, + getMessages, + invalidateConfigCaches, + } = deps; function findBaseConfig(options?: { includeInactive?: boolean }): Promise { return options @@ -250,6 +259,57 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { } } + async function getSessionLink(req: ServerRequest, res: Response): Promise { + 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 { const disabledResponse = rejectWhenConnectionUnavailable(res); if (disabledResponse) { @@ -418,5 +478,5 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { } } - return { getConnection, updateConnection, testConnection }; + return { getConnection, getSessionLink, updateConnection, testConnection }; } diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts index 40562198d1..d682347a69 100644 --- a/packages/api/src/langfuse/destinations.ts +++ b/packages/api/src/langfuse/destinations.ts @@ -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), diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 000a9079e2..a286db2a24 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -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`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 9eea22588e..f132b8e90e 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -32,6 +32,12 @@ export function testLangfuseConnection( return request.post(endpoints.adminLangfuseConnectionTest(), payload); } +export function getLangfuseSessionLink( + conversationId: string, +): Promise { + return request.get(endpoints.adminLangfuseSessionLink(conversationId)); +} + export function revokeUserKey(name: string): Promise { return request.delete(endpoints.revokeUserKey(name)); } diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index f0a122da5d..aec853c863 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -9,6 +9,7 @@ export enum QueryKeys { conversation = 'conversation', searchEnabled = 'searchEnabled', langfuseConnection = 'langfuseConnection', + langfuseSessionLink = 'langfuseSessionLink', user = 'user', name = 'name', // user key name models = 'models', diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index d9dbfe8390..3c54252012 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -940,3 +940,7 @@ export type TLangfuseConnectionTestErrorCode = export type TLangfuseConnectionTestResponse = | { success: true } | { success: false; errorCode: TLangfuseConnectionTestErrorCode }; + +export type TLangfuseSessionLinkResponse = { + url: string | null; +};