mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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 <yujong@berri.ai> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
09313bcf1b
commit
e92bd50de1
22 changed files with 423 additions and 346 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<LensDemoContext.Provider value={demo}>
|
||||
<QueryClientProvider client={client}>
|
||||
<LensContent accessToken="lens-demo" userRole="" readOnly initialTab={initialTab} onExit={onExit} />
|
||||
</QueryClientProvider>
|
||||
</LensDemoContext.Provider>
|
||||
<LensServicesContext.Provider value={demo.services}>
|
||||
<LensDemoContext.Provider value={demo}>
|
||||
<QueryClientProvider client={client}>
|
||||
<LensContent accessToken="lens-demo" userRole="" readOnly initialTab={initialTab} onExit={onExit} />
|
||||
</QueryClientProvider>
|
||||
</LensDemoContext.Provider>
|
||||
</LensServicesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<unknown>;
|
||||
|
||||
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]<unknown>(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<Lens>(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }),
|
||||
mutationFn: ({ id, settings }: { id?: string; settings: Settings }): Promise<Lens> => api.saveLens(id, settings),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeWorker(accessToken: string) {
|
||||
const apiClient = useLensApi();
|
||||
return useMutation({
|
||||
retry: false,
|
||||
mutationFn: (id: string) => apiClient.delete<unknown>(`/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<string> {
|
||||
export async function createAnalysisKey(api: LensApi, access: AnalysisAccess): Promise<string> {
|
||||
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<unknown>(`/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<WorkerCreated>("/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<LensList>("/lens", { accessToken });
|
||||
const current = await api.lenses();
|
||||
if (!current.workers.some((worker) => !worker.revoked && worker.analysis_key_id === newKey)) {
|
||||
await apiClient.post<unknown>("/key/delete", { accessToken, body: { keys: [newKey] } });
|
||||
await api.deleteKeys([newKey]);
|
||||
}
|
||||
onChanged();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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<LensList>("/lens", { accessToken }),
|
||||
queryKey: lensKeys.list(api.scope),
|
||||
queryFn: () => api.lenses(),
|
||||
refetchInterval: (current: Query<LensList>): 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<Job[]>(`/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<Job>(`/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<components["schemas"]["ExecutionContent"]>(
|
||||
`/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<Sample>("/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<string[]>("/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<typeof keySchema>;
|
||||
|
||||
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<typeof pageSchema>, pages: z.infer<typeof pageSchema>[]) =>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
137
ui/litellm-dashboard/src/components/lens/api/service.ts
Normal file
137
ui/litellm-dashboard/src/components/lens/api/service.ts
Normal file
|
|
@ -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<typeof keySchema>;
|
||||
export type KeyPage = z.infer<typeof keyPageSchema>;
|
||||
export type KeyInfo = z.infer<typeof keyInfoSchema>["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<LensList>;
|
||||
activity(): Promise<{ traces: boolean; requests: boolean }>;
|
||||
runs(lensId: string, offset: number): Promise<Job[]>;
|
||||
run(lensId: string, jobId: string): Promise<Job>;
|
||||
execution(lensId: string, executionId: string, offset: number): Promise<ExecutionContent>;
|
||||
sample(selection: ActivitySelection, offset: number, asOf: string): Promise<Sample>;
|
||||
agents(): Promise<string[]>;
|
||||
models(): Promise<{ data: { id: string }[] }>;
|
||||
modelDetails(): Promise<{ data: AnalysisModelInfo[] }>;
|
||||
keys(alias: string, page: number, signal: AbortSignal): Promise<KeyPage>;
|
||||
keyInfo(keyId: string): Promise<KeyInfo>;
|
||||
saveLens(id: string | undefined, settings: Settings): Promise<Lens>;
|
||||
startRun(lensId: string): Promise<void>;
|
||||
cancelRun(lensId: string): Promise<void>;
|
||||
reviewFinding(lensId: string, findingId: string, status: FindingStatus, reason: string): Promise<void>;
|
||||
registerWorker(analysisKeyId: string | null): Promise<WorkerCreated>;
|
||||
setWorkerBillingKey(workerId: string, analysisKeyId: string | null): Promise<void>;
|
||||
revokeWorker(workerId: string): Promise<void>;
|
||||
generateAnalysisKey(request: AnalysisKeyRequest): Promise<{ token_id?: string }>;
|
||||
deleteKeys(keys: readonly string[]): Promise<void>;
|
||||
}
|
||||
|
||||
export function liveLensApi(apiClient: ApiClient, accessToken: string): LensApi {
|
||||
const encode = encodeURIComponent;
|
||||
return {
|
||||
scope: accessToken,
|
||||
lenses: () => apiClient.get<LensList>("/lens", { accessToken }),
|
||||
activity: () => apiClient.get("/lens/activity/available", { accessToken }),
|
||||
runs: (lensId, offset) => apiClient.get<Job[]>(`/lens/${lensId}/runs`, { accessToken, query: { offset } }),
|
||||
run: (lensId, jobId) => apiClient.get<Job>(`/lens/${lensId}/runs/${jobId}`, { accessToken }),
|
||||
execution: (lensId, executionId, offset) =>
|
||||
apiClient.get<ExecutionContent>(`/lens/${lensId}/executions/${encode(executionId)}`, {
|
||||
accessToken,
|
||||
query: { offset },
|
||||
}),
|
||||
sample: (selection, offset, asOf) => {
|
||||
const { lookback_hours, ...selectionSettings } = selection;
|
||||
return apiClient.post<Sample>("/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<string[]>("/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<Lens>(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<WorkerCreated>("/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 } }),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { useLensDemo } from "../LensDemoContext";
|
||||
|
||||
export function useLensApi() {
|
||||
return useLensDemo()?.client ?? apiClient;
|
||||
}
|
||||
|
|
@ -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<TracePage>("/v1/traces");
|
||||
const recent = await client.get<TracePage>("/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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof createLensDemoData>;
|
||||
|
||||
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<typeof createLensDemoData>, 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<never> =>
|
||||
Promise.reject(new ApiError("This item is not in the demo", 404, { detail: "This item is not in the demo" }));
|
||||
const readOnly = (): Promise<never> =>
|
||||
Promise.reject(new ApiError("Demo data is read-only", 403, { detail: "Demo data is read-only" }));
|
||||
const found = <T>(value: T | undefined): Promise<T> => (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);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
update: (write: LensWrite) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -45,7 +46,7 @@ export function InvestigationActions({
|
|||
{lens.settings.enabled ? (
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onClick={() => update(`/lens/${lens.id}`, { ...lens.settings, enabled: false }, "put")}
|
||||
onClick={() => update((api) => api.saveLens(lens.id, { ...lens.settings, enabled: false }))}
|
||||
>
|
||||
<Pause />
|
||||
Pause monitoring
|
||||
|
|
@ -58,7 +59,7 @@ export function InvestigationActions({
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button disabled={busy || !!active || !ready} onClick={() => update(`/lens/${lens.id}/runs`, {})}>
|
||||
<Button disabled={busy || !!active || !ready} onClick={() => update((api) => api.startRun(lens.id))}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
update: (write: LensWrite) => Promise<void>;
|
||||
connected: boolean;
|
||||
results: ReturnType<typeof useInvestigationResults>;
|
||||
}) {
|
||||
|
|
@ -96,7 +97,7 @@ export function InvestigationDetail({
|
|||
readOnly
|
||||
? undefined
|
||||
: () => {
|
||||
void update(`/lens/${lens.id}/cancel`, {});
|
||||
void update((api) => api.cancelRun(lens.id));
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
26
ui/litellm-dashboard/src/components/lens/services.ts
Normal file
26
ui/litellm-dashboard/src/components/lens/services.ts
Normal file
|
|
@ -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<LensServices | null>(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;
|
||||
}
|
||||
|
|
@ -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<InvestigationInput>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<WorkerFormInput>();
|
||||
const apiClient = useLensApi();
|
||||
const models = useQuery(lensQueries.models(apiClient, accessToken));
|
||||
const api = useLensApi(accessToken);
|
||||
const models = useQuery(lensQueries.models(api));
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<WorkerFormInput>();
|
||||
const value = useWatch({ control, name: "analysisKey" });
|
||||
const apiClient = useLensApi();
|
||||
const api = useLensApi(accessToken);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Key | null>(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;
|
||||
|
|
|
|||
|
|
@ -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<SpanDetail, Error> = {
|
||||
queryKey: ["agentTraceSpan", traceId, traceRef, spanId, accessToken],
|
||||
queryFn: () =>
|
||||
demo
|
||||
? demo.client.get<SpanDetail>(
|
||||
`/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<string | null>(null);
|
||||
const queryOptions: UseQueryOptions<SpanErrorPage, Error> = {
|
||||
queryKey: ["agentTraceSpanError", traceId, traceRef, span.span_id, accessToken, cursor],
|
||||
queryFn: () =>
|
||||
demo
|
||||
? demo.client.get<SpanErrorPage>(
|
||||
`/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,
|
||||
|
|
|
|||
|
|
@ -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<SpanDetail> =>
|
||||
demo
|
||||
? demo.client.get<SpanDetail>(
|
||||
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(span.span_id)}`,
|
||||
)
|
||||
: agentTraceSpanCall(accessToken, traceId, span.span_id, traceRef),
|
||||
queryFn: (): Promise<SpanDetail> => traces.span(traceId, span.span_id, traceRef),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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<TraceView>("steps");
|
||||
const traceQuery = useQuery({
|
||||
queryKey: ["agentTrace", traceId, traceRef, accessToken],
|
||||
queryFn: () =>
|
||||
demo
|
||||
? demo.client.get<Trace>(`/v1/traces/${encodeURIComponent(traceId)}`)
|
||||
: agentTraceCall(accessToken, traceId, traceRef),
|
||||
queryFn: () => traces.trace(traceId, traceRef),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const trace = traceQuery.data;
|
||||
|
|
|
|||
|
|
@ -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<TracePage>;
|
||||
anyRecorded(): Promise<boolean>;
|
||||
trace(traceId: string, traceRef?: string): Promise<Trace>;
|
||||
span(traceId: string, spanId: string, traceRef?: string): Promise<SpanDetail>;
|
||||
spanError(
|
||||
traceId: string,
|
||||
spanId: string,
|
||||
options: { readonly traceRef?: string; readonly cursor?: string | null },
|
||||
): Promise<SpanErrorPage>;
|
||||
}
|
||||
|
||||
export function liveTracesApi(accessToken: string): TracesApi {
|
||||
return {
|
||||
list: (window) => agentTraceListCall({ accessToken, ...window }),
|
||||
anyRecorded: async () => {
|
||||
const page = await apiClient.get<TracePage>("/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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<TracePage> => {
|
||||
const nowMs = Date.now();
|
||||
const listOptions: Parameters<typeof agentTraceListCall>[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<TracePage>("/v1/traces", {
|
||||
query: { start_ms: listOptions.startMs, end_ms: listOptions.endMs, cursor: listOptions.cursor },
|
||||
})
|
||||
: agentTraceListCall(listOptions);
|
||||
});
|
||||
};
|
||||
const queryOptions: Parameters<typeof useInfiniteQuery<TracePage, Error>>[0] = {
|
||||
queryKey: ["agentTraces", accessToken, startTime, endTime, isCustomDate],
|
||||
|
|
@ -96,11 +89,11 @@ export function useAgentTraces({
|
|||
};
|
||||
const query = useInfiniteQuery<TracePage, Error>(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<TracePage, Error, boolean> = {
|
||||
const traces = useTracesApi(accessToken);
|
||||
const options: UseQueryOptions<boolean, Error> = {
|
||||
queryKey: ["trace-availability", accessToken],
|
||||
queryFn: () => client.get<TracePage>("/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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue