diff --git a/ui/litellm-dashboard/src/components/lens/LensDemoContext.tsx b/ui/litellm-dashboard/src/components/lens/LensDemoContext.tsx index dd34511bc09..fc7fbbdb65c 100644 --- a/ui/litellm-dashboard/src/components/lens/LensDemoContext.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensDemoContext.tsx @@ -1,10 +1,8 @@ "use client"; import { createContext, useContext } from "react"; -import type { ApiClient } from "@/lib/http/client"; export interface LensDemo { - client: ApiClient; copyTrace: (traceId: string, spanId?: string) => string; } diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx index a0115af6636..0a3d3ae6a0d 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx @@ -8,6 +8,7 @@ import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage"; import { DemoNotice } from "@/components/shared/DemoNotice"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LensDemoContext, useLensDemo } from "./LensDemoContext"; +import { LensServicesContext } from "./services"; import { LensPreviewTarget } from "./LensPreviewButton"; import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles"; import { InvestigationsView } from "./investigations/InvestigationsView"; @@ -37,11 +38,13 @@ function DemoSession({ initialTab, onExit }: { initialTab: Tab; onExit: () => vo [client], ); return ( - - - - - + + + + + + + ); } diff --git a/ui/litellm-dashboard/src/components/lens/api/mutations.ts b/ui/litellm-dashboard/src/components/lens/api/mutations.ts index 0d2797f5456..0bc9109b1bb 100644 --- a/ui/litellm-dashboard/src/components/lens/api/mutations.ts +++ b/ui/litellm-dashboard/src/components/lens/api/mutations.ts @@ -3,54 +3,35 @@ import { validateWorkerAddress, analysisAccessSchema, type AnalysisAccess } from "../setup/worker/workerSchema"; import { useMutation } from "@tanstack/react-query"; -import type { ApiClient } from "@/lib/http/client"; -import type { Lens, Settings, WorkerCreated, LensList } from "../model/types"; +import type { Lens, Settings, WorkerCreated } from "../model/types"; -import { useLensApi } from "./useLensApi"; +import type { LensApi } from "./service"; +import { useLensApi } from "../services"; + +export type LensWrite = (api: LensApi) => Promise; export function useLensUpdate(accessToken: string) { - const apiClient = useLensApi(); - return useMutation({ - retry: false, - mutationFn: ({ path, body, method = "post" }: { path: string; body: unknown; method?: "post" | "put" | "patch" }) => - apiClient[method](path, { accessToken, body }), - }); + const api = useLensApi(accessToken); + return useMutation({ retry: false, mutationFn: (write: LensWrite) => write(api) }); } export function useSaveLens(accessToken: string) { - const apiClient = useLensApi(); + const api = useLensApi(accessToken); return useMutation({ retry: false, - mutationFn: ({ id, settings }: { id?: string; settings: Settings }) => - apiClient.request(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }), + mutationFn: ({ id, settings }: { id?: string; settings: Settings }): Promise => api.saveLens(id, settings), }); } export function useRevokeWorker(accessToken: string) { - const apiClient = useLensApi(); - return useMutation({ - retry: false, - mutationFn: (id: string) => apiClient.delete(`/lens/workers/${id}`, { accessToken }), - }); + const api = useLensApi(accessToken); + return useMutation({ retry: false, mutationFn: (id: string) => api.revokeWorker(id) }); } -export async function createAnalysisKey( - apiClient: ApiClient, - accessToken: string, - access: AnalysisAccess, -): Promise { +export async function createAnalysisKey(api: LensApi, access: AnalysisAccess): Promise { const parsed = analysisAccessSchema.safeParse(access); if (!parsed.success) throw new Error(parsed.error.issues[0].message); - const result = await apiClient.post<{ token_id?: string }>("/key/generate", { - accessToken, - body: { - key_alias: "Lens analysis", - models: [parsed.data.model], - max_budget: parsed.data.budget, - budget_duration: "1mo", - metadata: { purpose: "lens" }, - }, - }); + const result = await api.generateAnalysisKey(parsed.data); if (!result.token_id) throw new Error("The proxy did not return the new key's ID"); return result.token_id; } @@ -59,7 +40,7 @@ export function usePrepareWorker( accessToken: string, { onChanged, onPrepared }: { onChanged: () => void; onPrepared: (created: WorkerCreated | null) => void }, ) { - const apiClient = useLensApi(); + const api = useLensApi(accessToken); return useMutation({ retry: false, mutationFn: async ({ @@ -78,21 +59,15 @@ export function usePrepareWorker( let newKey: string | null = null; try { validateWorkerAddress(address); - const keyId = useExisting ? analysisKey : await createAnalysisKey(apiClient, accessToken, access); + const keyId = useExisting ? analysisKey : await createAnalysisKey(api, access); if (!useExisting) newKey = keyId; if (editingWorker) { - await apiClient.put(`/lens/workers/${editingWorker}/billing-key`, { - accessToken, - body: { analysis_key_id: keyId }, - }); + await api.setWorkerBillingKey(editingWorker, keyId); onPrepared(null); onChanged(); return null; } - const created = await apiClient.post("/lens/workers/register", { - accessToken, - body: { name: "Lens worker", analysis_key_id: keyId }, - }); + const created = await api.registerWorker(keyId); onPrepared(created); onChanged(); return created; @@ -100,9 +75,9 @@ export function usePrepareWorker( const message = e instanceof Error ? e.message : "Could not create credential"; if (newKey) { try { - const current = await apiClient.get("/lens", { accessToken }); + const current = await api.lenses(); if (!current.workers.some((worker) => !worker.revoked && worker.analysis_key_id === newKey)) { - await apiClient.post("/key/delete", { accessToken, body: { keys: [newKey] } }); + await api.deleteKeys([newKey]); } onChanged(); } catch { diff --git a/ui/litellm-dashboard/src/components/lens/api/queries.ts b/ui/litellm-dashboard/src/components/lens/api/queries.ts index 687ea781dd4..a17299b758f 100644 --- a/ui/litellm-dashboard/src/components/lens/api/queries.ts +++ b/ui/litellm-dashboard/src/components/lens/api/queries.ts @@ -1,41 +1,39 @@ import { infiniteQueryOptions, queryOptions, type Query } from "@tanstack/react-query"; -import { z } from "zod"; -import type { ApiClient } from "@/lib/http/client"; -import type { components } from "@/lib/http/schema"; -import { type LensList, type Job, type Sample, type Settings, type ActivitySelection } from "../model/types"; -import type { AnalysisModelInfo } from "../setup/fields/analysisModels"; +import type { LensList, Settings, ActivitySelection } from "../model/types"; +import type { KeyPage, LensApi } from "./service"; + +export type { Key } from "./service"; export const lensKeys = { all: ["lens"] as const, lists: () => [...lensKeys.all, "list"] as const, - list: (accessToken: string) => [...lensKeys.lists(), { accessToken }] as const, + list: (scope: string) => [...lensKeys.lists(), { scope }] as const, histories: () => [...lensKeys.all, "history"] as const, - history: (accessToken: string, lensId: string | undefined, offset: number) => - [...lensKeys.histories(), { accessToken, lensId, offset }] as const, + history: (scope: string, lensId: string | undefined, offset: number) => + [...lensKeys.histories(), { scope, lensId, offset }] as const, runs: () => [...lensKeys.all, "run"] as const, - run: (accessToken: string, lensId: string | undefined, batchId: string) => - [...lensKeys.runs(), { accessToken, lensId, batchId }] as const, - evidence: (accessToken: string, lensId: string | undefined, evidenceId: string | undefined, offset: number) => - [...lensKeys.all, "evidence", { accessToken, lensId, evidenceId, offset }] as const, - models: (accessToken: string) => [...lensKeys.all, "models", { accessToken }] as const, - modelDetails: (accessToken: string) => [...lensKeys.all, "model-details", { accessToken }] as const, - activity: (accessToken: string) => [...lensKeys.all, "activity-available", { accessToken }] as const, - discovery: (accessToken: string, source: Settings["source"], hours: number | undefined, asOf: string) => - [...lensKeys.all, "discovery", { accessToken, source, hours, asOf }] as const, - preview: (accessToken: string, scope: ActivitySelection, offset: number, asOf: string) => - [...lensKeys.all, "preview", { accessToken, scope, offset, asOf }] as const, - agents: (accessToken: string, asOf: string) => [...lensKeys.all, "agents", { accessToken, asOf }] as const, - analysisKeys: (accessToken: string, query: string) => - [...lensKeys.all, "analysis-keys", { accessToken, query }] as const, - analysisKeyInfo: (accessToken: string, keyId: string | undefined) => - [...lensKeys.all, "analysis-key-info", { accessToken, keyId }] as const, + run: (scope: string, lensId: string | undefined, batchId: string) => + [...lensKeys.runs(), { scope, lensId, batchId }] as const, + evidence: (scope: string, lensId: string | undefined, evidenceId: string | undefined, offset: number) => + [...lensKeys.all, "evidence", { scope, lensId, evidenceId, offset }] as const, + models: (scope: string) => [...lensKeys.all, "models", { scope }] as const, + modelDetails: (scope: string) => [...lensKeys.all, "model-details", { scope }] as const, + activity: (scope: string) => [...lensKeys.all, "activity-available", { scope }] as const, + discovery: (scope: string, source: Settings["source"], hours: number | undefined, asOf: string) => + [...lensKeys.all, "discovery", { scope, source, hours, asOf }] as const, + preview: (scope: string, selection: ActivitySelection, offset: number, asOf: string) => + [...lensKeys.all, "preview", { scope, selection, offset, asOf }] as const, + agents: (scope: string, asOf: string) => [...lensKeys.all, "agents", { scope, asOf }] as const, + analysisKeys: (scope: string, query: string) => [...lensKeys.all, "analysis-keys", { scope, query }] as const, + analysisKeyInfo: (scope: string, keyId: string | undefined) => + [...lensKeys.all, "analysis-key-info", { scope, keyId }] as const, }; export const lensQueries = { - list(apiClient: ApiClient, accessToken: string, demo: boolean, workerSetup: boolean) { + list(api: LensApi, demo: boolean, workerSetup: boolean) { const options = { - queryKey: lensKeys.list(accessToken), - queryFn: () => apiClient.get("/lens", { accessToken }), + queryKey: lensKeys.list(api.scope), + queryFn: () => api.lenses(), refetchInterval: (current: Query): number | false => { if (demo) return false; const running = current.state.data?.lenses.some((item) => @@ -46,53 +44,43 @@ export const lensQueries = { }; return queryOptions(options); }, - models(apiClient: ApiClient, accessToken: string) { - const options = { - queryKey: lensKeys.models(accessToken), - queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }), - }; - return queryOptions(options); + models(api: LensApi) { + return queryOptions({ queryKey: lensKeys.models(api.scope), queryFn: () => api.models() }); }, - modelDetails(apiClient: ApiClient, accessToken: string) { - const options = { - queryKey: lensKeys.modelDetails(accessToken), - queryFn: () => apiClient.get<{ data: AnalysisModelInfo[] }>("/model_group/info", { accessToken }), - }; - return queryOptions(options); + modelDetails(api: LensApi) { + return queryOptions({ queryKey: lensKeys.modelDetails(api.scope), queryFn: () => api.modelDetails() }); }, - activity(apiClient: ApiClient, accessToken: string, loaded: boolean, demo: boolean) { + activity(api: LensApi, loaded: boolean, demo: boolean) { const options = { - queryKey: lensKeys.activity(accessToken), - queryFn: () => apiClient.get<{ traces: boolean; requests: boolean }>("/lens/activity/available", { accessToken }), + queryKey: lensKeys.activity(api.scope), + queryFn: () => api.activity(), enabled: loaded, refetchInterval: demo ? (false as const) : 5000, }; return queryOptions(options); }, history( - apiClient: ApiClient, - accessToken: string, + api: LensApi, { lensId, historyOffset, demo }: { lensId: string | undefined; historyOffset: number; demo: boolean }, ) { const options = { - queryKey: lensKeys.history(accessToken, lensId, historyOffset), + queryKey: lensKeys.history(api.scope, lensId, historyOffset), enabled: !!lensId, - queryFn: () => apiClient.get(`/lens/${lensId}/runs`, { accessToken, query: { offset: historyOffset } }), + queryFn: () => api.runs(lensId as string, historyOffset), refetchInterval: demo ? (false as const) : 10000, }; return queryOptions(options); }, - run(apiClient: ApiClient, accessToken: string, lensId: string | undefined, batchId: string) { + run(api: LensApi, lensId: string | undefined, batchId: string) { const options = { - queryKey: lensKeys.run(accessToken, lensId, batchId), + queryKey: lensKeys.run(api.scope, lensId, batchId), enabled: !!lensId && !["latest", "all"].includes(batchId), - queryFn: () => apiClient.get(`/lens/${lensId}/runs/${batchId}`, { accessToken }), + queryFn: () => api.run(lensId as string, batchId), }; return queryOptions(options); }, evidence( - apiClient: ApiClient, - accessToken: string, + api: LensApi, { lensId, evidenceId, @@ -106,78 +94,38 @@ export const lensQueries = { }, ) { const options = { - queryKey: lensKeys.evidence(accessToken, lensId, evidenceId, requestOffset), + queryKey: lensKeys.evidence(api.scope, lensId, evidenceId, requestOffset), enabled: !!lensId && source === "requests", - queryFn: () => - apiClient.get( - `/lens/${lensId}/executions/${encodeURIComponent(evidenceId ?? "")}`, - { accessToken, query: { offset: requestOffset } }, - ), + queryFn: () => api.execution(lensId as string, evidenceId ?? "", requestOffset), }; return queryOptions(options); }, - sample( - apiClient: ApiClient, - accessToken: string, - { selection, pageOffset, asOf }: { selection: ActivitySelection; pageOffset: number; asOf: string }, - ) { - const { lookback_hours, ...selectionSettings } = selection; - return apiClient.post("/lens/preview/sample", { - accessToken, - body: { - offset: pageOffset, - as_of: asOf, - settings: { - ...selectionSettings, - execution_ids: [], - name: "Preview", - model: "preview", - checks: [{ id: "preview", instruction: "Preview recorded activity" }], - }, - lookback_hours: lookback_hours ?? 24, - }, - }); - }, - discovery( - apiClient: ApiClient, - accessToken: string, - { value, asOf, enabled }: { value: ActivitySelection; asOf: string; enabled: boolean }, - ) { + discovery(api: LensApi, { value, asOf, enabled }: { value: ActivitySelection; asOf: string; enabled: boolean }) { + const unfiltered = { source: value.source, service: "", filters: [], lookback_hours: value.lookback_hours }; const options = { - queryKey: lensKeys.discovery(accessToken, value.source, value.lookback_hours, asOf), - queryFn: () => - lensQueries.sample(apiClient, accessToken, { - selection: { - source: value.source, - service: "", - filters: [], - lookback_hours: value.lookback_hours, - }, - pageOffset: 0, - asOf, - }), + queryKey: lensKeys.discovery(api.scope, value.source, value.lookback_hours, asOf), + queryFn: () => api.sample(unfiltered, 0, asOf), staleTime: 60000, enabled, }; return queryOptions(options); }, preview( - apiClient: ApiClient, - accessToken: string, + api: LensApi, { scope, offset, asOf, enabled }: { scope: ActivitySelection; offset: number; asOf: string; enabled: boolean }, ) { const options = { - queryKey: lensKeys.preview(accessToken, scope, offset, asOf), - queryFn: () => lensQueries.sample(apiClient, accessToken, { selection: scope, pageOffset: offset, asOf }), + queryKey: lensKeys.preview(api.scope, scope, offset, asOf), + queryFn: () => api.sample(scope, offset, asOf), enabled, staleTime: 30000, }; return queryOptions(options); }, - agents(apiClient: ApiClient, accessToken: string, asOf: string, source: Settings["source"]) { + agents(api: LensApi, asOf: string, source: Settings["source"]) { const options = { - queryKey: lensKeys.agents(accessToken, asOf), - queryFn: () => apiClient.get("/lens/agents", { accessToken }), + queryKey: lensKeys.agents(api.scope, asOf), + queryFn: () => api.agents(), enabled: source !== "requests", staleTime: 60000, }; @@ -185,55 +133,22 @@ export const lensQueries = { }, }; -const keySchema = z.object({ token: z.string(), key_alias: z.string().nullable().optional() }); -const pageSchema = z.object({ keys: z.array(keySchema), total_pages: z.number() }); -export type Key = z.infer; - -export function analysisKeysQuery(apiClient: ApiClient, accessToken: string, query: string) { +export function analysisKeysQuery(api: LensApi, query: string) { const options = { - queryKey: lensKeys.analysisKeys(accessToken, query), + queryKey: lensKeys.analysisKeys(api.scope, query), initialPageParam: 1, - queryFn: async ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) => - pageSchema.parse( - await apiClient.get("/key/list", { - accessToken, - signal, - query: { - page: String(pageParam), - size: "25", - return_full_object: "true", - key_alias: query || undefined, - substring_matching: "true", - include_team_keys: "true", - include_created_by_keys: "true", - status: "active", - }, - }), - ), - getNextPageParam: (lastPage: z.infer, pages: z.infer[]) => + queryFn: ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) => api.keys(query, pageParam, signal), + getNextPageParam: (lastPage: KeyPage, pages: KeyPage[]) => pages.length < lastPage.total_pages ? pages.length + 1 : undefined, }; return infiniteQueryOptions(options); } -const keyInfoFields = { - key_alias: z.string().nullable().optional(), - models: z.array(z.string()), - max_budget: z.number().nullable(), - budget_duration: z.string().nullable().optional(), - rpm_limit: z.number().nullable().optional(), - tpm_limit: z.number().nullable().optional(), - expires: z.string().nullable().optional(), - status: z.string().optional(), -}; -const keyInfoSchema = z.object({ info: z.object(keyInfoFields) }); - -export function analysisKeyInfoQuery(apiClient: ApiClient, accessToken: string, keyId?: string) { +export function analysisKeyInfoQuery(api: LensApi, keyId?: string) { const options = { - queryKey: lensKeys.analysisKeyInfo(accessToken, keyId), + queryKey: lensKeys.analysisKeyInfo(api.scope, keyId), enabled: !!keyId, - queryFn: async () => - keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info, + queryFn: () => api.keyInfo(keyId as string), }; return queryOptions(options); } diff --git a/ui/litellm-dashboard/src/components/lens/api/service.ts b/ui/litellm-dashboard/src/components/lens/api/service.ts new file mode 100644 index 00000000000..b784cc582d3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/api/service.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import type { ApiClient } from "@/lib/http/client"; +import type { components } from "@/lib/http/schema"; +import type { ActivitySelection, Job, Lens, LensList, Sample, Settings, WorkerCreated } from "../model/types"; +import type { AnalysisModelInfo } from "../setup/fields/analysisModels"; + +export type ExecutionContent = components["schemas"]["ExecutionContent"]; +export type FindingStatus = components["schemas"]["FindingUpdate"]["status"]; + +const keySchema = z.object({ token: z.string(), key_alias: z.string().nullable().optional() }); +const keyPageSchema = z.object({ keys: z.array(keySchema), total_pages: z.number() }); +const keyInfoFields = { + key_alias: z.string().nullable().optional(), + models: z.array(z.string()), + max_budget: z.number().nullable(), + budget_duration: z.string().nullable().optional(), + rpm_limit: z.number().nullable().optional(), + tpm_limit: z.number().nullable().optional(), + expires: z.string().nullable().optional(), + status: z.string().optional(), +}; +const keyInfoSchema = z.object({ info: z.object(keyInfoFields) }); +export type Key = z.infer; +export type KeyPage = z.infer; +export type KeyInfo = z.infer["info"]; + +export interface AnalysisKeyRequest { + readonly model: string; + readonly budget: number; +} + +export interface LensApi { + /** Partitions query caches between backends (one token, or the demo). */ + readonly scope: string; + lenses(): Promise; + activity(): Promise<{ traces: boolean; requests: boolean }>; + runs(lensId: string, offset: number): Promise; + run(lensId: string, jobId: string): Promise; + execution(lensId: string, executionId: string, offset: number): Promise; + sample(selection: ActivitySelection, offset: number, asOf: string): Promise; + agents(): Promise; + models(): Promise<{ data: { id: string }[] }>; + modelDetails(): Promise<{ data: AnalysisModelInfo[] }>; + keys(alias: string, page: number, signal: AbortSignal): Promise; + keyInfo(keyId: string): Promise; + saveLens(id: string | undefined, settings: Settings): Promise; + startRun(lensId: string): Promise; + cancelRun(lensId: string): Promise; + reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise; + registerWorker(analysisKeyId: string | null): Promise; + setWorkerBillingKey(workerId: string, analysisKeyId: string | null): Promise; + revokeWorker(workerId: string): Promise; + generateAnalysisKey(request: AnalysisKeyRequest): Promise<{ token_id?: string }>; + deleteKeys(keys: readonly string[]): Promise; +} + +export function liveLensApi(apiClient: ApiClient, accessToken: string): LensApi { + const encode = encodeURIComponent; + return { + scope: accessToken, + lenses: () => apiClient.get("/lens", { accessToken }), + activity: () => apiClient.get("/lens/activity/available", { accessToken }), + runs: (lensId, offset) => apiClient.get(`/lens/${lensId}/runs`, { accessToken, query: { offset } }), + run: (lensId, jobId) => apiClient.get(`/lens/${lensId}/runs/${jobId}`, { accessToken }), + execution: (lensId, executionId, offset) => + apiClient.get(`/lens/${lensId}/executions/${encode(executionId)}`, { + accessToken, + query: { offset }, + }), + sample: (selection, offset, asOf) => { + const { lookback_hours, ...selectionSettings } = selection; + return apiClient.post("/lens/preview/sample", { + accessToken, + body: { + offset, + as_of: asOf, + settings: { + ...selectionSettings, + execution_ids: [], + name: "Preview", + model: "preview", + checks: [{ id: "preview", instruction: "Preview recorded activity" }], + }, + lookback_hours: lookback_hours ?? 24, + }, + }); + }, + agents: () => apiClient.get("/lens/agents", { accessToken }), + models: () => apiClient.get("/models", { accessToken }), + modelDetails: () => apiClient.get("/model_group/info", { accessToken }), + keys: async (alias, page, signal) => + keyPageSchema.parse( + await apiClient.get("/key/list", { + accessToken, + signal, + query: { + page: String(page), + size: "25", + return_full_object: "true", + key_alias: alias || undefined, + substring_matching: "true", + include_team_keys: "true", + include_created_by_keys: "true", + status: "active", + }, + }), + ), + keyInfo: async (keyId) => + keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info, + saveLens: (id, settings) => + apiClient.request(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }), + startRun: (lensId) => apiClient.post(`/lens/${lensId}/runs`, { accessToken, body: {} }), + cancelRun: (lensId) => apiClient.post(`/lens/${lensId}/cancel`, { accessToken, body: {} }), + reviewFinding: (lensId, findingId, status, reason) => + apiClient.patch(`/lens/${lensId}/findings/${findingId}`, { accessToken, body: { status, reason } }), + registerWorker: (analysisKeyId) => + apiClient.post("/lens/workers/register", { + accessToken, + body: { name: "Lens worker", analysis_key_id: analysisKeyId }, + }), + setWorkerBillingKey: (workerId, analysisKeyId) => + apiClient.put(`/lens/workers/${workerId}/billing-key`, { accessToken, body: { analysis_key_id: analysisKeyId } }), + revokeWorker: (workerId) => apiClient.delete(`/lens/workers/${workerId}`, { accessToken }), + generateAnalysisKey: (request) => + apiClient.post<{ token_id?: string }>("/key/generate", { + accessToken, + body: { + key_alias: "Lens analysis", + models: [request.model], + max_budget: request.budget, + budget_duration: "1mo", + metadata: { purpose: "lens" }, + }, + }), + deleteKeys: (keys) => apiClient.post("/key/delete", { accessToken, body: { keys } }), + }; +} diff --git a/ui/litellm-dashboard/src/components/lens/api/useLensApi.ts b/ui/litellm-dashboard/src/components/lens/api/useLensApi.ts deleted file mode 100644 index 31cc03ac332..00000000000 --- a/ui/litellm-dashboard/src/components/lens/api/useLensApi.ts +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { apiClient } from "@/components/networking"; -import { useLensDemo } from "../LensDemoContext"; - -export function useLensApi() { - return useLensDemo()?.client ?? apiClient; -} diff --git a/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.test.ts b/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.test.ts index 86e149dde76..8ddf632e015 100644 --- a/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.test.ts +++ b/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import { createLensDemo, createLensDemoData } from "./createLensDemo"; -import type { TracePage } from "@/components/view_logs/TraceView/traceTypes"; import { evidenceTarget } from "../model/findings"; describe("Lens demo data", () => { @@ -56,14 +55,16 @@ describe("Lens demo data", () => { it("filters time windows locally and rejects writes or unknown reads without network access", async () => { const network = vi.spyOn(globalThis, "fetch"); const now = Date.now(); - const { client } = createLensDemo(now); - const all = await client.get("/v1/traces"); - const recent = await client.get("/v1/traces", { query: { start_ms: now - 3600_000, end_ms: now } }); + const { services } = createLensDemo(now); + const all = await services.traces.list({ startMs: 0, endMs: now }); + const recent = await services.traces.list({ startMs: now - 3600_000, endMs: now }); expect(recent.data.length).toBeGreaterThan(0); expect(recent.data.length).toBeLessThan(all.data.length); expect(recent.data.every((trace) => Date.parse(trace.start_time) >= now - 3600_000)).toBe(true); - await expect(client.post("/lens", { body: {} })).rejects.toMatchObject({ status: 403 }); - await expect(client.get("/lens/real-investigation")).rejects.toMatchObject({ status: 404 }); + const settings = services.lens.lenses().then((list) => list.lenses[0].settings); + await expect(services.lens.saveLens(undefined, await settings)).rejects.toMatchObject({ status: 403 }); + await expect(services.lens.run("real-investigation", "job")).rejects.toMatchObject({ status: 404 }); + await expect(services.traces.trace("missing")).rejects.toMatchObject({ status: 404 }); expect(network).not.toHaveBeenCalled(); network.mockRestore(); }); diff --git a/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts b/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts index 204b682f7b6..c971bcf8821 100644 --- a/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts +++ b/ui/litellm-dashboard/src/components/lens/demo/createLensDemo.ts @@ -1,5 +1,8 @@ -import { createApiClient } from "@/lib/http/client"; +import { ApiError } from "@/lib/http/client"; +import type { TracesApi } from "@/components/view_logs/TraceView/tracesApi"; import type { LensDemo } from "../LensDemoContext"; +import type { LensServices } from "../services"; +import type { LensApi } from "../api/service"; import type { Trace, Span, SpanDetail } from "@/components/view_logs/TraceView/traceTypes"; import type { Lens, Finding, Job, Settings } from "../model/types"; import { withReleaseCases } from "./lensDemoLongTrace"; @@ -109,6 +112,8 @@ function makeTrace(scene: Scenario, index: number, now: number) { return { trace, details }; } +export type LensDemoData = ReturnType; + export function createLensDemoData(now = Date.now()) { const runs = scenarios.map((scene, index) => { const run = makeTrace(scene, index, now); @@ -331,57 +336,73 @@ export function createLensDemoData(now = Date.now()) { return { runs, lenses }; } -function readDemoTrace(data: ReturnType, url: URL): unknown { - const parts = url.pathname.split("/").filter(Boolean); - if (parts[0] !== "v1" || parts[1] !== "traces") return undefined; - if (!parts[2]) { - const start = Number(url.searchParams.get("start_ms") ?? 0); - const end = Number(url.searchParams.get("end_ms") ?? Infinity); - return { - data: data.runs - .map((run) => run.trace.summary) - .filter((trace) => Date.parse(trace.start_time) >= start && Date.parse(trace.start_time) <= end), - next_cursor: null, - }; - } - const run = data.runs.find(({ trace }) => trace.summary.trace_id === parts[2]); - if (!parts[3]) return run?.trace; - if (parts[3] === "spans" && !parts[5]) return run?.details.find((span) => span.span_id === parts[4]); - if (parts[3] === "spans" && parts[5] === "error") { - const span = run?.trace.spans.find((item) => item.span_id === parts[4]); - return span - ? { span_id: span.span_id, message: span.error ?? "", total_chars: span.error?.length ?? 0, next_cursor: null } - : undefined; - } - return undefined; +const notInDemo = (): Promise => + Promise.reject(new ApiError("This item is not in the demo", 404, { detail: "This item is not in the demo" })); +const readOnly = (): Promise => + Promise.reject(new ApiError("Demo data is read-only", 403, { detail: "Demo data is read-only" })); +const found = (value: T | undefined): Promise => (value === undefined ? notInDemo() : Promise.resolve(value)); + +function demoLensApi(data: LensDemoData): LensApi { + const jobs = (lensId: string) => data.lenses.find((lens) => lens.id === lensId)?.jobs; + return { + scope: "demo", + lenses: async () => ({ lenses: data.lenses, workers: [], tracing_enabled: true }), + activity: async () => ({ traces: true, requests: false }), + runs: (lensId, offset) => found(jobs(lensId)?.slice(offset)), + run: (lensId, jobId) => found(jobs(lensId)?.find((job) => job.id === jobId)), + execution: notInDemo, + sample: notInDemo, + agents: notInDemo, + models: async () => ({ data: [] }), + modelDetails: async () => ({ data: [] }), + keys: notInDemo, + keyInfo: notInDemo, + saveLens: readOnly, + startRun: readOnly, + cancelRun: readOnly, + reviewFinding: readOnly, + registerWorker: readOnly, + setWorkerBillingKey: readOnly, + revokeWorker: readOnly, + generateAnalysisKey: readOnly, + deleteKeys: readOnly, + }; } -export function createLensDemo(now = Date.now()): LensDemo { - const data = createLensDemoData(now); - const read = (url: URL): unknown => { - const parts = url.pathname.split("/").filter(Boolean); - if (url.pathname === "/lens") return { lenses: data.lenses, workers: [], tracing_enabled: true }; - if (url.pathname === "/lens/activity/available") return { traces: true, requests: false }; - if (url.pathname === "/models" || url.pathname === "/model_group/info") return { data: [] }; - if (parts[0] === "lens" && parts[2] === "runs") { - const jobs = data.lenses.find((lens) => lens.id === parts[1])?.jobs; - return parts[3] - ? jobs?.find((job) => job.id === parts[3]) - : jobs?.slice(Number(url.searchParams.get("offset") ?? 0)); - } - return readDemoTrace(data, url); - }; +function demoTracesApi(data: LensDemoData): TracesApi { + const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId); return { - client: createApiClient({ - getBaseUrl: () => "https://lens-demo.invalid", - fetchImpl: async (input, init) => { - if (init?.method !== "GET") return Response.json({ detail: "Demo data is read-only" }, { status: 403 }); - const result = read(new URL(String(input))); - return result === undefined - ? Response.json({ detail: "This item is not in the demo" }, { status: 404 }) - : Response.json(result); - }, + list: async ({ startMs, endMs }) => ({ + data: data.runs + .map((item) => item.trace.summary) + .filter((trace) => Date.parse(trace.start_time) >= startMs && Date.parse(trace.start_time) <= endMs), + next_cursor: null, }), + anyRecorded: async () => data.runs.length > 0, + trace: (traceId) => found(run(traceId)?.trace), + span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)), + spanError: async (traceId, spanId) => { + const span = run(traceId)?.trace.spans.find((item) => item.span_id === spanId); + return found( + span && { + span_id: span.span_id, + message: span.error ?? "", + total_chars: span.error?.length ?? 0, + next_cursor: null, + }, + ); + }, + }; +} + +export interface LensDemoSession extends LensDemo { + readonly services: LensServices; +} + +export function createLensDemo(now = Date.now()): LensDemoSession { + const data = createLensDemoData(now); + return { + services: { lens: demoLensApi(data), traces: demoTracesApi(data) }, copyTrace: (traceId, spanId) => { const run = data.runs.find(({ trace }) => trace.summary.trace_id === traceId); return JSON.stringify(spanId ? run?.details.find((span) => span.span_id === spanId) : run, null, 2); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx index 796ccd85a3f..25ab4622b21 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx @@ -4,9 +4,9 @@ import { InvestigationMissing, InvestigationsLoading, InvestigationError } from import { InvestigationNavigation } from "./InvestigationNavigation"; import { useInvestigationResults } from "./useInvestigationResults"; -import { useLensUpdate, useSaveLens } from "../api/mutations"; +import { useLensUpdate, useSaveLens, type LensWrite } from "../api/mutations"; import { lensKeys, lensQueries } from "../api/queries"; -import { useLensApi } from "../api/useLensApi"; +import { useLensApi } from "../services"; import { InvestigationDetail } from "./detail/InvestigationDetail"; import { ReadinessBanner } from "./ReadinessBanner"; @@ -40,16 +40,16 @@ export function InvestigationsView({ onDemo?: () => void; }) { const demo = useLensDemo(); - const apiClient = useLensApi(); + const api = useLensApi(accessToken); const client = useQueryClient(); const updateLens = useLensUpdate(accessToken); const saveLens = useSaveLens(accessToken); const [workerSetup, setWorkerSetup] = useState(false); const [monitoring, setMonitoring] = useState(false); const now = useNow(2000); - const query = useQuery(lensQueries.list(apiClient, accessToken, !!demo, workerSetup)); - const models = useQuery(lensQueries.models(apiClient, accessToken)); - const modelDetails = useQuery(lensQueries.modelDetails(apiClient, accessToken)); + const query = useQuery(lensQueries.list(api, !!demo, workerSetup)); + const models = useQuery(lensQueries.models(api)); + const modelDetails = useQuery(lensQueries.modelDetails(api)); const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" })); const [demoSelected, setDemoSelected] = useState(null); const selected = demo ? demoSelected : liveSelected; @@ -96,7 +96,7 @@ export function InvestigationsView({ const defaultKeyId = activeWorkers.length === 1 ? activeWorkers[0].analysis_key_id : undefined; const analysisAccess = useAnalysisKeyInfo(accessToken, defaultKeyId ?? undefined); const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined; - const activity = useQuery(lensQueries.activity(apiClient, accessToken, loaded, !!demo)); + const activity = useQuery(lensQueries.activity(api, loaded, !!demo)); const { tracesReady, requestsReady, activityReady, ready } = readiness( activity.data, activity.error, @@ -110,15 +110,15 @@ export function InvestigationsView({ return lens?.settings; }; const refresh = () => { - void client.invalidateQueries({ queryKey: lensKeys.list(accessToken) }); + void client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }); void client.invalidateQueries({ queryKey: lensKeys.histories() }); }; - const update = async (path: string, body: unknown, method: "post" | "put" | "patch" = "post") => { + const update = async (write: LensWrite) => { setBusy(true); setError(""); try { - await updateLens.mutateAsync({ path, body, method }); - await client.invalidateQueries({ queryKey: lensKeys.list(accessToken) }); + await updateLens.mutateAsync(write); + await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }); } catch (e) { setError(e instanceof Error ? e.message : "Could not update lens"); } finally { @@ -137,7 +137,7 @@ export function InvestigationsView({ }; const changeFinding = async (status: Finding["status"], reason: string) => { if (!lens || !finding) return; - await update(`/lens/${lens.id}/findings/${finding.id}`, { status, reason }, "patch"); + await update((current) => current.reviewFinding(lens.id, finding.id, status, reason)); }; return ( diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx index bd8c8f1e830..8a5d2156e26 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationActions.tsx @@ -9,6 +9,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { type Lens, type Job } from "../../model/types"; +import type { LensWrite } from "../../api/mutations"; export function InvestigationActions({ lens, @@ -25,7 +26,7 @@ export function InvestigationActions({ active: Job | undefined; setEditing: (mode: "new" | "edit" | "duplicate") => void; setMonitoring: (open: boolean) => void; - update: (path: string, body: unknown, method?: "post" | "put" | "patch") => Promise; + update: (write: LensWrite) => Promise; }) { return (
@@ -45,7 +46,7 @@ export function InvestigationActions({ {lens.settings.enabled ? ( update(`/lens/${lens.id}`, { ...lens.settings, enabled: false }, "put")} + onClick={() => update((api) => api.saveLens(lens.id, { ...lens.settings, enabled: false }))} > Pause monitoring @@ -58,7 +59,7 @@ export function InvestigationActions({ )} - diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx index 4bb1085b702..508c214c78d 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx @@ -10,6 +10,7 @@ import { InvestigationSummary } from "./InvestigationSummary"; import { InvestigationFailure } from "./InvestigationFailure"; import { scopeLabel, sourceLabels } from "../../model/format"; import { type Lens } from "../../model/types"; +import type { LensWrite } from "../../api/mutations"; import { InvestigationActions } from "./InvestigationActions"; import { RunPicker } from "./RunPicker"; @@ -33,7 +34,7 @@ export function InvestigationDetail({ busy: boolean; setEditing: (mode: "new" | "edit" | "duplicate") => void; setMonitoring: (open: boolean) => void; - update: (path: string, body: unknown, method?: "post" | "put" | "patch") => Promise; + update: (write: LensWrite) => Promise; connected: boolean; results: ReturnType; }) { @@ -96,7 +97,7 @@ export function InvestigationDetail({ readOnly ? undefined : () => { - void update(`/lens/${lens.id}/cancel`, {}); + void update((api) => api.cancelRun(lens.id)); } } /> diff --git a/ui/litellm-dashboard/src/components/lens/investigations/useInvestigationResults.ts b/ui/litellm-dashboard/src/components/lens/investigations/useInvestigationResults.ts index 2532ebf4635..49d1919803a 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/useInvestigationResults.ts +++ b/ui/litellm-dashboard/src/components/lens/investigations/useInvestigationResults.ts @@ -3,13 +3,13 @@ import { useCallback, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useLensDemo } from "../LensDemoContext"; -import { useLensApi } from "../api/useLensApi"; +import { useLensApi } from "../services"; import { lensQueries } from "../api/queries"; import { evidenceTarget, mergeFeedback, sortedFindings } from "../model/findings"; import type { Job, Lens } from "../model/types"; export function useInvestigationResults(accessToken: string, lens: Lens | undefined) { - const apiClient = useLensApi(); + const api = useLensApi(accessToken); const demo = useLensDemo(); const [batchId, setBatchId] = useState("latest"); const [historyOffset, setHistoryOffset] = useState(0); @@ -18,10 +18,8 @@ export function useInvestigationResults(accessToken: string, lens: Lens | undefi const [filter, setFilter] = useState("open"); const [kind, setKind] = useState<"issue" | "pattern">("issue"); const [evidence, setEvidence] = useState<{ id: string; span: string } | null>(null); - const history = useQuery( - lensQueries.history(apiClient, accessToken, { lensId: lens?.id, historyOffset, demo: !!demo }), - ); - const historical = useQuery(lensQueries.run(apiClient, accessToken, lens?.id, batchId)); + const history = useQuery(lensQueries.history(api, { lensId: lens?.id, historyOffset, demo: !!demo })); + const historical = useQuery(lensQueries.run(api, lens?.id, batchId)); const { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings } = runSnapshot( lens, batchId, @@ -42,7 +40,7 @@ export function useInvestigationResults(accessToken: string, lens: Lens | undefi const target = evidence ? evidenceTarget(evidence.id) : null; const [requestOffset, setRequestOffset] = useState(0); const evidenceInput = { lensId: lens?.id, evidenceId: evidence?.id, requestOffset, source: target?.source }; - const requestEvidence = useQuery(lensQueries.evidence(apiClient, accessToken, evidenceInput)); + const requestEvidence = useQuery(lensQueries.evidence(api, evidenceInput)); const reset = useCallback(() => { setBatchId("latest"); setHistoryOffset(0); diff --git a/ui/litellm-dashboard/src/components/lens/services.ts b/ui/litellm-dashboard/src/components/lens/services.ts new file mode 100644 index 00000000000..bc1155d3bb3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/services.ts @@ -0,0 +1,26 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import { apiClient } from "@/components/networking"; +import { liveTracesApi, type TracesApi } from "@/components/view_logs/TraceView/tracesApi"; +import { liveLensApi, type LensApi } from "./api/service"; + +/** Without a provider, hooks fall back to the live HTTP implementations for the caller's token. */ +export interface LensServices { + readonly lens: LensApi; + readonly traces: TracesApi; +} + +export const LensServicesContext = createContext(null); + +export function useLensApi(accessToken: string): LensApi { + const provided = useContext(LensServicesContext)?.lens; + const live = useMemo(() => liveLensApi(apiClient, accessToken), [accessToken]); + return provided ?? live; +} + +export function useTracesApi(accessToken: string): TracesApi { + const provided = useContext(LensServicesContext)?.traces; + const live = useMemo(() => liveTracesApi(accessToken), [accessToken]); + return provided ?? live; +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/MatchingActivity.tsx b/ui/litellm-dashboard/src/components/lens/setup/MatchingActivity.tsx index 7f3831aedc8..da0ca45860e 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/MatchingActivity.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/MatchingActivity.tsx @@ -10,7 +10,7 @@ import { lensQueries } from "../api/queries"; import { useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useLensApi } from "../api/useLensApi"; +import { useLensApi } from "../services"; import { Button } from "@/components/ui/button"; import { TraceSheet } from "../investigations/TraceSheet"; @@ -34,7 +34,7 @@ export function MatchingActivity({ }) { const { control, setValue } = useFormContext(); const selection = useWatch({ control, name: "selection" }); - const apiClient = useLensApi(); + const api = useLensApi(accessToken); const id = useId(); const [offset, setOffset] = useState(0); const [scope, setScope] = useState(selection); @@ -57,12 +57,10 @@ export function MatchingActivity({ const validSampling = percent > 0 && percent <= 100 && validCap; const validFilters = (scope.filters ?? []).every((f) => f.key.trim() && f.value.trim()); const valid = validWindow && validSampling && validFilters; - const discovery = useQuery( - lensQueries.discovery(apiClient, accessToken, { value: selection, asOf, enabled: validWindow }), - ); - const agents = useQuery(lensQueries.agents(apiClient, accessToken, asOf, selection.source)); + const discovery = useQuery(lensQueries.discovery(api, { value: selection, asOf, enabled: validWindow })); + const agents = useQuery(lensQueries.agents(api, asOf, selection.source)); const previewInput = { scope, offset, asOf, enabled: valid }; - const preview = useQuery(lensQueries.preview(apiClient, accessToken, previewInput)); + const preview = useQuery(lensQueries.preview(api, previewInput)); const empty = preview.data?.eligible === 0; useEffect(() => { if (!empty || !valid) return; diff --git a/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisAccessFields.tsx b/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisAccessFields.tsx index e77893e1bab..ee84051f4f6 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisAccessFields.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisAccessFields.tsx @@ -4,7 +4,7 @@ import { Controller, useFormContext } from "react-hook-form"; import { lensQueries } from "../../api/queries"; import { useQuery } from "@tanstack/react-query"; -import { useLensApi } from "../../api/useLensApi"; +import { useLensApi } from "../../services"; import { SearchSelect } from "@/components/shared/SearchSelect"; import { Input } from "@/components/ui/input"; import type { WorkerFormInput } from "./workerSchema"; @@ -15,8 +15,8 @@ export function AnalysisAccessFields({ accessToken }: { accessToken: string }) { register, formState: { errors }, } = useFormContext(); - const apiClient = useLensApi(); - const models = useQuery(lensQueries.models(apiClient, accessToken)); + const api = useLensApi(accessToken); + const models = useQuery(lensQueries.models(api)); return (
diff --git a/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyDetails.tsx b/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyDetails.tsx index 1d9289e1a39..3fa6a8bf433 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyDetails.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyDetails.tsx @@ -3,7 +3,7 @@ import { analysisKeyInfoQuery } from "../../api/queries"; import { useQuery } from "@tanstack/react-query"; -import { useLensApi } from "../../api/useLensApi"; +import { useLensApi } from "../../services"; import { Button } from "@/components/ui/button"; import { runTime } from "../../model/format"; @@ -26,8 +26,8 @@ function budgetLabel(amount: number | null, duration?: string | null): string { } export function useAnalysisKeyInfo(accessToken: string, keyId?: string) { - const apiClient = useLensApi(); - return useQuery(analysisKeyInfoQuery(apiClient, accessToken, keyId)); + const api = useLensApi(accessToken); + return useQuery(analysisKeyInfoQuery(api, keyId)); } export function AnalysisKeyDetails({ diff --git a/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyPicker.tsx b/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyPicker.tsx index 1d1175743e5..7cbf56b85f8 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyPicker.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/worker/AnalysisKeyPicker.tsx @@ -5,7 +5,7 @@ import { analysisKeysQuery, type Key } from "../../api/queries"; import { Controller, useFormContext, useWatch } from "react-hook-form"; import { useState } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { useLensApi } from "../../api/useLensApi"; +import { useLensApi } from "../../services"; import { AnalysisKeyDetails } from "./AnalysisKeyDetails"; import type { WorkerFormInput } from "./workerSchema"; import { @@ -20,11 +20,11 @@ import { export function AnalysisKeyPicker({ accessToken }: { accessToken: string }) { const { control } = useFormContext(); const value = useWatch({ control, name: "analysisKey" }); - const apiClient = useLensApi(); + const api = useLensApi(accessToken); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(value ? { token: value } : null); - const keyPages = useInfiniteQuery(analysisKeysQuery(apiClient, accessToken, query)); + const keyPages = useInfiniteQuery(analysisKeysQuery(api, query)); const keys = keyPages.data?.pages.flatMap((page) => page.keys) ?? []; const choice = keys.find((key) => key.token === value) ?? (selected?.token === value ? selected : null); const loading = keyPages.isFetching; diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx index 00e47d97a8b..ccbf9748c0e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/DetailContent.tsx @@ -1,5 +1,5 @@ "use client"; -import { useLensDemo } from "@/components/lens/LensDemoContext"; +import { useTracesApi } from "@/components/lens/services"; import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { useState } from "react"; @@ -8,7 +8,6 @@ import { AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cva.config"; -import { agentTraceSpanCall, agentTraceSpanErrorCall } from "../../networking"; import { type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows"; import { Card, MessageCard, Section, ToolResultCard } from "./MessageCard"; import type { ErrorSource } from "./traceTree"; @@ -30,15 +29,10 @@ const errorReason = (headline: string): string => /^([A-Za-z_][\w.]*)\(/.exec(he /** Shared lazy fetch of one span's full input / output / attributes. */ export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null, traceRef?: string) { - const demo = useLensDemo(); + const traces = useTracesApi(accessToken); const queryOptions: UseQueryOptions = { queryKey: ["agentTraceSpan", traceId, traceRef, spanId, accessToken], - queryFn: () => - demo - ? demo.client.get( - `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId as string)}`, - ) - : agentTraceSpanCall(accessToken, traceId, spanId as string, traceRef), + queryFn: () => traces.span(traceId, spanId as string, traceRef), enabled: spanId !== null, staleTime: Infinity, }; @@ -153,17 +147,12 @@ interface DetailContentProps { } function DiagnosticContent({ accessToken, traceId, traceRef, span }: DetailContentProps) { - const demo = useLensDemo(); + const traces = useTracesApi(accessToken); const [opened, setOpened] = useState(false); const [cursor, setCursor] = useState(null); const queryOptions: UseQueryOptions = { queryKey: ["agentTraceSpanError", traceId, traceRef, span.span_id, accessToken, cursor], - queryFn: () => - demo - ? demo.client.get( - `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(span.span_id)}/error`, - ) - : agentTraceSpanErrorCall(accessToken, traceId, span.span_id, { traceRef, cursor }), + queryFn: () => traces.spanError(traceId, span.span_id, { traceRef, cursor }), enabled: opened, staleTime: Infinity, gcTime: 0, diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceConversation.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceConversation.tsx index b0e9463556b..7b6c9eccd68 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceConversation.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceConversation.tsx @@ -5,9 +5,8 @@ import { useState } from "react"; import { ChevronRight, Wrench } from "lucide-react"; import { cn } from "@/lib/cva.config"; import { CopyButton } from "./CopyButton"; -import { useLensDemo } from "@/components/lens/LensDemoContext"; +import { useTracesApi } from "@/components/lens/services"; import { Button } from "@/components/ui/button"; -import { agentTraceSpanCall } from "../../networking"; import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation"; import { ErrorBlock } from "./DetailContent"; import { Markdown, ToolCallBlock } from "./MessageCard"; @@ -23,7 +22,7 @@ export function TraceConversation({ accessToken: string; onOpenStep: (id: string) => void; }) { - const demo = useLensDemo(); + const traces = useTracesApi(accessToken); const [limit, setLimit] = useState(CONVERSATION_PAGE_SIZE); const steps = conversationSteps(trace.spans); const visible = steps.slice(0, limit); @@ -31,12 +30,7 @@ export function TraceConversation({ const queries = useQueries({ queries: visible.map((span) => ({ queryKey: ["agentTraceSpan", traceId, traceRef, span.span_id, accessToken], - queryFn: (): Promise => - demo - ? demo.client.get( - `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(span.span_id)}`, - ) - : agentTraceSpanCall(accessToken, traceId, span.span_id, traceRef), + queryFn: (): Promise => traces.span(traceId, span.span_id, traceRef), staleTime: Infinity, retry: false, })), diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx index c672b9d5135..acdaef64873 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TraceDrawer.tsx @@ -1,5 +1,6 @@ "use client"; import { useLensDemo } from "@/components/lens/LensDemoContext"; +import { useTracesApi } from "@/components/lens/services"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, Check, Copy } from "lucide-react"; @@ -11,7 +12,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -import { agentTraceCall, getProxyBaseUrl } from "../../networking"; +import { getProxyBaseUrl } from "../../networking"; import { DetailPane } from "./DetailPane"; import { IdChip } from "./IdChip"; import { formatCost } from "./AgentTracesTable"; @@ -369,14 +370,11 @@ interface RunViewProps { /** One agent run: header with totals and "Copy for agent", span tree on the left, span details on the right. */ export function RunView({ traceId, traceRef, initialSpanId, accessToken, onBack, embedded = false }: RunViewProps) { - const demo = useLensDemo(); + const traces = useTracesApi(accessToken); const [view, setView] = useState("steps"); const traceQuery = useQuery({ queryKey: ["agentTrace", traceId, traceRef, accessToken], - queryFn: () => - demo - ? demo.client.get(`/v1/traces/${encodeURIComponent(traceId)}`) - : agentTraceCall(accessToken, traceId, traceRef), + queryFn: () => traces.trace(traceId, traceRef), staleTime: 30_000, }); const trace = traceQuery.data; diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/tracesApi.ts b/ui/litellm-dashboard/src/components/view_logs/TraceView/tracesApi.ts new file mode 100644 index 00000000000..398fe1ec95b --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/tracesApi.ts @@ -0,0 +1,39 @@ +import { + agentTraceCall, + agentTraceListCall, + agentTraceSpanCall, + agentTraceSpanErrorCall, + apiClient, +} from "../../networking"; +import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./traceTypes"; + +export interface TraceWindow { + readonly startMs: number; + readonly endMs: number; + readonly cursor?: string | null; +} + +export interface TracesApi { + list(window: TraceWindow): Promise; + anyRecorded(): Promise; + trace(traceId: string, traceRef?: string): Promise; + span(traceId: string, spanId: string, traceRef?: string): Promise; + spanError( + traceId: string, + spanId: string, + options: { readonly traceRef?: string; readonly cursor?: string | null }, + ): Promise; +} + +export function liveTracesApi(accessToken: string): TracesApi { + return { + list: (window) => agentTraceListCall({ accessToken, ...window }), + anyRecorded: async () => { + const page = await apiClient.get("/v1/traces", { accessToken, query: { start_ms: 0 } }); + return page.data.length > 0; + }, + trace: (traceId, traceRef) => agentTraceCall(accessToken, traceId, traceRef), + span: (traceId, spanId, traceRef) => agentTraceSpanCall(accessToken, traceId, spanId, traceRef), + spanError: (traceId, spanId, options) => agentTraceSpanErrorCall(accessToken, traceId, spanId, options), + }; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/useAgentTraces.ts b/ui/litellm-dashboard/src/components/view_logs/TraceView/useAgentTraces.ts index 46f4b4bed71..dd3bbc873b3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/useAgentTraces.ts +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/useAgentTraces.ts @@ -1,11 +1,10 @@ -import { useLensDemo } from "@/components/lens/LensDemoContext"; +import { useTracesApi } from "@/components/lens/services"; import { useInfiniteQuery, useQuery, type UseQueryOptions } from "@tanstack/react-query"; import moment from "moment"; import { useMemo } from "react"; import { ApiError } from "@/lib/http/client"; -import { agentTraceListCall, apiClient } from "../../networking"; import { LIVE_TAIL_INTERVAL_MS } from "../log_filter_logic"; import type { TracePage, TraceSummary } from "./traceTypes"; @@ -66,20 +65,14 @@ export function useAgentTraces({ isLiveTail, enabled, }: UseAgentTracesOptions): AgentTracesResult { - const demo = useLensDemo(); + const traces = useTracesApi(accessToken); const fetchPage = (pageParam: unknown): Promise => { const nowMs = Date.now(); - const listOptions: Parameters[0] = { - accessToken, + return traces.list({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, nowMs), endMs: isCustomDate ? moment(endTime).valueOf() : nowMs, cursor: pageParam as string | null, - }; - return demo - ? demo.client.get("/v1/traces", { - query: { start_ms: listOptions.startMs, end_ms: listOptions.endMs, cursor: listOptions.cursor }, - }) - : agentTraceListCall(listOptions); + }); }; const queryOptions: Parameters>[0] = { queryKey: ["agentTraces", accessToken, startTime, endTime, isCustomDate], @@ -96,11 +89,11 @@ export function useAgentTraces({ }; const query = useInfiniteQuery(queryOptions); - const traces = useMemo(() => query.data?.pages.flatMap((page) => page.data) ?? [], [query.data]); + const loaded = useMemo(() => query.data?.pages.flatMap((page) => page.data) ?? [], [query.data]); const notEnabled = isTracingNotEnabled(query.error); return { - traces, + traces: loaded, isLoading: query.isLoading, isFetching: query.isFetching, notEnabledDetail: notEnabled ? query.error?.message || "Agent tracing is not enabled" : null, @@ -112,16 +105,14 @@ export function useAgentTraces({ } export function useTraceAvailability(accessToken: string, enabled: boolean) { - const demo = useLensDemo(); - const client = demo?.client ?? apiClient; - const options: UseQueryOptions = { + const traces = useTracesApi(accessToken); + const options: UseQueryOptions = { queryKey: ["trace-availability", accessToken], - queryFn: () => client.get("/v1/traces", { accessToken, query: { start_ms: 0 } }), - select: (page: TracePage) => page.data.length > 0, + queryFn: () => traces.anyRecorded(), enabled, retry: false, refetchInterval: (query) => - query.state.data?.data.length || requiresUserAction(query.state.error) ? false : LIVE_TAIL_INTERVAL_MS, + query.state.data || requiresUserAction(query.state.error) ? false : LIVE_TAIL_INTERVAL_MS, refetchOnWindowFocus: (query) => !requiresUserAction(query.state.error), refetchOnReconnect: (query) => !requiresUserAction(query.state.error), refetchIntervalInBackground: false,