From e92bd50de11b1825389afb2ba4fe39b679d86bba Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:08 +0000 Subject: [PATCH] refactor(ui): inject Lens backends as services instead of a fake HTTP client (#44369) The interactive Lens demo used to build a fetch shim that encoded in-memory fixtures as HTTP responses so the shared ApiClient could decode them again, and every trace view branched on demo vs live to pick a URL. Lens and the trace views now depend on two small service interfaces, LensApi and TracesApi, with named operations. The live layer wraps the existing HTTP calls, the demo layer reads fixtures directly, and a React context provides whichever one the session runs on. Without a provider the hooks fall back to the live implementation, so the live app and existing tests are unchanged. Co-authored-by: Yujong Lee Co-authored-by: Claude Fable 5.1 --- .../src/components/lens/LensDemoContext.tsx | 2 - .../src/components/lens/LensWorkspace.tsx | 13 +- .../src/components/lens/api/mutations.ts | 63 ++---- .../src/components/lens/api/queries.ts | 201 +++++------------- .../src/components/lens/api/service.ts | 137 ++++++++++++ .../src/components/lens/api/useLensApi.ts | 8 - .../lens/demo/createLensDemo.test.ts | 13 +- .../components/lens/demo/createLensDemo.ts | 117 +++++----- .../investigations/InvestigationsView.tsx | 24 +-- .../detail/InvestigationActions.tsx | 7 +- .../detail/InvestigationDetail.tsx | 5 +- .../investigations/useInvestigationResults.ts | 12 +- .../src/components/lens/services.ts | 26 +++ .../lens/setup/MatchingActivity.tsx | 12 +- .../setup/worker/AnalysisAccessFields.tsx | 6 +- .../lens/setup/worker/AnalysisKeyDetails.tsx | 6 +- .../lens/setup/worker/AnalysisKeyPicker.tsx | 6 +- .../view_logs/TraceView/DetailContent.tsx | 21 +- .../view_logs/TraceView/TraceConversation.tsx | 12 +- .../view_logs/TraceView/TraceDrawer.tsx | 10 +- .../view_logs/TraceView/tracesApi.ts | 39 ++++ .../view_logs/TraceView/useAgentTraces.ts | 29 +-- 22 files changed, 423 insertions(+), 346 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/lens/api/service.ts delete mode 100644 ui/litellm-dashboard/src/components/lens/api/useLensApi.ts create mode 100644 ui/litellm-dashboard/src/components/lens/services.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/tracesApi.ts 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,