From 46196339d27113ea029e564fd5efe13ef029d1c9 Mon Sep 17 00:00:00 2001 From: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:45:31 -0700 Subject: [PATCH 01/83] feat(ui): add test trace, tracing key and otel endpoints to tracing setup (#44090) * feat(ui): add a call that posts an OTLP export to the proxy * feat(ui): build a sample agent run as an OTLP export * feat(ui): add a pulsing dot for active tracing * feat(ui): render a crisp sample run preview from real trace components * chore(ui): remove the pixelated agent traces preview image * feat(ui): add test trace, tracing key, otel endpoints and more frameworks to tracing setup * test(ui): cover tracing setup test trace, key masking, endpoints and frameworks * feat(ui): open the received test trace and mark tracing as active * test(ui): mock the new tracing setup network calls * fix(ui): let tracing setup use the full page width * fix(ui): send OTLP/JSON sample trace ids as hex per the spec * test(ui): cover the sample trace export shape and hex ids --- .../public/assets/agent-traces-preview.png | Bin 113074 -> 0 bytes .../src/app/(dashboard)/lens/page.tsx | 7 +- .../src/components/networking.tsx | 3 + .../view_logs/TraceView/ActiveDot.tsx | 8 + .../view_logs/TraceView/AgentTracesPage.tsx | 14 +- .../TraceView/AgentTracesSection.test.tsx | 3 +- .../TraceView/AgentTracesSection.tsx | 30 +- .../view_logs/TraceView/TracePreview.tsx | 70 ++ .../TraceView/TracingSetupCard.test.tsx | 196 +++++- .../view_logs/TraceView/TracingSetupCard.tsx | 662 ++++++++++++++---- .../view_logs/TraceView/previewTrace.json | 98 +++ .../view_logs/TraceView/sampleTrace.test.ts | 48 ++ .../view_logs/TraceView/sampleTrace.ts | 95 +++ 13 files changed, 1061 insertions(+), 173 deletions(-) delete mode 100644 ui/litellm-dashboard/public/assets/agent-traces-preview.png create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/ActiveDot.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/TracePreview.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/previewTrace.json create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.test.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.ts diff --git a/ui/litellm-dashboard/public/assets/agent-traces-preview.png b/ui/litellm-dashboard/public/assets/agent-traces-preview.png deleted file mode 100644 index 34569e263315acaf94bd75a51ff44e3acd3d52cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113074 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 B#*Y91 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/page.tsx index 282262b6322..26726691a48 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/page.tsx @@ -37,7 +37,12 @@ export default function LensPage() { - + {isProxyAdminTierRole(userRole ?? "") ? ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 97d1782b08c..da902d4a9e2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2122,6 +2122,9 @@ export const agentTraceListCall = async ({ return apiClient.get(`/v1/traces`, { accessToken, query }); }; +export const sendOtlpTraceCall = async (accessToken: string, exportRequest: object): Promise => + apiClient.post(`/v1/traces`, { accessToken, body: exportRequest }); + export const agentTraceCall = async (accessToken: string, traceId: string, traceRef?: string): Promise => apiClient.get(`/v1/traces/${encodeURIComponent(traceId)}`, { accessToken, diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/ActiveDot.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/ActiveDot.tsx new file mode 100644 index 00000000000..b3d4cf55482 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/ActiveDot.tsx @@ -0,0 +1,8 @@ +export function ActiveDot() { + return ( +

@@ -156,7 +174,7 @@ export function AgentTracesSection({ > ← Back to traces - +
); } @@ -183,7 +201,7 @@ export function AgentTracesSection({ onStatusChange={setStatus} > {timeControls && ( diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracePreview.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracePreview.tsx new file mode 100644 index 00000000000..c4fd04602ac --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracePreview.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { cn } from "@/lib/cva.config"; + +import previewTrace from "./previewTrace.json"; +import { SpanIcon } from "./SpanIcon"; +import type { SpanType } from "./traceTypes"; +import { fmtMs } from "./traceUtils"; + +interface PreviewRow { + id: string; + name: string; + type: SpanType; + model: string | null; + depth: number; + start_offset_ms: number; + duration_ms: number; + error: boolean; +} + +const preview = previewTrace as { + name: string; + input_preview: string; + span_count: number; + duration_ms: number; + rows: PreviewRow[]; +}; + +export function TracePreview() { + const total = preview.duration_ms; + return ( +
+
+ + {preview.name} + {preview.input_preview} + + {preview.span_count} steps · {fmtMs(total)} + +
+
+ {preview.rows.map((row) => ( +
+
+ + {row.name} +
+
+
+
+ + {fmtMs(row.duration_ms)} + +
+ ))} +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.test.tsx index 4f42180d409..027870ab1ae 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.test.tsx @@ -1,39 +1,98 @@ -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { chooseSelectOption } from "@/../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { chooseSelectOption, renderWithProviders } from "@/../tests/test-utils"; import { copyToClipboard } from "@/utils/dataUtils"; -import { codingAgentPrompt, tracingEnvSnippet, TracingSetupCard } from "./TracingSetupCard"; +import { agentTraceCall, apiClient, sendOtlpTraceCall } from "../../networking"; +import { + codingAgentCommand, + codingAgentPrompt, + maskSecret, + TRACING_KEY_REQUEST, + tracingEnvSnippet, + TracingSetupCard, +} from "./TracingSetupCard"; +import type { Trace } from "./traceTypes"; -vi.mock("../../networking", () => ({ getProxyBaseUrl: () => "http://proxy.test/" })); +vi.mock("../../networking", () => ({ + getProxyBaseUrl: () => "http://proxy.test/", + sendOtlpTraceCall: vi.fn(), + agentTraceCall: vi.fn(), + apiClient: { post: vi.fn() }, +})); vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true) })); +const SECRET = "sk-abcdefghijklmnopWXYZ"; + +const renderCard = ( + props: { + detail?: string | null; + connected?: boolean; + onCheck?: () => void; + readOnly?: boolean; + canMintTracingKey?: boolean; + } = {}, +) => { + const onOpenTrace = vi.fn(); + renderWithProviders( + , + ); + return { onOpenTrace, card: screen.getByTestId("tracing-setup-card") }; +}; + +beforeEach(() => vi.clearAllMocks()); + describe("TracingSetupCard", () => { - it("shows agent connection guidance once tracing is enabled without presenting example data as live", async () => { + it("shows agent connection guidance and labels the example run as sample data", async () => { const user = userEvent.setup(); - render(); + const { card } = renderCard(); expect(screen.getByRole("heading", { name: "Connect your agent" })).toBeVisible(); expect(screen.getByText("Tracing enabled")).toBeVisible(); expect(screen.getByText("Waiting for your first trace")).toBeVisible(); - expect(screen.getByRole("img", { hidden: true, name: /Example agent trace/ })).not.toBeVisible(); - expect(screen.getByTestId("tracing-setup-card")).not.toHaveTextContent("store: clickhouse"); + expect(screen.getByTestId("trace-preview")).toBeVisible(); + expect(card).toHaveTextContent("sample data, not your runs"); + expect(card).not.toHaveTextContent("store: clickhouse"); await user.click(screen.getByText("Set up manually")); - expect(screen.getByText(/OTEL_EXPORTER_OTLP_ENDPOINT=http:\/\/proxy.test/)).toBeVisible(); - expect(screen.getByTestId("tracing-setup-card")).not.toHaveTextContent(/langsmith/i); + expect(screen.getByText(/^export OTEL_EXPORTER_OTLP_ENDPOINT=http:\/\/proxy.test/)).toBeVisible(); + expect(card).not.toHaveTextContent(/langsmith/i); }); - it("copies instructions for the selected framework and preserves both manual installers", async () => { + it("lists the OTEL endpoints before the framework picker, each copyable", async () => { const user = userEvent.setup(); - render(); + const { card } = renderCard(); + const text = card.textContent ?? ""; + expect(text.indexOf("OpenTelemetry (OTEL) endpoints")).toBeLessThan(text.indexOf("Your agent framework")); + await user.click(screen.getByRole("button", { name: "Copy http://proxy.test/v1/traces" })); + expect(copyToClipboard).toHaveBeenLastCalledWith("http://proxy.test/v1/traces"); + }); + + it("hides the sample preview once traces are arriving", () => { + renderCard({ connected: true }); + expect(screen.getByRole("heading", { name: "Connect another agent" })).toBeVisible(); + expect(screen.queryByTestId("trace-preview")).not.toBeInTheDocument(); + }); + + it("builds the coding agent command for the selected framework and keeps both manual installers", async () => { + const user = userEvent.setup(); + renderCard(); await chooseSelectOption(user, screen.getByRole("combobox", { name: "Your agent framework" }), "CrewAI"); - await user.click(screen.getByRole("button", { name: "Copy setup prompt" })); - expect(copyToClipboard).toHaveBeenLastCalledWith( - codingAgentPrompt("http://proxy.test", { - label: "CrewAI", - packages: "crewai openinference-instrumentation-crewai", - }), - ); - expect(screen.getByRole("button", { name: "Prompt copied" })).toBeVisible(); + const prompt = codingAgentPrompt("http://proxy.test", { + label: "CrewAI", + packages: "crewai openinference-instrumentation-crewai", + }); + const commandText = () => screen.getByText(/^claude |^codex /).textContent; + expect(commandText()).toBe(codingAgentCommand("Claude Code", prompt)); + await user.click(screen.getByRole("tab", { name: "Codex" })); + expect(commandText()).toBe(codingAgentCommand("Codex", prompt)); + await user.click(screen.getByText("Set up manually")); expect(screen.getByText(/pip install -U opentelemetry-distro/)).toHaveTextContent( "crewai openinference-instrumentation-crewai", @@ -44,13 +103,88 @@ describe("TracingSetupCard", () => { ); }); + it("uses npm and a CommonJS-safe TypeScript entrypoint for the Vercel AI SDK", async () => { + const user = userEvent.setup(); + const { card } = renderCard(); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Your agent framework" }), "Vercel AI SDK"); + await user.click(screen.getByText("Set up manually")); + expect(card).toHaveTextContent("npm install ai @ai-sdk/openai-compatible @vercel/otel"); + expect(card).toHaveTextContent("my_agent.ts"); + expect(card).not.toHaveTextContent("opentelemetry-instrument python"); + const quickstart = screen.getByText(/registerOTel\(\{/).textContent ?? ""; + expect(quickstart).toContain("async function main()"); + expect(quickstart.split("\n").filter((line) => /^(const|let) .*= await /.test(line))).toEqual([]); + }); + + it("hides the actions a read-only viewer cannot perform", () => { + const { card } = renderCard({ readOnly: true }); + expect(screen.queryByRole("button", { name: "Send a test trace" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Generate tracing key" })).not.toBeInTheDocument(); + expect(card).toHaveTextContent("ask a proxy admin for one"); + expect(card).toHaveTextContent("OpenTelemetry (OTEL) endpoints"); + }); + + it("offers a scoped tracing key only to callers allowed to set key routes", () => { + const { card } = renderCard({ canMintTracingKey: false }); + expect(screen.queryByRole("button", { name: "Generate tracing key" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send a test trace" })).toBeVisible(); + expect(card).toHaveTextContent("Use any LiteLLM virtual key you already have"); + }); + + it("generates a tracing key that stays masked on screen but copies in full", async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.post).mockResolvedValue({ key: SECRET }); + const { card } = renderCard(); + + await user.click(screen.getByRole("button", { name: "Generate tracing key" })); + + expect(await screen.findByText("Your tracing key")).toBeVisible(); + expect(apiClient.post).toHaveBeenCalledWith("/key/generate", { + accessToken: "sk-admin", + body: TRACING_KEY_REQUEST, + }); + expect(TRACING_KEY_REQUEST.allowed_routes).toEqual(["/v1/traces"]); + expect(card).not.toHaveTextContent(SECRET); + expect(card).toHaveTextContent(maskSecret(SECRET)); + await user.click(screen.getAllByRole("button", { name: "Copy" })[0]); + expect(copyToClipboard).toHaveBeenLastCalledWith(SECRET); + }); + + it("sends a test trace, waits for it to land, then opens it", async () => { + const user = userEvent.setup(); + const summary = { trace_id: "abc", name: "weather_agent" } as Trace["summary"]; + vi.mocked(sendOtlpTraceCall).mockResolvedValue(undefined); + vi.mocked(agentTraceCall).mockResolvedValue({ summary, agents: [], spans: [] } as unknown as Trace); + const { onOpenTrace } = renderCard(); + + await user.click(screen.getByRole("button", { name: "Send a test trace" })); + await user.click(await screen.findByRole("button", { name: /View trace/ })); + + expect(sendOtlpTraceCall).toHaveBeenCalledOnce(); + expect(vi.mocked(agentTraceCall).mock.calls[0][1]).toMatch(/^[0-9a-f]{32}$/); + expect(onOpenTrace).toHaveBeenCalledWith(summary); + }); + + it("reports a failed send instead of claiming success", async () => { + const user = userEvent.setup(); + vi.mocked(sendOtlpTraceCall).mockRejectedValue(new Error("boom")); + renderCard(); + + await user.click(screen.getByRole("button", { name: "Send a test trace" })); + + expect(await screen.findByText("Could not send the test trace.")).toBeVisible(); + expect(agentTraceCall).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /View trace/ })).not.toBeInTheDocument(); + }); + it("guides proxy setup before agent setup and allows checking readiness", async () => { const user = userEvent.setup(); const onCheck = vi.fn(); - render(); + const { card } = renderCard({ detail: "Agent tracing is not enabled", onCheck }); expect(screen.getByRole("heading", { name: "Enable tracing" })).toBeVisible(); - expect(screen.getByTestId("tracing-setup-card")).toHaveTextContent("store: clickhouse"); + expect(card).toHaveTextContent("store: clickhouse"); expect(screen.queryByRole("combobox", { name: "Your agent framework" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Send a test trace" })).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Check setup" })); expect(onCheck).toHaveBeenCalledOnce(); expect(screen.getByText(/Tracing is still unavailable/)).toBeVisible(); @@ -63,7 +197,13 @@ describe("setup snippets", () => { expect(env).toContain("OTEL_EXPORTER_OTLP_ENDPOINT=http://proxy.test\n"); expect(env).toContain("OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf"); expect(env).not.toContain("/v1/traces"); + expect(env).not.toContain("export LITELLM_API_KEY="); expect(env).toContain("Bearer $LITELLM_API_KEY"); + const withKey = tracingEnvSnippet("http://proxy.test", SECRET); + expect(withKey).toContain(`export LITELLM_TRACING_KEY=${SECRET}\n`); + expect(withKey).toContain('OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $LITELLM_TRACING_KEY"'); + expect(withKey).not.toContain("LITELLM_API_KEY"); + const prompt = codingAgentPrompt("http://proxy.test", { label: "LangChain", packages: "langchain" }); expect(prompt).toContain("base_url=http://proxy.test/v1"); expect(prompt).toContain("opentelemetry-distro opentelemetry-exporter-otlp-proto-http langchain"); @@ -71,4 +211,14 @@ describe("setup snippets", () => { expect(prompt).toContain("Lens > Traces"); expect(prompt).not.toMatch(/langsmith/i); }); + + it("builds a shell-safe command for each coding agent", () => { + expect(codingAgentCommand("Claude Code", "it's")).toBe("claude 'it'\\''s'"); + expect(codingAgentCommand("Codex", "go")).toBe("codex 'go'"); + }); + + it("masks secrets but keeps a recognisable prefix and suffix", () => { + expect(maskSecret(SECRET)).toBe(`sk-ab${"•".repeat(16)}WXYZ`); + expect(maskSecret("short")).toBe("•••••"); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx index 183dd24cb5e..5ac5c988efe 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUpRight, Check, Copy, Loader2 } from "lucide-react"; +import { ArrowRight, ArrowUpRight, Check, Copy, KeyRound, Loader2, Send } from "lucide-react"; import { useState } from "react"; import { cn } from "@/lib/cva.config"; @@ -8,22 +8,42 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { copyToClipboard } from "@/utils/dataUtils"; -import previewImg from "../../../../public/assets/agent-traces-preview.png"; +import anthropicLogo from "../../../../public/assets/logos/anthropic.svg"; import crewaiLogo from "../../../../public/assets/logos/crewai-color.svg"; import langchainLogo from "../../../../public/assets/logos/langchain.svg"; import langgraphLogo from "../../../../public/assets/logos/langgraph-color.svg"; import llamaindexLogo from "../../../../public/assets/logos/llamaindex-color.svg"; import openaiAgentsLogo from "../../../../public/assets/logos/openai-agents.svg"; +import openaiLogo from "../../../../public/assets/logos/openai_small.svg"; import otelLogo from "../../../../public/assets/logos/opentelemetry.svg"; import pydanticAiLogo from "../../../../public/assets/logos/pydantic-ai-color.svg"; -import { getProxyBaseUrl } from "../../networking"; +import vercelLogo from "../../../../public/assets/logos/vercel.svg"; +import { agentTraceCall, apiClient, getProxyBaseUrl, sendOtlpTraceCall } from "../../networking"; +import { ActiveDot } from "./ActiveDot"; +import { sampleTraceExport } from "./sampleTrace"; +import { TracePreview } from "./TracePreview"; +import type { TraceSummary } from "./traceTypes"; const COPIED_RESET_MS = 1500; const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens"; const OTEL_BASE_PACKAGES = "opentelemetry-distro opentelemetry-exporter-otlp-proto-http"; -const RUN_SNIPPET = "opentelemetry-instrument python my_agent.py"; +const PY_RUN_SNIPPET = "opentelemetry-instrument python my_agent.py"; +const TS_RUN_SNIPPET = "npx tsx my_agent.ts"; +const SAMPLE_TRACE_POLL_MS = 1000; +export const TRACING_KEY_REQUEST = { + key_alias: "Agent tracing", + allowed_routes: ["/v1/traces"], + metadata: { purpose: "agent_tracing" }, +} as const; +const SAMPLE_TRACE_POLL_ATTEMPTS = 15; type Installer = "pip" | "uv"; +type CodingAgent = "Claude Code" | "Codex"; + +const PY_INSTALL: Record string> = { + pip: (packages) => `pip install -U ${packages}`, + uv: (packages) => `uv add ${packages}`, +}; interface FrameworkGuide { id: string; @@ -31,12 +51,50 @@ interface FrameworkGuide { logo: string; packages: string; quickstart: string; + typescript?: boolean; } const FRAMEWORKS: readonly FrameworkGuide[] = [ + { + id: "deep-agents", + label: "Deep Agents", + logo: langgraphLogo.src, + packages: "deepagents langchain-openai openinference-instrumentation-langchain", + quickstart: `from deepagents import create_deep_agent +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI(model="claude-sonnet-4-5", base_url="{PROXY}/v1", api_key=os.environ["LITELLM_API_KEY"]) +agent = create_deep_agent(model=llm, tools=[], system_prompt="You are a careful researcher.") +agent.invoke({"messages": [{"role": "user", "content": "What is LiteLLM?"}]})`, + }, + { + id: "vercel-ai-sdk", + label: "Vercel AI SDK", + logo: vercelLogo.src, + typescript: true, + packages: "ai @ai-sdk/openai-compatible @vercel/otel @opentelemetry/api", + quickstart: `import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { registerOTel } from "@vercel/otel"; +import { generateText } from "ai"; + +registerOTel({ serviceName: process.env.OTEL_SERVICE_NAME ?? "my-agent" }); + +const litellm = createOpenAICompatible({ name: "litellm", baseURL: "{PROXY}/v1", apiKey: process.env.LITELLM_API_KEY }); + +async function main() { + const { text } = await generateText({ + model: litellm("claude-sonnet-4-5"), + prompt: "What is LiteLLM?", + experimental_telemetry: { isEnabled: true, functionId: "my_agent" }, + }); + console.log(text); +} + +main();`, + }, { id: "langgraph", - label: "LangGraph / Deep Agents", + label: "LangGraph", logo: langgraphLogo.src, packages: "langgraph langchain-openai openinference-instrumentation-langchain", quickstart: `from langchain.agents import create_agent @@ -119,19 +177,23 @@ with tracer.start_as_current_span("my_agent", attributes=attrs): }, ]; -const installPackages = (guide: Pick): string => - [OTEL_BASE_PACKAGES, guide.packages].filter(Boolean).join(" "); +const installPackages = (guide: Pick): string => + guide.typescript ? guide.packages : [OTEL_BASE_PACKAGES, guide.packages].filter(Boolean).join(" "); /** The endpoint is the proxy base URL: OTLP exporters append /v1/traces themselves. */ -export const tracingEnvSnippet = (proxyUrl: string): string => +export const tracingEnvSnippet = (proxyUrl: string, tracingKey: string | null = null): string => [ + ...(tracingKey ? [`export LITELLM_TRACING_KEY=${tracingKey}`] : []), `export OTEL_EXPORTER_OTLP_ENDPOINT=${proxyUrl}`, "export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf", - 'export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $LITELLM_API_KEY"', + `export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $${tracingKey ? "LITELLM_TRACING_KEY" : "LITELLM_API_KEY"}"`, "export OTEL_SERVICE_NAME=my-agent", ].join("\n"); -export const codingAgentPrompt = (proxyUrl: string, guide: Pick): string => +export const codingAgentPrompt = ( + proxyUrl: string, + guide: Pick, +): string => [ `Send this ${guide.label} project's OpenTelemetry traces to LiteLLM.`, "", @@ -141,7 +203,9 @@ export const codingAgentPrompt = (proxyUrl: string, guide: Pick", - "3. Start the app through OTEL auto-instrumentation: opentelemetry-instrument .", + guide.typescript + ? "3. Call registerOTel() from @vercel/otel at startup and pass experimental_telemetry: { isEnabled: true } to every AI SDK call." + : "3. Start the app through OTEL auto-instrumentation: opentelemetry-instrument .", `4. Point every LLM client at LiteLLM: base_url=${proxyUrl}/v1, api key from LITELLM_API_KEY.`, "5. Give each agent and subagent a name so runs are easy to read.", "6. Run the agent once and confirm the run shows up in the LiteLLM UI under Lens > Traces.", @@ -149,6 +213,21 @@ export const codingAgentPrompt = (proxyUrl: string, guide: Pick `'${value.replaceAll("'", "'\\''")}'`; + +export const codingAgentCommand = (agent: CodingAgent, prompt: string): string => + `${agent === "Claude Code" ? "claude" : "codex"} ${shellQuote(prompt)}`; + +export const maskSecret = (secret: string): string => + secret.length > 10 ? `${secret.slice(0, 5)}${"•".repeat(16)}${secret.slice(-4)}` : "•".repeat(secret.length); + +export const otlpEndpoints = (proxyUrl: string): readonly (readonly [string, string, boolean])[] => [ + ["Traces endpoint (POST)", `${proxyUrl}/v1/traces`, true], + ["OTEL_EXPORTER_OTLP_ENDPOINT", proxyUrl, true], + ["Auth header", "Authorization: Bearer ", true], + ["Protocol", "OTLP/HTTP, protobuf or JSON (gRPC not supported)", false], +]; + export const PROXY_CONFIG_SNIPPET = [ "general_settings:", " tracing:", @@ -157,7 +236,17 @@ export const PROXY_CONFIG_SNIPPET = [ "# env: CLICKHOUSE_URL (writer) and CLICKHOUSE_READER_URL (read-only user)", ].join("\n"); -function CodeBlock({ code, tabs, wrap = false }: { code: string; tabs?: React.ReactNode; wrap?: boolean }) { +function CodeBlock({ + code, + display = code, + tabs, + wrap = false, +}: { + code: string; + display?: string; + tabs?: React.ReactNode; + wrap?: boolean; +}) { const [copied, setCopied] = useState(false); const copy = async () => { if (await copyToClipboard(code)) { @@ -184,7 +273,7 @@ function CodeBlock({ code, tabs, wrap = false }: { code: string; tabs?: React.Re wrap ? "whitespace-pre-wrap" : "overflow-x-auto", )} > - {code} + {display}
); @@ -198,10 +287,12 @@ function LineTabs({ value, options, onChange, + logos, }: { value: T; options: T[]; onChange: (v: T) => void; + logos?: Partial>; }) { return (
@@ -213,12 +304,13 @@ function LineTabs({ aria-selected={value === option} onClick={() => onChange(option)} className={cn( - "-mb-px h-9 border-b-2 text-[12.5px]", + "-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-[12.5px]", value === option ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground", )} > + {logos?.[option] && } {option} ))} @@ -226,7 +318,7 @@ function LineTabs({ ); } -function Step({ title, children }: { title: string; children: React.ReactNode }) { +function Step({ title, children }: { title: React.ReactNode; children: React.ReactNode }) { return (

{title}

@@ -235,6 +327,86 @@ function Step({ title, children }: { title: string; children: React.ReactNode }) ); } +type SendState = + | { kind: "idle" } + | { kind: "sending" } + | { kind: "waiting" } + | { kind: "ready"; trace: TraceSummary } + | { kind: "failed"; message: string }; + +const SEND_LABEL: Record, string> = { + idle: "Send a test trace", + sending: "Sending…", + waiting: "Waiting for it to arrive…", + failed: "Send a test trace", +}; + +const delay = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +async function waitForTrace(accessToken: string, traceId: string): Promise { + for (let attempt = 0; attempt < SAMPLE_TRACE_POLL_ATTEMPTS; attempt++) { + try { + return (await agentTraceCall(accessToken, traceId)).summary; + } catch { + await delay(SAMPLE_TRACE_POLL_MS); + } + } + return null; +} + +function SendTestTrace({ + accessToken, + onOpenTrace, +}: { + accessToken: string; + onOpenTrace: (trace: TraceSummary) => void; +}) { + const [state, setState] = useState({ kind: "idle" }); + const send = async () => { + setState({ kind: "sending" }); + const sample = sampleTraceExport(Date.now()); + try { + await sendOtlpTraceCall(accessToken, sample.body); + } catch { + setState({ kind: "failed", message: "Could not send the test trace." }); + return; + } + setState({ kind: "waiting" }); + const trace = await waitForTrace(accessToken, sample.traceId); + setState( + trace + ? { kind: "ready", trace } + : { kind: "failed", message: "Sent, but it has not shown up yet. Check again in a moment." }, + ); + }; + if (state.kind === "ready") { + return ( +
+ + + +
+ ); + } + const busy = state.kind === "sending" || state.kind === "waiting"; + return ( +
+ + {state.kind === "failed" &&

{state.message}

} +
+ ); +} + function TraceReceipt({ connected, checked, @@ -273,159 +445,367 @@ function TraceReceipt({ ); } +function TracingKey({ + accessToken, + tracingKey, + onCreated, +}: { + accessToken: string; + tracingKey: string | null; + onCreated: (key: string) => void; +}) { + const [creating, setCreating] = useState(false); + const [error, setError] = useState(""); + const create = async () => { + setCreating(true); + setError(""); + try { + const result = await apiClient.post<{ key?: string }>("/key/generate", { + accessToken, + body: TRACING_KEY_REQUEST, + }); + if (!result.key) throw new Error("The proxy did not return the new key"); + onCreated(result.key); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not create a key"); + } finally { + setCreating(false); + } + }; + if (tracingKey) { + return ( +
+ Your tracing key} /> +

+ Hidden for safety. Copy copies the full key, and the environment step below includes it. This key can only + send traces, so your agent still needs its own key for model calls. Manage it under Virtual Keys as + "Agent tracing". +

+
+ ); + } + return ( +
+ + Or use any existing LiteLLM virtual key. + {error &&

{error}

} +
+ ); +} + +function EndpointValue({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + if (await copyToClipboard(value)) { + setCopied(true); + window.setTimeout(() => setCopied(false), COPIED_RESET_MS); + } + }; + return ( + + ); +} + +function Endpoints({ proxyUrl }: { proxyUrl: string }) { + return ( +
+

+ + OpenTelemetry (OTEL) endpoints +

+

+ Point any OpenTelemetry exporter here. The framework guides below set these for you. +

+
+ {otlpEndpoints(proxyUrl).map(([label, value, copyable]) => ( +
+
{label}
+
+ {copyable ? : {value}} +
+
+ ))} +
+
+ ); +} + +const CODING_AGENT_LOGOS: Record = { + "Claude Code": anthropicLogo.src, + Codex: openaiLogo.src, +}; + function setupTitle(enabled: boolean, connected: boolean) { if (!enabled) return "Enable tracing"; return connected ? "Connect another agent" : "Connect your agent"; } -export function TracingSetupCard({ - detail, - connected = false, +interface ConnectAgentProps { + accessToken: string; + onOpenTrace: (trace: TraceSummary) => void; + connected: boolean; + checked: boolean; + checking: boolean; + onCheck: () => void; + readOnly: boolean; + canMintTracingKey: boolean; +} + +function EnableTracing({ checked, checking, onCheck }: { checked: boolean; checking: boolean; onCheck: () => void }) { + return ( + <> + +

+ Set your ClickHouse writer and read-only reader URLs, add this to config.yaml, then restart the proxy. Ask + your proxy administrator if you don’t manage this deployment. +

+ config.yaml} /> + + ClickHouse and proxy setup +
+ {checked && !checking && ( +

+ Tracing is still unavailable. Check that the configuration was applied to this proxy and it has restarted. +

+ )} + + + ); +} + +function ConnectAgent({ + accessToken, + onOpenTrace, + connected, + checked, + checking, onCheck, - checking = false, -}: { - detail: string | null; - connected?: boolean; - onCheck?: () => void; - checking?: boolean; -}) { + readOnly, + canMintTracingKey, +}: ConnectAgentProps) { const proxyUrl = getProxyBaseUrl().replace(/\/$/, ""); const [framework, setFramework] = useState(FRAMEWORKS[0].id); const [installer, setInstaller] = useState("pip"); - const [copied, setCopied] = useState(false); - const [checked, setChecked] = useState(false); + const [codingAgent, setCodingAgent] = useState("Claude Code"); + const [tracingKey, setTracingKey] = useState(null); const guide = FRAMEWORKS.find((f) => f.id === framework) ?? FRAMEWORKS[0]; const packages = installPackages(guide); - const install = installer === "pip" ? `pip install -U ${packages}` : `uv add ${packages}`; + const install = guide.typescript ? `npm install ${packages}` : PY_INSTALL[installer](packages); + const quickstart = guide.quickstart.replace("{PROXY}", proxyUrl); + return ( + <> + {!readOnly && ( + +

+ Send a small sample run (an agent, an LLM call and a tool call) to confirm tracing works end to end. +

+ +
+ )} + + + +
+ + +
+ + + {canMintTracingKey && !readOnly ? ( + + ) : ( +

+ Use any LiteLLM virtual key you already have, or ask a proxy admin for one. +

+ )} +
+ + + Let Claude Code or + Codex connect it + + } + > +

+ Run this in your agent’s project. It starts your coding agent with the setup task and reads the key from + LITELLM_API_KEY. +

+ + } + /> +
+ +
+ Set up manually + + npm + ) : ( + + ) + } + /> + + + Shell} + /> + + +

+ Replace the example model with a model configured on your proxy. +

+ {guide.typescript ? "my_agent.ts" : "my_agent.py"}} + /> +
+ Shell} /> +
+
+
+ + + + {!connected && ( +
+

+ Example run (sample data, not your runs) +

+ +
+ )} + + ); +} + +export function TracingSetupCard({ + detail, + accessToken, + onOpenTrace, + connected = false, + onCheck, + checking = false, + readOnly = false, + canMintTracingKey = false, +}: { + detail: string | null; + accessToken: string; + onOpenTrace: (trace: TraceSummary) => void; + connected?: boolean; + onCheck?: () => void; + checking?: boolean; + readOnly?: boolean; + canMintTracingKey?: boolean; +}) { + const [checked, setChecked] = useState(false); const enabled = detail === null; - const copyPrompt = async () => { - if (await copyToClipboard(codingAgentPrompt(proxyUrl, guide))) { - setCopied(true); - window.setTimeout(() => setCopied(false), COPIED_RESET_MS); - } - }; const check = () => { setChecked(true); onCheck?.(); }; return ( -
-

{setupTitle(enabled, connected)}

+
+
+

{setupTitle(enabled, connected)}

+ + Docs +

{enabled ? "Send your agent’s runs to LiteLLM to see its inputs, outputs, and tool calls." : "Tracing needs ClickHouse and a small update to your LiteLLM proxy configuration."}

- {enabled &&

- - {!enabled ? ( - <> - -

- Set your ClickHouse writer and read-only reader URLs, add this to config.yaml, then restart the proxy. Ask - your proxy administrator if you don’t manage this deployment. -

- config.yaml} /> - - ClickHouse and proxy setup -
- {checked && !checking && ( -

- Tracing is still unavailable. Check that the configuration was applied to this proxy and it has restarted. -

- )} - - + {enabled ? ( + ) : ( - <> -
- - -
- -

- Paste the setup prompt into Claude Code or Codex in your agent’s project. It uses your existing LiteLLM - key from the environment. -

- -
-
- Set up manually - - } - /> - - - Shell} /> - - -

- Replace the example model with a model configured on your proxy. -

- my_agent.py} - /> -
- Shell} /> -
-
-
- -
- See an example trace -

Example only. These are not your agent’s runs.

- Example agent trace with tool calls, inputs, and outputs -
- + )}
); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/previewTrace.json b/ui/litellm-dashboard/src/components/view_logs/TraceView/previewTrace.json new file mode 100644 index 00000000000..7da2a5b454d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/previewTrace.json @@ -0,0 +1,98 @@ +{ + "name": "deep_research_agent", + "input_preview": "Should we store OTEL agent spans in ClickHouse or Postgres at 50k spans/sec?", + "span_count": 126, + "duration_ms": 51385, + "rows": [ + { + "id": "5e79f3b5b504985e", + "name": "deep_research_agent", + "type": "agent", + "model": null, + "depth": 0, + "start_offset_ms": 0, + "duration_ms": 51385.4, + "error": false + }, + { + "id": "83451f3235847f6c", + "name": "model", + "type": "chain", + "model": null, + "depth": 1, + "start_offset_ms": 1, + "duration_ms": 9518.2, + "error": false + }, + { + "id": "8a6a1c31940d07af", + "name": "ChatOpenAI", + "type": "llm", + "model": "claude-sonnet-4-5", + "depth": 2, + "start_offset_ms": 6, + "duration_ms": 9510.8, + "error": false + }, + { + "id": "f6fdd164d528fee5", + "name": "tools", + "type": "chain", + "model": null, + "depth": 1, + "start_offset_ms": 9520, + "duration_ms": 2.7, + "error": false + }, + { + "id": "1526d46d48d29a09", + "name": "write_file", + "type": "tool", + "model": null, + "depth": 2, + "start_offset_ms": 9521, + "duration_ms": 0.6, + "error": false + }, + { + "id": "2697122e295d91b6", + "name": "tools", + "type": "chain", + "model": null, + "depth": 1, + "start_offset_ms": 9523, + "duration_ms": 35177.7, + "error": false + }, + { + "id": "b2fb3a8f5a2fce01", + "name": "task", + "type": "tool", + "model": null, + "depth": 2, + "start_offset_ms": 9524, + "duration_ms": 35176.1, + "error": false + }, + { + "id": "81499b492fd93f85", + "name": "researcher", + "type": "agent", + "model": null, + "depth": 3, + "start_offset_ms": 9524, + "duration_ms": 35175.3, + "error": false + }, + { + "id": "64a2c760897f3310", + "name": "model", + "type": "chain", + "model": null, + "depth": 4, + "start_offset_ms": 9526, + "duration_ms": 6071.3, + "error": false + } + ] +} diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.test.ts b/ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.test.ts new file mode 100644 index 00000000000..56ac75a5ea7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { SAMPLE_TRACE_SERVICE, sampleTraceExport } from "./sampleTrace"; + +interface ExportedSpan { + traceId: string; + spanId: string; + parentSpanId?: string; + startTimeUnixNano: string; + endTimeUnixNano: string; + attributes: { key: string; value: { stringValue: string } }[]; +} + +const spansOf = (body: object): ExportedSpan[] => + (body as { resourceSpans: { scopeSpans: { spans: ExportedSpan[] }[] }[] }).resourceSpans[0].scopeSpans[0].spans; + +const operation = (span: ExportedSpan) => + span.attributes.find((a) => a.key === "gen_ai.operation.name")?.value.stringValue; + +describe("sampleTraceExport", () => { + it("uses OTLP/JSON hex ids and returns the trace id the spans carry", () => { + const { traceId, body } = sampleTraceExport(Date.now()); + const spans = spansOf(body); + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + for (const span of spans) { + expect(span.traceId).toBe(traceId); + expect(span.spanId).toMatch(/^[0-9a-f]{16}$/); + } + }); + + it("nests an LLM call and a tool call under one root agent", () => { + const spans = spansOf(sampleTraceExport(Date.now()).body); + const roots = spans.filter((s) => !s.parentSpanId); + expect(roots).toHaveLength(1); + expect(operation(roots[0])).toBe("invoke_agent"); + const children = spans.filter((s) => s.parentSpanId === roots[0].spanId); + expect(children.map(operation).sort()).toEqual(["chat", "execute_tool"]); + }); + + it("ends at the given time and tags the sample service", () => { + const now = 1_790_000_000_000; + const { body } = sampleTraceExport(now); + const root = spansOf(body).find((s) => !s.parentSpanId)!; + expect(BigInt(root.endTimeUnixNano)).toBe(BigInt(now) * BigInt(1_000_000)); + expect(BigInt(root.startTimeUnixNano)).toBeLessThan(BigInt(root.endTimeUnixNano)); + expect(JSON.stringify(body)).toContain(SAMPLE_TRACE_SERVICE); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.ts b/ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.ts new file mode 100644 index 00000000000..6dc6f3262b7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/sampleTrace.ts @@ -0,0 +1,95 @@ +const NS_PER_MS = BigInt(1_000_000); + +export const SAMPLE_TRACE_SERVICE = "litellm-sample-agent"; + +const randomHexId = (bytes: number): string => + Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (b) => b.toString(16).padStart(2, "0")).join(""); + +const str = (key: string, value: string) => ({ key, value: { stringValue: value } }); + +const messages = (role: string, content: string): string => JSON.stringify([{ role, content }]); + +interface SampleSpan { + name: string; + parent: string | null; + startMs: number; + endMs: number; + attributes: ReturnType[]; +} + +export function sampleTraceExport(nowMs: number): { traceId: string; body: object } { + const traceId = randomHexId(16); + const agentId = randomHexId(8); + const question = "What is the weather in San Francisco?"; + const answer = "It is 18°C and sunny in San Francisco."; + const spans: readonly (SampleSpan & { id: string })[] = [ + { + id: agentId, + name: "weather_agent", + parent: null, + startMs: 0, + endMs: 2400, + attributes: [ + str("gen_ai.operation.name", "invoke_agent"), + str("gen_ai.agent.name", "weather_agent"), + str("gen_ai.input.messages", messages("user", question)), + str("gen_ai.output.messages", messages("assistant", answer)), + ], + }, + { + id: randomHexId(8), + name: "chat sample-model", + parent: agentId, + startMs: 100, + endMs: 1500, + attributes: [ + str("gen_ai.operation.name", "chat"), + str("gen_ai.agent.name", "weather_agent"), + str("gen_ai.request.model", "sample-model"), + str("gen_ai.input.messages", messages("user", question)), + str("gen_ai.output.messages", messages("assistant", "Calling get_weather(city=San Francisco)")), + ], + }, + { + id: randomHexId(8), + name: "get_weather", + parent: agentId, + startMs: 1600, + endMs: 2300, + attributes: [ + str("gen_ai.operation.name", "execute_tool"), + str("gen_ai.agent.name", "weather_agent"), + str("gen_ai.tool.call.arguments", JSON.stringify({ city: "San Francisco" })), + str("gen_ai.tool.call.result", JSON.stringify({ temp_c: 18, sky: "sunny" })), + ], + }, + ]; + const startNs = BigInt(nowMs - 2400) * NS_PER_MS; + const toNs = (ms: number): string => String(startNs + BigInt(ms) * NS_PER_MS); + return { + traceId, + body: { + resourceSpans: [ + { + resource: { attributes: [str("service.name", SAMPLE_TRACE_SERVICE)] }, + scopeSpans: [ + { + scope: { name: "litellm-ui-sample" }, + spans: spans.map((s) => ({ + traceId, + spanId: s.id, + ...(s.parent ? { parentSpanId: s.parent } : {}), + name: s.name, + kind: 1, + startTimeUnixNano: toNs(s.startMs), + endTimeUnixNano: toNs(s.endMs), + attributes: s.attributes, + status: { code: 1 }, + })), + }, + ], + }, + ], + }, + }; +} From 03743ae020b85bd701a2899a6306561050fc95cb Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:50:41 +0000 Subject: [PATCH 02/83] feat(proxy): add LITELLM_DISABLE_LAZY_ROUTES to register optional routers at startup (#43911) * feat(proxy): add LITELLM_DISABLE_LAZY_ROUTES to register optional routers at startup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): register eager lazy routes at startup so late eager routes keep precedence Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): share one optional-feature install path between lazy and eager registration Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs(proxy): say eager lazy routes register at worker startup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): name the import callable passed to _install Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): prove a startup hook can drop eager lazy routes for good Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): strip the lazy-routes flag from the lazy-mode control proxies Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): cover the lazy warmup route registering a feature Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(unit): run tests/unit/proxy/test__lazy_features.py in the proxy-server-core shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): audit cells for LITELLM_DISABLE_LAZY_ROUTES Flag spellings, /openapi.json at boot, the warm-up route in both modes, /mcp/proxy ahead of the /mcp mount, route table and OpenAPI parity with a fully warmed lazy proxy, a broken optional import in both modes, every client SDK against the completion endpoints, a boot burst with a killed worker, and a restart * fix(proxy): keep config pass-through routes ahead of eagerly registered features With LITELLM_DISABLE_LAZY_ROUTES set, features registered before the proxy lifespan added config pass-through endpoints, so a pass-through overlapping a feature path (e.g. a self-hosted /langfuse) lost to the built-in route. Restore lazy mode's registry order once startup finishes, without bringing back routes a startup hook removed --------- Co-authored-by: ryan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .circleci/scripts/unit_selection.sh | 1 + litellm/proxy/_lazy_features.py | 181 ++++-- .../configuration/test_lazy_routes_flag.py | 605 ++++++++++++++++++ tests/unit/proxy/test__lazy_features.py | 211 ++++++ 4 files changed, 945 insertions(+), 53 deletions(-) create mode 100644 tests/integration/configuration/test_lazy_routes_flag.py create mode 100644 tests/unit/proxy/test__lazy_features.py diff --git a/.circleci/scripts/unit_selection.sh b/.circleci/scripts/unit_selection.sh index f5b9b82499c..542984dd2e0 100755 --- a/.circleci/scripts/unit_selection.sh +++ b/.circleci/scripts/unit_selection.sh @@ -146,6 +146,7 @@ legacy_paths() { echo tests/unit/proxy/test_proxy_token_counter.py echo tests/unit/proxy/test_server_root_path.py ;; proxy-db-proxy-server-core) + echo tests/unit/proxy/test__lazy_features.py echo tests/unit/proxy/test_aproxy_startup.py echo tests/unit/proxy/test_proxy_server.py ;; proxy-db-proxy-utils) echo tests/unit/proxy/test_proxy_utils.py ;; diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index 0b687340ea5..0b470cf7bda 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -3,19 +3,24 @@ Lazy registration for optional feature routers. Each LAZY_FEATURES entry imports its module only on the first request matching its path prefix, saving ~700 MB at idle for deployments that don't use these features. First hit pays the import cost (1-3 s for heavy modules); /openapi.json -omits each feature's routes until the feature is warmed. +omits each feature's routes until the feature is warmed. Setting +LITELLM_DISABLE_LAZY_ROUTES registers every feature at worker startup +instead, so the route table is complete before the first request. """ import asyncio import importlib -from collections.abc import Callable, Mapping, Sequence +import os +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet +from contextlib import asynccontextmanager from dataclasses import dataclass, field +from functools import partial from types import MappingProxyType from typing import TYPE_CHECKING, Final from starlette.routing import BaseRoute, Match -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send from litellm._logging import verbose_proxy_logger from litellm.proxy.route_priority import hot_routes_first @@ -428,57 +433,127 @@ def _in_registry_order( async def _force_load(app: "FastAPI", feat: LazyFeature, features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> bool: """Import + register a lazy feature exactly once per (app, module). Shared by the middleware and the /lazy/warm endpoint.""" + async with _lazy_lock(app, feat.module_path): + if feat.module_path in _lazy_loaded(app): + return False + # Import on a thread (heavy modules take 1-3 s). register_fn + # mutates app.router.routes, so it stays on the loop thread. + imported: Final = asyncio.get_running_loop().run_in_executor(None, importlib.import_module, feat.module_path) + await asyncio.wait((imported,)) + return _install(app, feat, imported.result, features) + + +def _install( + app: "FastAPI", feat: LazyFeature, import_module: Callable[[], object], features: tuple[LazyFeature, ...] +) -> bool: + try: + _register_feature(app, feat, import_module(), features) + return True + except Exception as exc: + _mark_failed(app, feat, exc) + return False + + +def _lazy_loaded(app: "FastAPI") -> set[str]: if not hasattr(app.state, "lazy_loaded"): - app.state.lazy_loaded = set() - app.state.lazy_locks = {} - lock: Final = app.state.lazy_locks.setdefault(feat.module_path, asyncio.Lock()) - async with lock: - if feat.module_path in app.state.lazy_loaded: - return False - try: - # Import on a thread (heavy modules take 1-3 s). register_fn - # mutates app.router.routes, so it stays on the loop thread. - loop: Final = asyncio.get_running_loop() - module: Final = await loop.run_in_executor(None, importlib.import_module, feat.module_path) - before: Final = len(app.router.routes) - feat.register_fn(app, module) - previous: Final[Mapping[str, tuple[BaseRoute, ...]]] = ( - app.state.lazy_routes if hasattr(app.state, "lazy_routes") else MappingProxyType({}) - ) - lazy_routes: Final[Mapping[str, tuple[BaseRoute, ...]]] = MappingProxyType( - {**previous, feat.module_path: tuple(app.router.routes[before:])} - ) - app.state.lazy_routes = lazy_routes # rebind-ok: the app owns the record of which routes each feature added - app.router.routes[:] = hot_routes_first( # rebind-ok: the app owns its route table - _in_registry_order(app.router.routes, lazy_routes, features, _lazy_slots(app)) - ) - app.state.lazy_loaded.add(feat.module_path) - app.openapi_schema = None - verbose_proxy_logger.info( - "Lazy-loaded optional feature %r (module: %s)", - feat.name, - feat.module_path, - ) - return True - except Exception as exc: - # Mark loaded anyway so we don't retry on every request. - app.state.lazy_loaded.add(feat.module_path) - verbose_proxy_logger.warning( - "Failed to lazy-load optional feature %r (module: %s): %s. " - "This feature's endpoints will return 404 until restart.", - feat.name, - feat.module_path, - exc, - ) - return False + app.state.lazy_loaded = set[str]() + app.state.lazy_locks = dict[str, asyncio.Lock]() + loaded: Final[set[str]] = app.state.lazy_loaded + return loaded -def attach_lazy_features(app: "FastAPI") -> None: - app.include_router(_make_warmup_router(app)) - app.add_middleware(LazyFeatureMiddleware, fastapi_app=app) +def _lazy_lock(app: "FastAPI", module_path: str) -> asyncio.Lock: + if not hasattr(app.state, "lazy_locks"): + app.state.lazy_locks = dict[str, asyncio.Lock]() + locks: Final[dict[str, asyncio.Lock]] = app.state.lazy_locks + return locks.setdefault(module_path, asyncio.Lock()) -def _make_warmup_router(app: "FastAPI") -> "APIRouter": +def _register_feature(app: "FastAPI", feat: LazyFeature, module: object, features: tuple[LazyFeature, ...]) -> None: + before: Final = len(app.router.routes) + feat.register_fn(app, module) + previous: Final[Mapping[str, tuple[BaseRoute, ...]]] = ( + app.state.lazy_routes if hasattr(app.state, "lazy_routes") else MappingProxyType({}) + ) + lazy_routes: Final[Mapping[str, tuple[BaseRoute, ...]]] = MappingProxyType( + {**previous, feat.module_path: tuple(app.router.routes[before:])} + ) + app.state.lazy_routes = lazy_routes # rebind-ok: the app owns the record of which routes each feature added + app.router.routes[:] = hot_routes_first( # rebind-ok: the app owns its route table + _in_registry_order(app.router.routes, lazy_routes, features, _lazy_slots(app)) + ) + _lazy_loaded(app).add(feat.module_path) + app.openapi_schema = None + verbose_proxy_logger.info( + "Lazy-loaded optional feature %r (module: %s)", + feat.name, + feat.module_path, + ) + + +def _mark_failed(app: "FastAPI", feat: LazyFeature, exc: Exception) -> None: + # Mark loaded anyway so we don't retry on every request. + _lazy_loaded(app).add(feat.module_path) + verbose_proxy_logger.warning( + "Failed to lazy-load optional feature %r (module: %s): %s. " + "This feature's endpoints will return 404 until restart.", + feat.name, + feat.module_path, + exc, + ) + + +def lazy_routes_disabled() -> bool: + return os.getenv("LITELLM_DISABLE_LAZY_ROUTES", "").lower() in ("1", "true", "yes", "on") + + +def register_all_features(app: "FastAPI", features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> None: + """Register every feature router now, in registry order, so app.routes is + complete before the app serves its first request.""" + for feat in features: + _install(app, feat, partial(importlib.import_module, feat.module_path), features) + + +def attach_lazy_features(app: "FastAPI", features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> None: + if lazy_routes_disabled(): + app.router.lifespan_context = _register_all_on_startup(app.router.lifespan_context, features) + return + app.include_router(_make_warmup_router(app, features)) + app.add_middleware(LazyFeatureMiddleware, fastapi_app=app, features=features) + + +def _register_all_on_startup(inner: "Lifespan[FastAPI]", features: tuple[LazyFeature, ...]) -> "Lifespan[FastAPI]": + """Registering at startup, once every route the app defines exists, lands the features + where lazy mode splices them: after every eager route (so /mcp/proxy, defined after + attach_lazy_features(), still beats the /mcp mount) and before LITELLM_WORKER_STARTUP_HOOKS + or an outer lifespan can filter the table. The inner lifespan then adds routes of its own + (config pass-through endpoints), so the table is put back in lazy mode's order once it is up.""" + + @asynccontextmanager + async def lifespan(app: "FastAPI") -> AsyncGenerator[None]: + register_all_features(app, features) + async with inner(app): + _restore_registry_order(app, features) + yield + + return lifespan + + +def _restore_registry_order(app: "FastAPI", features: tuple[LazyFeature, ...]) -> None: + present: Final = frozenset(id(route) for route in app.router.routes) + registered: Final[Mapping[str, tuple[BaseRoute, ...]]] = ( + app.state.lazy_routes if hasattr(app.state, "lazy_routes") else MappingProxyType({}) + ) + still_routed: Final = MappingProxyType( + {module_path: tuple(r for r in routes if id(r) in present) for module_path, routes in registered.items()} + ) + app.router.routes[:] = hot_routes_first( # rebind-ok: the app owns its route table + _in_registry_order(app.router.routes, still_routed, features, _lazy_slots(app)) + ) + app.openapi_schema = None + + +def _make_warmup_router(app: "FastAPI", features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> "APIRouter": """POST /lazy/warm/{name}: load a feature and return its partial openapi so the Swagger plugin can merge in-place without a full /openapi.json refetch. Requires auth — anyone who can hit the proxy can already trigger the same @@ -497,13 +572,13 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter": dependencies=[Depends(user_api_key_auth)], ) async def warm(name: str): - feat: Final = next((f for f in LAZY_FEATURES if f.name == name), None) + feat: Final = next((f for f in features if f.name == name), None) if feat is None: raise HTTPException(404, f"unknown lazy feature: {name}") if feat.persistent_swagger_stub: return {"stub_path": None, "paths": {}, "components": {"schemas": {}}} - await _force_load(app, feat) + await _force_load(app, feat, features) feat_routes: Final = [r for r in app.routes if feat.matches(getattr(r, "path", ""))] full: Final = get_openapi(title=app.title, version=app.version, routes=feat_routes) @@ -524,7 +599,7 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter": def loaded_lazy_modules(app: "FastAPI") -> frozenset[str]: """The set of lazy feature modules whose routers are actually registered - on this app (tracked by _force_load), empty before the middleware ever ran. + on this app (tracked by _install), empty until a feature loads or eager startup runs. sys.modules is the wrong signal: boot code imports several feature modules (mcp_management, cloudzero, vantage, config_overrides) without mounting their routers, and their stubs must still be injected.""" @@ -583,6 +658,6 @@ def lazy_tag_to_prefix() -> dict[str, str]: because /openapi.json already has full route info.""" from litellm.proxy._lazy_openapi_snapshot import load_snapshot - if load_snapshot(): + if lazy_routes_disabled() or load_snapshot(): return {} return {feat.name: feat.path_prefixes[0] for feat in LAZY_FEATURES if not feat.persistent_swagger_stub} diff --git a/tests/integration/configuration/test_lazy_routes_flag.py b/tests/integration/configuration/test_lazy_routes_flag.py new file mode 100644 index 00000000000..6135deca1c8 --- /dev/null +++ b/tests/integration/configuration/test_lazy_routes_flag.py @@ -0,0 +1,605 @@ +"""Route table contract for the LITELLM_DISABLE_LAZY_ROUTES startup flag. + +By default optional feature routers (``LAZY_FEATURES``) are registered on the first +request to their path prefix, so an operator inspecting the route table right after +boot cannot see or gate them. With the flag set every feature is registered at worker +startup, so ``GET /routes`` lists them before any feature request is served and the +first feature request changes nothing. +""" + +import asyncio +import json +import os +import re +import uuid +from collections.abc import Iterator, Mapping +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import Final + +import anthropic +import httpx +import openai +import psutil +import pytest +import yaml +from pydantic import JsonValue, TypeAdapter + +from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature +from tests.integration._support.client import Gateway, eventually, object_value, string_value +from tests.integration._support.mcp import McpPeer, call_tool, echo_tool, scripted_peer, tool_calls, tool_names +from tests.integration._support.process import OwnedProxy, owned_proxy_process +from tests.integration._support.wire import Reply, Request, Wire, wire_server + +TICKET_FEATURES: Final = ("mcp_management", "mcp_byok_oauth") +FLAG: Final = "LITELLM_DISABLE_LAZY_ROUTES" +WARMUP_ROUTE: Final = "/lazy/warm/{name}" +MCP_WARM_PATH: Final = "/mcp/enabled" +MARKER: Final = re.compile(rb"lazyroutes-[0-9a-f]{32}") +FAILED_FEATURE: Final = re.compile(r"Failed to lazy-load optional feature '([a-z_]+)'") +JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) +HOOK_MODULE: Final = "lazy_routes_route_filter_hook" +HOOK_SOURCE: Final = """from litellm.proxy.proxy_server import app + + +def drop_mcp_routes() -> None: + app.router.routes[:] = [ + route for route in app.router.routes if not getattr(route, "path", "").startswith(("/mcp", "/v1/mcp")) + ] +""" + + +def _paths(candidate: Gateway) -> tuple[str, ...]: + routes: Final = candidate.get("/routes")["routes"] + assert isinstance(routes, list), routes + return tuple(string_value(object_value(route)["path"]) for route in routes) + + +def _routed_features(candidate: Gateway) -> Mapping[str, tuple[str, ...]]: + paths: Final = _paths(candidate) + return {feature.name: tuple(path for path in paths if feature.matches(path)) for feature in LAZY_FEATURES} + + +def _mcp_paths(candidate: Gateway) -> tuple[str, ...]: + return tuple(path for path in _paths(candidate) if path.startswith(("/mcp", "/v1/mcp"))) + + +def _route_filter_hook(directory: Path) -> Mapping[str, str]: + (directory / f"{HOOK_MODULE}.py").write_text(HOOK_SOURCE) + search_path: Final = (str(directory), os.environ.get("PYTHONPATH", "")) + return { + "PYTHONPATH": os.pathsep.join(entry for entry in search_path if entry), + "LITELLM_WORKER_STARTUP_HOOKS": f"{HOOK_MODULE}:drop_mcp_routes", + } + + +def test_lazy_routes_are_absent_from_the_route_table_until_first_request(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as owned: + at_boot: Final = _routed_features(owned.gateway) + assert {name: at_boot[name] for name in TICKET_FEATURES} == {name: () for name in TICKET_FEATURES}, at_boot + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + after_first_request: Final = _routed_features(owned.gateway) + assert after_first_request["mcp_management"] != (), "first request did not register the router" + assert after_first_request["mcp_byok_oauth"] == (), "only the requested feature is mounted" + + +@pytest.mark.parametrize("workers", (1, 4)) +def test_disable_lazy_routes_flag_registers_every_feature_at_startup( + gateway: Gateway, tmp_path: Path, workers: int +) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}, workers=workers) as owned: + at_boot: Final = tuple(_routed_features(owned.gateway) for _ in range(2 * workers)) + unregistered: Final = sorted(name for name, paths in at_boot[0].items() if not paths) + assert unregistered == [], f"features still missing from /routes at startup: {unregistered}" + assert all(table == at_boot[0] for table in at_boot), "workers disagree on the route table" + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + assert _routed_features(owned.gateway) == at_boot[0], "first feature request changed the route table" + + +def test_startup_hook_cannot_remove_lazy_routes_that_register_after_it_ran(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy_process(gateway, tmp_path, _route_filter_hook(tmp_path), remove_environment=(FLAG,)) as owned: + assert _mcp_paths(owned.gateway) == (), "hook should have removed the routes registered before it ran" + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + assert _mcp_paths(owned.gateway) != (), "first request should have registered the routes the hook never saw" + + +def test_disable_lazy_routes_flag_lets_a_startup_hook_remove_optional_routes_for_good( + gateway: Gateway, tmp_path: Path +) -> None: + overrides: Final = {**_route_filter_hook(tmp_path), FLAG: "true"} + with owned_proxy_process(gateway, tmp_path, overrides) as owned: + assert _mcp_paths(owned.gateway) == (), "hook should have seen and removed every MCP route" + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 404, listing.text + mounted: Final = owned.gateway.request("POST", "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + assert mounted.status_code == 404, mounted.text + guardrails: Final = owned.gateway.request("GET", "/guardrails/list") + assert guardrails.status_code == 200, guardrails.text + assert _mcp_paths(owned.gateway) == (), "a feature request re-registered routes the hook removed" + + +def _openapi_paths(candidate: Gateway) -> Mapping[str, tuple[str, ...]]: + paths: Final = object_value(candidate.get("/openapi.json")["paths"]) + return {path: tuple(sorted(object_value(operations))) for path, operations in paths.items()} + + +def _published(feature: LazyFeature, paths: Mapping[str, tuple[str, ...]]) -> bool: + return any(feature.matches(path) for path in paths) + + +def _warm_every_feature(candidate: Gateway) -> None: + warmed: Final = tuple( + (feature.name, candidate.request("POST", f"/lazy/warm/{feature.name}")) for feature in LAZY_FEATURES + ) + cold: Final = [ + (name, response.status_code, response.text) for name, response in warmed if response.status_code != 200 + ] + assert cold == [], cold + enabled: Final = candidate.request("GET", MCP_WARM_PATH) + assert enabled.status_code == 200, enabled.text + unregistered: Final = sorted(name for name, paths in _routed_features(candidate).items() if not paths) + assert unregistered == [], f"features still missing after warming every one of them: {unregistered}" + + +def _shadowed_dependency(directory: Path) -> Mapping[str, str]: + package: Final = directory / "shadow" / "RestrictedPython" + package.mkdir(parents=True) + (package / "__init__.py").write_text('raise ImportError("shadowed by the lazy routes audit")\n') + search_path: Final = (str(package.parent), os.environ.get("PYTHONPATH", "")) + return {"PYTHONPATH": os.pathsep.join(entry for entry in search_path if entry)} + + +def _failed_features(owned: OwnedProxy) -> frozenset[str]: + return frozenset(FAILED_FEATURE.findall(owned.log.read_text())) + + +def _marker() -> str: + return "lazyroutes-" + uuid.uuid4().hex + + +def _chat_reply(identity: str, stream: bool) -> Reply: + if not stream: + return Reply( + body=json.dumps( + { + "id": identity, + "object": "chat.completion", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "lazy ok"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}, + } + ).encode() + ) + chunk: Final[dict[str, JsonValue]] = { + "id": identity, + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + } + deltas: Final[tuple[dict[str, JsonValue], ...]] = ( + {**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "lazy"}}]}, + {**chunk, "choices": [{"index": 0, "delta": {"content": " ok"}, "finish_reason": "stop"}]}, + {**chunk, "choices": [], "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}}, + ) + return Reply( + content_type="text/event-stream", + chunks=(*(b"data: " + json.dumps(delta).encode() + b"\n\n" for delta in deltas), b"data: [DONE]\n\n"), + ) + + +def _responses_reply(identity: str, stream: bool) -> Reply: + response: Final[dict[str, JsonValue]] = { + "id": identity, + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "id": "msg_" + identity, + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "lazy ok", "annotations": []}], + } + ], + "usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9}, + } + if not stream: + return Reply(body=json.dumps(response).encode()) + events: Final[tuple[dict[str, JsonValue], ...]] = ( + {"type": "response.created", "sequence_number": 0, "response": {**response, "status": "in_progress"}}, + { + "type": "response.output_text.delta", + "sequence_number": 1, + "item_id": "msg_" + identity, + "output_index": 0, + "content_index": 0, + "delta": "lazy ok", + }, + {"type": "response.completed", "sequence_number": 2, "response": response}, + ) + return Reply( + content_type="text/event-stream", + chunks=tuple(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events), + ) + + +def _upstream(request: Request) -> Reply: + found: Final = MARKER.search(request.body) + if found is None: + return Reply(status=404, body=b'{"error":"no marker"}') + marker: Final = found.group(0).decode() + stream: Final = object_value(JSON.validate_json(request.body)).get("stream") is True + if request.target.endswith("/responses"): + return _responses_reply(f"resp_{marker}", stream) + return _chat_reply(f"chatcmpl-{marker}", stream) + + +@pytest.fixture(scope="module") +def provider() -> Iterator[Wire]: + with wire_server(_upstream) as wire: + yield wire + + +async def _stream_chat(base_url: str, key: str, model: str, marker: str) -> tuple[frozenset[str], str]: + client: Final = openai.AsyncOpenAI(base_url=base_url + "/v1", api_key=key, max_retries=0) + stream: Final = await client.chat.completions.create( + model=model, messages=[{"role": "user", "content": marker}], stream=True + ) + chunks: Final = [chunk async for chunk in stream] + text: Final = "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) + return frozenset(chunk.id for chunk in chunks), text + + +async def _stream_message(base_url: str, key: str, model: str, marker: str) -> str: + client: Final = anthropic.AsyncAnthropic(base_url=base_url, api_key=key, max_retries=0) + async with client.messages.stream( + model=model, max_tokens=16, messages=[{"role": "user", "content": marker}] + ) as stream: + return "".join([text async for text in stream.text_stream]) + + +def _status(candidate: Gateway, path: str) -> int: + return candidate.request("GET", path).status_code + + +def _workers(owned: OwnedProxy) -> tuple[psutil.Process, ...]: + return tuple(child for child in psutil.Process(owned.process.pid).children() if _is_worker(child)) + + +def _is_worker(child: psutil.Process) -> bool: + try: + return "spawn_main" in " ".join(child.cmdline()) and child.status() != psutil.STATUS_ZOMBIE + except psutil.Error: + return False + + +@pytest.mark.parametrize("spelling", ("1", "Yes", "ON")) +def test_every_truthy_spelling_of_the_flag_registers_the_ticket_features_at_startup( + gateway: Gateway, tmp_path: Path, spelling: str +) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: spelling}) as owned: + at_boot: Final = _routed_features(owned.gateway) + assert all(at_boot[name] for name in TICKET_FEATURES), {name: at_boot[name] for name in TICKET_FEATURES} + + +@pytest.mark.parametrize( + "spelling", ("", "0", "off", "maybe", "x" * 5000), ids=("empty", "zero", "off", "unknown-word", "five-kilobytes") +) +def test_a_falsey_or_unknown_flag_value_keeps_the_default_lazy_registration( + gateway: Gateway, tmp_path: Path, spelling: str +) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: spelling}) as owned: + at_boot: Final = _routed_features(owned.gateway) + assert {name: at_boot[name] for name in TICKET_FEATURES} == {name: () for name in TICKET_FEATURES}, at_boot + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + assert _routed_features(owned.gateway)["mcp_management"] != (), "first request did not register the router" + + +def test_disable_lazy_routes_flag_publishes_the_live_route_table_in_openapi_before_any_feature_request( + gateway: Gateway, tmp_path: Path +) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as owned: + at_boot: Final = _openapi_paths(owned.gateway) + unpublished: Final = sorted( + feature.name + for feature in LAZY_FEATURES + if feature.name in TICKET_FEATURES and not _published(feature, at_boot) + ) + assert unpublished == [], f"ticket features missing from /openapi.json at startup: {unpublished}" + assert "get" in at_boot["/v1/mcp/server"], at_boot["/v1/mcp/server"] + assert WARMUP_ROUTE not in at_boot + stranger: Final = owned.gateway.request("GET", "/v1/mcp/server", key="sk-not-a-real-key") + assert stranger.status_code == 401, stranger.text + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + assert _openapi_paths(owned.gateway) == at_boot, "first feature request changed /openapi.json" + + +def test_disable_lazy_routes_flag_removes_the_warmup_route(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as owned: + assert WARMUP_ROUTE not in _paths(owned.gateway) + warmed: Final = owned.gateway.request("POST", "/lazy/warm/mcp_management") + assert warmed.status_code == 404, warmed.text + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + + +def test_the_warmup_route_registers_a_feature_on_demand_by_default(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as owned: + assert WARMUP_ROUTE in _paths(owned.gateway) + warmed: Final = owned.gateway.request("POST", "/lazy/warm/mcp_management") + assert warmed.status_code == 200, warmed.text + assert "/v1/mcp/server" in object_value(object_value(JSON.validate_json(warmed.content))["paths"]) + assert _routed_features(owned.gateway)["mcp_management"] != (), "warmup did not register the router" + + +def test_disable_lazy_routes_flag_keeps_the_fixed_mcp_proxy_route_ahead_of_the_mcp_mount( + gateway: Gateway, tmp_path: Path +) -> None: + with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as lazy: + control: Final = lazy.gateway.request("POST", "/mcp/proxy", {}) + assert control.status_code == 400, control.text + warmed: Final = _paths(lazy.gateway) + assert warmed.count("/mcp") == 2, "expected the fixed /mcp route and the /mcp mount" + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as eager: + at_boot: Final = _paths(eager.gateway) + assert at_boot.count("/mcp") == 2, "expected the fixed /mcp route and the /mcp mount at startup" + mount: Final = max(index for index, path in enumerate(at_boot) if path == "/mcp") + assert at_boot.index("/mcp/proxy") < mount, "the /mcp mount shadows /mcp/proxy" + proxied: Final = eager.gateway.request("POST", "/mcp/proxy", {}) + assert (proxied.status_code, proxied.text) == (control.status_code, control.text) + + +def test_disable_lazy_routes_flag_matches_the_fully_warmed_lazy_route_table_and_openapi( + gateway: Gateway, tmp_path: Path +) -> None: + with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as lazy: + _warm_every_feature(lazy.gateway) + warmed_paths: Final = tuple(path for path in _paths(lazy.gateway) if path != WARMUP_ROUTE) + warmed_openapi: Final = _openapi_paths(lazy.gateway) + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as eager: + assert _paths(eager.gateway) == warmed_paths + assert _openapi_paths(eager.gateway) == warmed_openapi + + +def test_disable_lazy_routes_flag_keeps_registering_after_an_optional_dependency_fails_to_import( + gateway: Gateway, tmp_path: Path +) -> None: + with owned_proxy_process(gateway, tmp_path, {**_shadowed_dependency(tmp_path), FLAG: "true"}) as owned: + at_boot: Final = _routed_features(owned.gateway) + unregistered: Final = frozenset(name for name, paths in at_boot.items() if not paths) + failed: Final = _failed_features(owned) + assert "guardrails" in failed, owned.log.read_text() + assert unregistered == failed, (sorted(unregistered), sorted(failed)) + guardrails: Final = owned.gateway.request("GET", "/guardrails/list") + assert guardrails.status_code == 404, guardrails.text + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + stores: Final = owned.gateway.request("GET", "/vector_store/list") + assert stores.status_code == 200, stores.text + assert _routed_features(owned.gateway) == at_boot, "feature requests changed the route table" + + +def test_a_broken_optional_dependency_only_404s_its_own_feature_by_default(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy_process(gateway, tmp_path, _shadowed_dependency(tmp_path), remove_environment=(FLAG,)) as owned: + guardrails: Final = owned.gateway.request("GET", "/guardrails/list") + assert guardrails.status_code == 404, guardrails.text + assert "guardrails" in _failed_features(owned), owned.log.read_text() + listing: Final = owned.gateway.request("GET", "/v1/mcp/server") + assert listing.status_code == 200, listing.text + assert _routed_features(owned.gateway)["mcp_management"] != () + + +def test_disable_lazy_routes_flag_leaves_the_completion_endpoints_serving_every_client( + gateway: Gateway, tmp_path: Path, provider: Wire +) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as owned, owned.gateway.scenario() as scenario: + model: Final = scenario.model(api_base=provider.url + "/v1") + base_url: Final = str(owned.gateway.client.base_url) + key: Final = owned.gateway.key + markers: Final = tuple(_marker() for _ in range(6)) + + completion: Final = openai.OpenAI( + base_url=base_url + "/v1", api_key=key, max_retries=0 + ).chat.completions.create(model=model, messages=[{"role": "user", "content": markers[0]}]) + assert (completion.id, completion.choices[0].message.content) == (f"chatcmpl-{markers[0]}", "lazy ok") + + assert asyncio.run(_stream_chat(base_url, key, model, markers[1])) == ( + frozenset({f"chatcmpl-{markers[1]}"}), + "lazy ok", + ) + + message: Final = anthropic.Anthropic(base_url=base_url, api_key=key, max_retries=0).messages.create( + model=model, max_tokens=16, messages=[{"role": "user", "content": markers[2]}] + ) + assert [block.text for block in message.content if block.type == "text"] == ["lazy ok"] + + assert asyncio.run(_stream_message(base_url, key, model, markers[3])) == "lazy ok" + + responded: Final = owned.gateway.request("POST", "/v1/responses", {"model": model, "input": markers[4]}) + assert responded.status_code == 200, responded.text + response: Final = object_value(JSON.validate_json(responded.content)) + assert (response["status"], response["object"]) == ("completed", "response"), responded.text + assert "lazy ok" in responded.text, responded.text + + streamed: Final = owned.gateway.request( + "POST", "/v1/responses", {"model": model, "input": markers[5], "stream": True} + ) + assert streamed.status_code == 200, streamed.text + assert "response.completed" in streamed.text and "lazy ok" in streamed.text, streamed.text + + reached: Final = tuple(request.target for request in provider.drain() if MARKER.search(request.body)) + assert len(reached) == 6, reached + + +def test_disable_lazy_routes_flag_route_table_survives_a_boot_burst_and_a_killed_worker( + gateway: Gateway, tmp_path: Path +) -> None: + probes: Final = ("/routes", "/v1/mcp/server", "/openapi.json", "/guardrails/list", "/vector_store/list") * 8 + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}, workers=2) as owned: + at_boot: Final = _routed_features(owned.gateway) + unregistered: Final = sorted(name for name, paths in at_boot.items() if not paths) + assert unregistered == [], f"features still missing from /routes at startup: {unregistered}" + with ThreadPoolExecutor(max_workers=8) as pool: + statuses: Final = tuple(pool.map(partial(_status, owned.gateway), probes)) + assert statuses == (200,) * len(probes), statuses + assert _routed_features(owned.gateway) == at_boot, "the boot burst changed the route table" + + victim: Final = eventually(lambda: _workers(owned), lambda workers: len(workers) == 2)[0] + victim.kill() + with httpx.Client(base_url=owned.gateway.client.base_url, timeout=15, trust_env=False) as fresh: + survivor: Final = Gateway(fresh, owned.gateway.key, owned.gateway.upstream_url) + during: Final = tuple(survivor.request("GET", "/v1/mcp/server").status_code for _ in range(10)) + assert during == (200,) * 10, during + respawned: Final = eventually( + lambda: frozenset(worker.pid for worker in _workers(owned)), + lambda pids: len(pids) == 2 and victim.pid not in pids, + seconds=30, + ) + assert f"Child process [{victim.pid}] died" in owned.log.read_text(), respawned + tables: Final = tuple(_routed_features(owned.gateway) for _ in range(4)) + assert all(table == at_boot for table in tables), "the respawned worker disagrees on the route table" + + +def test_disable_lazy_routes_flag_yields_the_same_route_table_after_a_restart(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as first: + table: Final = _paths(first.gateway) + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as second: + assert _paths(second.gateway) == table + assert all(_routed_features(second.gateway).values()), "a feature is missing after restart" + + +SELF_HOSTED_LANGFUSE: Final = "/self-hosted-langfuse" + + +@dataclass(frozen=True, slots=True) +class _ConfiguredFeatures: + alias: str + config: Path + policy: Wire + langfuse: Wire + peer: McpPeer + + +def _allow(request: Request) -> Reply: + return Reply(body=json.dumps({"action": "NONE"}).encode()) + + +def _langfuse_health(request: Request) -> Reply: + return Reply(body=json.dumps({"status": "OK"}).encode()) + + +def _config_declaring(directory: Path, alias: str, policy: Wire, langfuse: Wire, peer: McpPeer) -> Path: + base: Final = object_value( + JSON.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + ) + config: Final = { + **base, + "guardrails": [ + { + "guardrail_name": alias, + "litellm_params": { + "guardrail": "generic_guardrail_api", + "mode": "pre_call", + "default_on": True, + "api_base": policy.url, + "api_key": "synthetic-guardrail-key", + }, + } + ], + "mcp_servers": {alias: peer.registration()}, + "general_settings": { + **object_value(base["general_settings"]), + "pass_through_endpoints": [ + { + "path": "/langfuse", + "target": langfuse.url + SELF_HOSTED_LANGFUSE, + "include_subpath": True, + "auth": True, + } + ], + }, + } + path: Final = directory / "configured-features.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +@contextmanager +def _configured_features(directory: Path) -> Iterator[_ConfiguredFeatures]: + alias: Final = "lazyroutes" + uuid.uuid4().hex[:8] + with ( + wire_server(_allow) as policy, + wire_server(_langfuse_health) as langfuse, + scripted_peer(echo_tool("add")) as peer, + ): + config: Final = _config_declaring(directory, alias, policy, langfuse, peer) + yield _ConfiguredFeatures(alias, config, policy, langfuse, peer) + + +def _config_server_id(candidate: Gateway, alias: str) -> str: + servers: Final = JSON.validate_json(candidate.request("GET", "/v1/mcp/server").content) + assert isinstance(servers, list), servers + return next( + string_value(object_value(server)["server_id"]) + for server in servers + if object_value(server)["server_name"] == alias + ) + + +def _assert_config_declared_features_serve(owned: OwnedProxy, features: _ConfiguredFeatures, provider: Wire) -> None: + marker: Final = _marker() + with owned.gateway.scenario() as scenario: + model: Final = scenario.model(api_base=provider.url + "/v1") + completion: Final = owned.gateway.request( + "POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]} + ) + assert completion.status_code == 200, completion.text + screened: Final = [request for request in features.policy.drain() if marker.encode() in request.body] + assert len(screened) == 1, "the config-declared guardrail did not screen the completion" + assert len([request for request in provider.drain() if marker.encode() in request.body]) == 1 + + identity: Final = _config_server_id(owned.gateway, features.alias) + key: Final = scenario.key(object_permission={"mcp_servers": [identity]}) + tool: Final = tool_names(owned.gateway, key, identity)["add"] + features.peer.drain() + called: Final = call_tool(owned.gateway, key, identity, tool, {"marker": marker}) + assert called.status_code == 200, called.text + reached_peer: Final = [ + object_value(object_value(call["body"])["params"]) for call in tool_calls(features.peer.drain()) + ] + assert [(params["name"], params["arguments"]) for params in reached_peer] == [("add", {"marker": marker})] + + forwarded: Final = owned.gateway.request("GET", "/langfuse/api/public/health") + assert forwarded.status_code == 200, forwarded.text + reached_langfuse: Final = tuple(request.target for request in features.langfuse.drain()) + assert reached_langfuse == (SELF_HOSTED_LANGFUSE + "/api/public/health",), ( + f"the config pass-through for /langfuse lost to the built-in Langfuse route: {reached_langfuse}" + ) + + +def test_disable_lazy_routes_flag_serves_config_declared_features_like_the_warmed_lazy_proxy( + gateway: Gateway, tmp_path: Path, provider: Wire +) -> None: + with _configured_features(tmp_path) as features: + with owned_proxy_process(gateway, tmp_path, {}, config=features.config, remove_environment=(FLAG,)) as lazy: + _assert_config_declared_features_serve(lazy, features, provider) + _warm_every_feature(lazy.gateway) + warmed_paths: Final = tuple(path for path in _paths(lazy.gateway) if path != WARMUP_ROUTE) + warmed_openapi: Final = _openapi_paths(lazy.gateway) + with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}, config=features.config) as eager: + _assert_config_declared_features_serve(eager, features, provider) + assert _paths(eager.gateway) == warmed_paths + assert _openapi_paths(eager.gateway) == warmed_openapi diff --git a/tests/unit/proxy/test__lazy_features.py b/tests/unit/proxy/test__lazy_features.py new file mode 100644 index 00000000000..d2bb8244c2f --- /dev/null +++ b/tests/unit/proxy/test__lazy_features.py @@ -0,0 +1,211 @@ +import sys +from collections.abc import AsyncGenerator, Mapping +from contextlib import asynccontextmanager +from types import ModuleType +from typing import Final + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from litellm.proxy._lazy_features import ( + LazyFeature, + LazyFeatureMiddleware, + attach_lazy_features, + lazy_tag_to_prefix, + loaded_lazy_modules, +) + +FLAG: Final = "LITELLM_DISABLE_LAZY_ROUTES" +WARMUP_PATH: Final = "/lazy/warm/{name}" + + +class _Operation(BaseModel): + tags: tuple[str, ...] + + +class _WarmupBody(BaseModel): + stub_path: str + paths: Mapping[str, Mapping[str, _Operation]] + + +def _feature_module(monkeypatch: pytest.MonkeyPatch, name: str, path: str) -> LazyFeature: + async def served() -> dict[str, str]: + return {"feature": name} + + router: Final = APIRouter() + router.add_api_route(path, served, methods=["GET"]) + module: Final = ModuleType(f"tests.unit.proxy.lazy_fixture_{name}") + module.router = router # pyright: ignore[reportAttributeAccessIssue] # fixture module built at test time + monkeypatch.setitem(sys.modules, module.__name__, module) + return LazyFeature(name=name, module_path=module.__name__, path_prefixes=(path,)) + + +def _paths(app: FastAPI) -> tuple[str, ...]: + return tuple(str(getattr(route, "path", "")) for route in app.routes) + + +def _has_lazy_middleware(app: FastAPI) -> bool: + return any(middleware.cls is LazyFeatureMiddleware for middleware in app.user_middleware) + + +@pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "on")) +def test_flag_registers_every_feature_at_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv(FLAG, value) + features: Final = ( + _feature_module(monkeypatch, "alpha", "/alpha/list"), + _feature_module(monkeypatch, "beta", "/beta/list"), + ) + app: Final = FastAPI() + + attach_lazy_features(app, features) + + assert WARMUP_PATH not in _paths(app) + assert not _has_lazy_middleware(app) + assert loaded_lazy_modules(app) == set() + with TestClient(app) as client: + at_startup: Final = _paths(app) + assert {"/alpha/list", "/beta/list"} <= set(at_startup) + assert loaded_lazy_modules(app) == {features[0].module_path, features[1].module_path} + assert client.get("/beta/list").json() == {"feature": "beta"} + assert client.post("/lazy/warm/alpha").status_code == 404 + assert _paths(app) == at_startup, "first feature request changed the table" + + +def test_flag_registers_before_the_inner_lifespan_and_after_late_routes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(FLAG, "true") + features: Final = (_feature_module(monkeypatch, "epsilon", "/epsilon/{name}"),) + seen_by_inner_lifespan: Final[list[tuple[str, ...]]] = [] # mutable-ok: captured from inside the lifespan + + @asynccontextmanager + async def inner_lifespan(app_: FastAPI) -> AsyncGenerator[None]: + seen_by_inner_lifespan.append(_paths(app_)) + yield + + async def late() -> dict[str, str]: + return {"feature": "late"} + + app: Final = FastAPI(lifespan=inner_lifespan) + attach_lazy_features(app, features) + app.add_api_route("/epsilon/list", late, methods=["GET"]) + + with TestClient(app) as client: + assert client.get("/epsilon/list").json() == {"feature": "late"}, "late eager route must win, as in lazy mode" + assert client.get("/epsilon/x").json() == {"feature": "epsilon"} + assert seen_by_inner_lifespan == [_paths(app)], "startup hooks inside the proxy lifespan must see the full table" + + +def test_flag_lets_a_route_added_during_startup_beat_an_overlapping_feature_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(FLAG, "true") + features: Final = (_feature_module(monkeypatch, "zeta", "/zeta/{endpoint:path}"),) + + async def configured() -> dict[str, str]: + return {"feature": "configured"} + + @asynccontextmanager + async def adds_a_pass_through(app_: FastAPI) -> AsyncGenerator[None]: + app_.add_api_route("/zeta/{subpath:path}", configured, methods=["GET"]) + yield + + app: Final = FastAPI(lifespan=adds_a_pass_through) + attach_lazy_features(app, features) + + with TestClient(app) as client: + assert client.get("/zeta/health").json() == {"feature": "configured"}, ( + "lazy mode routes this to startup's route" + ) + + +def test_flag_does_not_bring_back_a_feature_route_removed_during_startup(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(FLAG, "true") + features: Final = ( + _feature_module(monkeypatch, "eta", "/eta/list"), + _feature_module(monkeypatch, "theta", "/theta/list"), + ) + + @asynccontextmanager + async def drops_eta(app_: FastAPI) -> AsyncGenerator[None]: + app_.router.routes[:] = [route for route in app_.router.routes if getattr(route, "path", "") != "/eta/list"] + yield + + app: Final = FastAPI(lifespan=drops_eta) + attach_lazy_features(app, features) + + with TestClient(app) as client: + assert client.get("/eta/list").status_code == 404 + assert client.get("/theta/list").json() == {"feature": "theta"} + assert "/eta/list" not in _paths(app) + + +@pytest.mark.parametrize("value", (None, "", "0", "false", "off")) +def test_without_the_flag_features_still_mount_on_first_request( + monkeypatch: pytest.MonkeyPatch, value: str | None +) -> None: + if value is None: + monkeypatch.delenv(FLAG, raising=False) + else: + monkeypatch.setenv(FLAG, value) + features: Final = (_feature_module(monkeypatch, "gamma", "/gamma/list"),) + app: Final = FastAPI() + + attach_lazy_features(app, features) + + assert "/gamma/list" not in _paths(app) + assert WARMUP_PATH in _paths(app) + assert _has_lazy_middleware(app) + with TestClient(app) as client: + assert client.get("/gamma/list").json() == {"feature": "gamma"} + assert "/gamma/list" in _paths(app) + + +def test_flag_keeps_registering_after_one_feature_fails_to_import(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(FLAG, "true") + broken: Final = LazyFeature( + name="broken", module_path="tests.unit.proxy.lazy_fixture_does_not_exist", path_prefixes=("/broken",) + ) + healthy: Final = _feature_module(monkeypatch, "delta", "/delta/list") + app: Final = FastAPI() + + attach_lazy_features(app, (broken, healthy)) + + with TestClient(app) as client: + assert "/delta/list" in _paths(app) + assert loaded_lazy_modules(app) == {broken.module_path, healthy.module_path} + assert client.get("/delta/list").json() == {"feature": "delta"} + assert client.get("/broken").status_code == 404 + + +def test_flag_hides_the_swagger_warmup_plugin(monkeypatch: pytest.MonkeyPatch) -> None: + import litellm.proxy._lazy_openapi_snapshot as snapshot + + monkeypatch.setattr(snapshot, "SNAPSHOT_FILE", snapshot.SNAPSHOT_FILE.with_name("missing-snapshot.json")) + monkeypatch.setenv(FLAG, "false") + assert lazy_tag_to_prefix() != {}, "control: without the flag and without a snapshot the plugin has tags" + monkeypatch.setenv(FLAG, "true") + assert lazy_tag_to_prefix() == {} + + +def test_without_the_flag_the_warmup_route_registers_a_feature_and_returns_its_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(FLAG, raising=False) + features: Final = ( + _feature_module(monkeypatch, "alpha", "/alpha/list"), + _feature_module(monkeypatch, "beta", "/beta/list"), + ) + app: Final = FastAPI() + attach_lazy_features(app, features) + + with TestClient(app) as client: + assert client.post("/lazy/warm/zeta").status_code == 404 + warmed: Final = client.post("/lazy/warm/alpha") + assert warmed.status_code == 200, warmed.text + body: Final = _WarmupBody.model_validate_json(warmed.text) + assert body.stub_path == "/alpha/list" + assert set(body.paths) == {"/alpha/list"} + assert body.paths["/alpha/list"]["get"].tags == ("alpha",) + assert loaded_lazy_modules(app) == {features[0].module_path} + assert "/alpha/list" in _paths(app) and "/beta/list" not in _paths(app) From 4b1d9bf148e501f8f8037fb81fd420ccd3b0d760 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 1 Oct 2026 15:54:02 -0700 Subject: [PATCH 03/83] test(e2e): keep 1ms-timeout deployments off the provider cache (#44082) The timeout reliability tests rely on a 1ms deadline the real backend always misses. With E2E_PROVIDER_CACHE on, the deployment pointed at the cache edge, and its healthy sibling in the same test had already recorded a response for the same canonical request, so the edge answered from Redis inside the 1ms read window. Build 342 of litellm-e2e saw test_timeout_trips_cooldown_then_recovers get a 200 from the timing-out deployment itself, with a recording made about 12 hours earlier. Both timeout helpers now register on the live provider path, which PROVIDER_CACHE.md reserves for tests that need real provider timing --- tests/e2e/router/reliability_support.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/e2e/router/reliability_support.py b/tests/e2e/router/reliability_support.py index 5984d5645d8..f600531e663 100644 --- a/tests/e2e/router/reliability_support.py +++ b/tests/e2e/router/reliability_support.py @@ -97,7 +97,9 @@ def create_never_benched_refusing_deployment(proxy: ProxyClient, name: str) -> s def create_timeout_deployment(proxy: ProxyClient, name: str) -> str: """Register a deployment with a 1ms deadline the real backend always exceeds.""" - return proxy.create_model(name, LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001)) + return proxy.create_model( + name, LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001), provider_live=True + ) def create_small_context_deployment(proxy: ProxyClient, name: str) -> str: @@ -149,7 +151,7 @@ def create_caching_deployment(proxy: ProxyClient, name: str) -> str: def _register_benched_on_first_failure( - proxy: ProxyClient, name: str, litellm_params: LiteLLMParamsBody, allowed_fails: str + proxy: ProxyClient, name: str, litellm_params: LiteLLMParamsBody, allowed_fails: str, *, provider_live: bool = False ) -> str: """The always-picked half of a failing pair: all of the group's shuffle weight, and a cooldown policy that benches it on its first failure of the given class, @@ -159,7 +161,8 @@ def _register_benched_on_first_failure( model_name=name, litellm_params=litellm_params, model_info=ModelInfoBody(allowed_fails_policy={allowed_fails: 0}), - ) + ), + provider_live=provider_live, ) @@ -170,6 +173,7 @@ def create_always_timing_out_deployment(proxy: ProxyClient, name: str, cooldown_ name, LiteLLMParamsBody(model=REAL_MODEL, api_key=REAL_KEY, timeout=0.001, weight=1, cooldown_time=cooldown_time), "TimeoutErrorAllowedFails", + provider_live=True, ) From aa601ce4e8e87d1f01962332f30af4cd0548ae07 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:02:01 -0700 Subject: [PATCH 04/83] refactor(repositories): daily activity repository with centralized bounded usage queries (#43398) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 11 + litellm/proxy/_lazy_openapi_snapshot.json | 39 + .../common_daily_activity.py | 886 +++++------------- .../internal_user_endpoints.py | 28 +- .../management_endpoints/team_endpoints.py | 26 +- .../usage_endpoints/ai_usage_chat.py | 32 +- .../repositories/daily_activity_repository.py | 304 ++++++ litellm/repositories/daily_activity_sql.py | 519 ++++++++++ .../common_daily_activity.py | 16 + litellm/types/repositories/__init__.py | 0 litellm/types/repositories/daily_activity.py | 192 ++++ .../unbounded_in_baseline.txt | 11 +- .../spend/_daily_activity_fixtures.py | 319 +++++++ .../spend/fixtures/daily_activity_team.json | 1 + .../spend/fixtures/daily_activity_user.json | 1 + ...st_daily_activity_aggregated_breakdowns.py | 93 +- .../spend/test_daily_activity_repository.py | 624 ++++++++++++ .../test_common_daily_activity.py | 778 ++++++++------- .../test_internal_user_endpoints.py | 29 +- .../test_team_endpoints.py | 12 +- .../unit/proxy/proxy_server/test_lifecycle.py | 11 +- .../test_daily_activity_repository.py | 549 +++++++++++ .../repositories/test_daily_activity_sql.py | 411 ++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 17 + 24 files changed, 3736 insertions(+), 1173 deletions(-) create mode 100644 litellm/repositories/daily_activity_repository.py create mode 100644 litellm/repositories/daily_activity_sql.py create mode 100644 litellm/types/repositories/__init__.py create mode 100644 litellm/types/repositories/daily_activity.py create mode 100644 tests/integration/spend/_daily_activity_fixtures.py create mode 100644 tests/integration/spend/fixtures/daily_activity_team.json create mode 100644 tests/integration/spend/fixtures/daily_activity_user.json create mode 100644 tests/integration/spend/test_daily_activity_repository.py create mode 100644 tests/unit/repositories/test_daily_activity_repository.py create mode 100644 tests/unit/repositories/test_daily_activity_sql.py diff --git a/litellm/constants.py b/litellm/constants.py index 23962400f2a..af4d1268c03 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -2162,6 +2162,17 @@ MCP_SPEND_LOG_MODEL_PREFIX: Final[str] = "MCP: " PTU_SENTINEL_API_KEY: Final[str] = "__ptu_flat_cost__" PTU_ROLLUP_JOB_ID: Final[str] = "ptu_flat_cost_rollup_job" PTU_ROLLUP_LOCK_TTL_SECONDS: Final[int] = 900 +USAGE_TOP_API_KEYS_DEFAULT: Final[int] = 100 +USAGE_TOP_API_KEYS_MAX: Final[int] = 1000 +USAGE_KEY_PAGE_DEFAULT: Final[int] = 50 +USAGE_KEY_PAGE_MAX: Final[int] = 100 +USAGE_KEY_SEARCH_DEFAULT: Final[int] = 100 +USAGE_KEY_SEARCH_MAX: Final[int] = 100 +USAGE_MODEL_TOP_KEYS_DEFAULT: Final[int] = 5 +USAGE_MODEL_TOP_KEYS_MAX: Final[int] = 100 +USAGE_CACHE_LEAKAGE_KEYS_DEFAULT: Final[int] = 20 +USAGE_CACHE_LEAKAGE_KEYS_MAX: Final[int] = 100 +USAGE_EXPORT_BATCH_SIZE: Final[int] = 1000 # Furthest back the catch-up pass looks for unpriced PTU days when a deployment # declares no ptu_effective_from, bounding the scan for an open-ended window. PTU_ROLLUP_MAX_BACKFILL_DAYS: Final[int] = 90 diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 663a8e0d6d8..a9d88c50380 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3455,6 +3455,33 @@ }, "DailySpendMetadata": { "properties": { + "api_key_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "When set, api_keys and every api_key_breakdown list at most this many keys, ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.", + "title": "Api Key Limit" + }, + "entity_total_api_keys": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Distinct API keys per entity over the requested range, set when the entity breakdown is included. When an entity's count exceeds api_key_limit, its api_key_breakdown lists only its keys among the top api_key_limit keys overall.", + "title": "Entity Total Api Keys" + }, "has_more": { "default": false, "title": "Has More", @@ -3465,6 +3492,18 @@ "title": "Page", "type": "integer" }, + "total_api_keys": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key lists are truncated to the highest-spend keys.", + "title": "Total Api Keys" + }, "total_api_requests": { "default": 0, "title": "Total Api Requests", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index f3564fcbc56..59d1d2bf01d 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,13 +1,15 @@ import asyncio from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet -from datetime import datetime, timedelta, timezone -from types import MappingProxyType, SimpleNamespace -from typing import TYPE_CHECKING, Final, Protocol +from dataclasses import dataclass, replace +from datetime import datetime, timedelta +from types import MappingProxyType +from typing import Final, Protocol from fastapi import HTTPException, status from typing_extensions import ReadOnly, TypedDict +from litellm import constants from litellm._logging import verbose_proxy_logger from litellm.constants import PTU_SENTINEL_API_KEY from litellm.proxy._types import CommonProxyErrors @@ -20,11 +22,7 @@ from litellm.proxy.spend_tracking.key_metadata_recovery import ( ) from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled from litellm.proxy.utils import PrismaClient -from litellm.repositories.prisma_protocols import TableActions -from litellm.repositories.table_repositories import DeletedVerificationTokenRepository -from litellm.repositories.verification_token_repository import ( - VerificationTokenRepository, -) +from litellm.repositories.daily_activity_repository import DailyActivityRepository from litellm.types.proxy.management_endpoints.common_daily_activity import ( BreakdownMetrics, DailySpendData, @@ -36,24 +34,15 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, SpendMetrics, ) - -if TYPE_CHECKING: - from prisma.models import ( - LiteLLM_DeletedVerificationToken as PrismaDeletedVerificationToken, - ) - from prisma.models import ( - LiteLLM_VerificationToken as PrismaVerificationToken, - ) - -# Mapping from Prisma accessor names to actual PostgreSQL table names. -_PRISMA_TO_PG_TABLE: Final[Mapping[str, str]] = { - "litellm_dailyuserspend": "LiteLLM_DailyUserSpend", - "litellm_dailyteamspend": "LiteLLM_DailyTeamSpend", - "litellm_dailyorganizationspend": "LiteLLM_DailyOrganizationSpend", - "litellm_dailyenduserspend": "LiteLLM_DailyEndUserSpend", - "litellm_dailyagentspend": "LiteLLM_DailyAgentSpend", - "litellm_dailytagspend": "LiteLLM_DailyTagSpend", -} +from litellm.types.repositories.daily_activity import ( + DailyActivityScope, + DailyActivityTable, + EntityRollupRow, + GroupingSetsRow, + KeyMetadataRow, + RollupMetricsRow, + SpendLogsWindow, +) class DailySpendRecord(Protocol): @@ -132,57 +121,23 @@ class _KeyMetadataDict(TypedDict, total=False): key_exists: ReadOnly[bool] -def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str) -> KeyMetadata: - meta: Final = api_key_metadata.get(api_key, {}) +class _AggregatedSpendData(TypedDict): + results: ReadOnly[list[DailySpendData]] + totals: ReadOnly[SpendMetrics] + + +def _key_metadata(api_key_metadata: Mapping[str, KeyMetadataRow], api_key: str) -> KeyMetadata: + meta: Final = api_key_metadata.get(api_key) return KeyMetadata( - key_alias=meta.get("key_alias"), - team_id=meta.get("team_id"), - user_id=meta.get("user_id"), - user_email=meta.get("user_email"), - key_exists=meta.get("key_exists", False), + key_alias=meta.key_alias if meta is not None else None, + team_id=meta.team_id if meta is not None else None, + user_id=meta.user_id if meta is not None else None, + user_email=meta.user_email if meta is not None else None, + key_exists=meta.key_exists if meta is not None else False, ) -_WhereValue = str | dict[str, object] - - -class _AggregatedSpendData(TypedDict): - results: list[DailySpendData] - totals: SpendMetrics - - -class _GroupingSetsRow(SimpleNamespace): - date: str - api_key: str | None - model: str | None - model_group: str | None - custom_llm_provider: str | None - mcp_namespaced_tool_name: str | None - endpoint: str | None - group_level: int - spend: float | None - prompt_tokens: int | None - completion_tokens: int | None - cache_read_input_tokens: int | None - cache_creation_input_tokens: int | None - compression_saved_tokens: int | None - compression_savings_spend: float | None - prompt_caching_savings_spend: float | None - gateway_injected_caching_savings_spend: float | None - autorouter_savings_spend: float | None - api_requests: int | None - successful_requests: int | None - failed_requests: int | None - total_response_time_ms: int | None - timed_requests: int | None - - -class _EntityRollupRow(_GroupingSetsRow): - entity_id: str | None - api_key_rolled: int - - -def _reported_flat_cost(record: DailySpendRecord | _GroupingSetsRow) -> float: +def _reported_flat_cost(record: DailySpendRecord | RollupMetricsRow) -> float: """Flat cost a daily row reports, which is zero unless PTU cost attribution is enabled. Both read paths funnel through here: the paginated path reads the ``ptu_flat_cost`` @@ -223,9 +178,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) -> existing_metrics.compression_saved_tokens += record.compression_saved_tokens or 0 existing_metrics.compression_savings_spend += record.compression_savings_spend or 0 existing_metrics.prompt_caching_savings_spend += record.prompt_caching_savings_spend or 0 - existing_metrics.gateway_injected_caching_savings_spend += ( # rebind-ok: this accumulator mutates its target in place for every metric on the row - record.gateway_injected_caching_savings_spend or 0 - ) + existing_metrics.gateway_injected_caching_savings_spend += record.gateway_injected_caching_savings_spend or 0 existing_metrics.autorouter_savings_spend += record.autorouter_savings_spend or 0 existing_metrics.api_requests += record.api_requests or 0 existing_metrics.successful_requests += record.successful_requests or 0 @@ -255,7 +208,7 @@ def compute_tag_metadata_totals(records: Sequence[DailySpendRecord]) -> SpendMet if not request_id: continue - tag_value = getattr(record, "tag", None) + tag_value: str | None = getattr(record, "tag", None) if _is_user_agent_tag(tag_value): continue @@ -283,7 +236,7 @@ def update_breakdown_metrics( record: DailySpendRecord, model_metadata: Mapping[str, dict[str, object]], provider_metadata: Mapping[str, dict[str, object]], - api_key_metadata: Mapping[str, _KeyMetadataDict], + api_key_metadata: Mapping[str, KeyMetadataRow], entity_id_field: str | None = None, entity_metadata_field: Mapping[str, dict[str, object]] | None = None, ) -> BreakdownMetrics: @@ -426,8 +379,7 @@ def update_breakdown_metrics( # Update entity-specific metrics if entity_id_field is provided if entity_id_field: - entity_value = getattr(record, entity_id_field, None) - entity_value = entity_value if entity_value else "Unassigned" # allow for null entity_id_field + entity_value: Final[str] = getattr(record, entity_id_field, None) or "Unassigned" if entity_value not in breakdown.entities: breakdown.entities[entity_value] = MetricWithMetadata( metrics=SpendMetrics(), @@ -466,9 +418,6 @@ def _parse_spend_date(raw: str | None) -> datetime | None: return None -_EMPTY_KEY_METADATA: Final[Mapping[str, _KeyMetadataDict]] = MappingProxyType({}) - - def _metadata_with_recovered_owner( metadata: Mapping[str, _KeyMetadataDict], key: str, @@ -480,432 +429,135 @@ def _metadata_with_recovered_owner( return {**current, "user_id": owner} -async def get_api_key_metadata( - prisma_client: PrismaClient, - api_keys: AbstractSet[str], - spend_logs_window: tuple[datetime, datetime] | None = None, -) -> Mapping[str, _KeyMetadataDict]: - """Get api key metadata, falling back to deleted keys table for keys not found in active table. +@dataclass(frozen=True, slots=True) +class _ProxyDailyActivityReads: + prisma_client: PrismaClient - This ensures that key_alias and team_id are preserved in historical activity logs - even after a key is deleted or regenerated. Also recovers aliases for api_key - values that were double-hashed by the v1.99 spend-log provenance gate. - """ - key_records: Sequence[PrismaVerificationToken] = await VerificationTokenRepository(prisma_client).table.find_many( - where={"token": {"in": list(api_keys)}} - ) - result: Final[dict[str, _KeyMetadataDict]] = { - k.token: { - "key_alias": k.key_alias, - "team_id": k.team_id, - "user_id": getattr(k, "user_id", None), - "key_exists": True, + async def recover_key_metadata( + self, resolved: Mapping[str, KeyMetadataRow], api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: + result: Final[dict[str, _KeyMetadataDict]] = { + key: { + "key_alias": row.key_alias, + "team_id": row.team_id, + "user_id": row.user_id, + "user_email": row.user_email, + "key_exists": row.key_exists, + } + for key, row in resolved.items() } - for k in key_records - } - - # For any keys not found in the active table, check the deleted keys table - missing_keys: Final = api_keys - set(result.keys()) - if missing_keys: - try: - deleted_key_records: Final[ - Sequence[PrismaDeletedVerificationToken] - ] = await DeletedVerificationTokenRepository(prisma_client).table.find_many( - where={"token": {"in": list(missing_keys)}}, - order={"deleted_at": "desc"}, - ) - # Use the most recent deleted record for each token (ordered by deleted_at desc) - for k in deleted_key_records: - if k.token not in result: - result[k.token] = { - "key_alias": k.key_alias, - "team_id": k.team_id, - "user_id": getattr(k, "user_id", None), - } - except Exception as e: - verbose_proxy_logger.warning( - "Failed to fetch deleted key metadata for %d missing keys: %s", - len(missing_keys), - e, - ) - - from_session_keys: Final = await recover_cli_session_key_metadata(prisma_client, api_keys - frozenset(result)) - still_missing: Final = api_keys - frozenset(result) - frozenset(from_session_keys) - from_reverse_hash: Final = ( - await recover_double_hashed_key_metadata(prisma_client, still_missing) if still_missing else _EMPTY_KEY_METADATA - ) - after_token_recovery: Final = MappingProxyType({**result, **from_session_keys, **from_reverse_hash}) - unresolved: Final = api_keys - frozenset(after_token_recovery) - from_spend_logs: Final = ( - await recover_key_metadata_from_spend_logs(prisma_client, unresolved, spend_logs_window) - if unresolved and spend_logs_window is not None - else _EMPTY_KEY_METADATA - ) - combined: Final = MappingProxyType({**after_token_recovery, **from_spend_logs}) - ownerless: Final = frozenset( - key - for key in api_keys - if not combined.get(key, {}).get("user_id") and not combined.get(key, {}).get("key_exists") - ) - owners: Final = await recover_key_owner_from_daily_spend(prisma_client, ownerless) - metadata_with_owners: Final[Mapping[str, _KeyMetadataDict]] = MappingProxyType( - { - **combined, - **{key: _metadata_with_recovered_owner(combined, key, owner) for key, owner in owners.items()}, - } - ) - return await attach_user_details(prisma_client, metadata_with_owners) + from_session_keys: Final = await recover_cli_session_key_metadata( + self.prisma_client, api_keys - frozenset(result) + ) + still_missing: Final = api_keys - frozenset(result) - frozenset(from_session_keys) + from_reverse_hash: Final = ( + await recover_double_hashed_key_metadata(self.prisma_client, still_missing) + if still_missing + else MappingProxyType({}) + ) + after_token_recovery: Final = MappingProxyType({**result, **from_session_keys, **from_reverse_hash}) + unresolved: Final = api_keys - frozenset(after_token_recovery) + from_spend_logs: Final = ( + await recover_key_metadata_from_spend_logs(self.prisma_client, unresolved, window) + if unresolved and window is not None + else MappingProxyType({}) + ) + combined: Final = MappingProxyType({**after_token_recovery, **from_spend_logs}) + ownerless: Final = frozenset( + key + for key in api_keys + if not combined.get(key, {}).get("user_id") and not combined.get(key, {}).get("key_exists") + ) + owners: Final = await recover_key_owner_from_daily_spend(self.prisma_client, ownerless) + with_owners: Final[Mapping[str, _KeyMetadataDict]] = MappingProxyType( + { + **combined, + **{key: _metadata_with_recovered_owner(combined, key, owner) for key, owner in owners.items()}, + } + ) + attached: Final = await attach_user_details(self.prisma_client, with_owners) + return MappingProxyType( + { + key: replace( + resolved[key], + key_alias=value.get("key_alias"), + team_id=value.get("team_id"), + user_id=value.get("user_id"), + user_email=value.get("user_email"), + key_exists=value.get("key_exists", False), + ) + if key in resolved + else KeyMetadataRow( + api_key=key, + key_alias=value.get("key_alias"), + team_id=value.get("team_id"), + user_id=value.get("user_id"), + user_email=value.get("user_email"), + key_exists=value.get("key_exists", False), + tags=(), + ) + for key, value in attached.items() + } + ) -def _adjust_dates_for_timezone( +def daily_activity_repository(prisma_client: PrismaClient) -> DailyActivityRepository: + return DailyActivityRepository(prisma_client, proxy_reads=_ProxyDailyActivityReads(prisma_client)) + + +def daily_activity_scope( + table: str, + entity_id_field: str, + entity_id: str | list[str] | None, + exclude_entity_ids: list[str] | None, + api_key: str | list[str] | None, start_date: str, end_date: str, + model: str | None, timezone_offset_minutes: int | None, include_current_utc_day: bool = False, - utc_now: datetime | None = None, -) -> tuple[str, str]: - """ - Map a caller-local date range onto UTC bucket keys, extending only the live end. - - The aggregation table (e.g. LiteLLM_DailyUserSpend) stores spend in whole-UTC-day - buckets keyed on date as YYYY-MM-DD. Any conversion of an interior local-day - boundary using only date arithmetic must round to whole UTC days, allowing up to - 24h of slop at each boundary. A previous implementation expanded the SQL range by - an extra full UTC day on whichever side the offset pointed, which pulled in 24h of - unrelated bucket data per boundary and produced approximately 100% over-counting on - single-day queries (e.g. IST May 29 returning UTC May 28 + UTC May 29 in full). - Sums of single-day queries then exceeded the equivalent multi-day aggregate, which - is mathematically impossible. Historical dates therefore stay a pass-through: the - local date is the UTC bucket key, trading boundary slop for monotonic, additive - results. Hour-level buckets or pro-rata weighting would fix that properly; both - require data the current schema does not store. - - The end boundary is different when the range reaches the caller's current day. A - caller west of UTC asking for a range ending "today" is asking for data up to now, - but once UTC has rolled past their local midnight, everything they sent since then - sits in the next UTC bucket, which the pass-through excludes: a PT dashboard goes - stale every evening from 5pm until local midnight, showing $0 for anything that - only started accruing that evening. Extending such a range to today's UTC bucket - cannot over-count, because the only part of that bucket outside the caller's range - is the future, and the future is empty. ``timezone_offset_minutes`` follows the - JS ``Date.getTimezoneOffset`` convention: UTC minus local, positive west of UTC. - - The extension is strictly opt-in via ``include_current_utc_day`` so a consumer - whose axis or reconciliation expects the range to stop at the requested end date - keeps today's byte-for-byte behaviour; the cost optimization dashboard opts in. - """ - if not include_current_utc_day or timezone_offset_minutes is None: - return start_date, end_date - now: Final = utc_now if utc_now is not None else datetime.now(timezone.utc) - caller_local_today: Final = (now - timedelta(minutes=timezone_offset_minutes)).date().isoformat() - if end_date < caller_local_today: - return start_date, end_date - return start_date, max(end_date, now.date().isoformat()) - - -def _build_where_conditions( - *, - entity_id_field: str, - entity_id: str | list[str] | None, - start_date: str, - end_date: str, - model: str | None, - api_key: str | list[str] | None, - exclude_entity_ids: list[str] | None = None, - timezone_offset_minutes: int | None = None, - include_current_utc_day: bool = False, -) -> dict[str, "_WhereValue"]: - """Build prisma where clause for daily activity queries.""" - # Adjust dates for timezone if provided - adjusted_start, adjusted_end = _adjust_dates_for_timezone( - start_date, end_date, timezone_offset_minutes, include_current_utc_day +) -> DailyActivityScope: + table_value: Final = DailyActivityTable(table) + entity_ids: tuple[str, ...] | None = ( + (entity_id,) if isinstance(entity_id, str) else tuple(entity_id) if entity_id is not None else None + ) + api_keys: tuple[str, ...] | None = ( + None if api_key in (None, "") else (api_key,) if isinstance(api_key, str) else tuple(api_key) + ) + return DailyActivityScope( + table=table_value, + entity_id_field=entity_id_field, + entity_ids=entity_ids, + exclude_entity_ids=tuple(exclude_entity_ids or ()), + api_keys=api_keys, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, ) - where_conditions: Final[dict[str, _WhereValue]] = { - "date": { - "gte": adjusted_start, - "lte": adjusted_end, + +async def get_api_key_metadata( + prisma_client: PrismaClient, api_keys: AbstractSet[str], spend_logs_window: SpendLogsWindow | None = None +) -> Mapping[str, _KeyMetadataDict]: + rows: Final = await daily_activity_repository(prisma_client).key_metadata(frozenset(api_keys), spend_logs_window) + return { + key: { + "key_alias": value.key_alias, + "team_id": value.team_id, + "user_id": value.user_id, + "user_email": value.user_email, + "key_exists": value.key_exists, } + for key, value in rows.items() } - if model: - where_conditions["model"] = model - if api_key: - if isinstance(api_key, list): - where_conditions["api_key"] = {"in": api_key} - else: - where_conditions["api_key"] = api_key - - if entity_id is not None: - if isinstance(entity_id, list): - where_conditions[entity_id_field] = {"in": entity_id} - else: - where_conditions[entity_id_field] = {"equals": entity_id} - - if exclude_entity_ids: - current: _WhereValue = where_conditions.get(entity_id_field, {}) - if isinstance(current, str): - current = {"equals": current} - current["not"] = {"in": exclude_entity_ids} - where_conditions[entity_id_field] = current - - return where_conditions - - -def _build_aggregated_where_clause( - *, - entity_id_field: str, - entity_id: str | list[str] | None, - adjusted_start: str, - adjusted_end: str, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None, # mutable-ok: filter union shared with the paginated path -) -> tuple[str, list[str]]: - """Build the WHERE clause and $N params shared by the aggregated queries.""" - sql_conditions: Final[list[str]] = [] - sql_params: Final[list[str]] = [] - p = 1 # parameter index (1-based for PostgreSQL $N placeholders) - - # Date range (always present) - sql_conditions.append(f"date >= ${p}") - sql_params.append(adjusted_start) - p += 1 - - sql_conditions.append(f"date <= ${p}") - sql_params.append(adjusted_end) - p += 1 - - # Optional entity filter; an empty list must match nothing, not everything - if entity_id is not None: - if isinstance(entity_id, list): - if entity_id: - placeholders = ", ".join(f"${p + i}" for i in range(len(entity_id))) - sql_conditions.append(f'"{entity_id_field}" IN ({placeholders})') - sql_params.extend(entity_id) - p += len(entity_id) - else: - sql_conditions.append("FALSE") - else: - sql_conditions.append(f'"{entity_id_field}" = ${p}') - sql_params.append(entity_id) - p += 1 - - # Exclude specific entities - if exclude_entity_ids: - placeholders = ", ".join(f"${p + i}" for i in range(len(exclude_entity_ids))) - sql_conditions.append(f'"{entity_id_field}" NOT IN ({placeholders})') - sql_params.extend(exclude_entity_ids) - p += len(exclude_entity_ids) - - # Optional model filter - if model: - sql_conditions.append(f"model = ${p}") - sql_params.append(model) - p += 1 - - # Optional api_key filter; an empty list must match nothing, not everything - if isinstance(api_key, list): - if api_key: - placeholders = ", ".join(f"${p + i}" for i in range(len(api_key))) - sql_conditions.append(f"api_key IN ({placeholders})") - sql_params.extend(api_key) - p += len(api_key) - else: - sql_conditions.append("FALSE") - elif api_key: - sql_conditions.append(f"api_key = ${p}") - sql_params.append(api_key) - p += 1 - - return " AND ".join(sql_conditions), sql_params - - -def _ptu_flat_cost_select(table_name: str) -> str: - """Only LiteLLM_DailyTeamSpend carries ptu_flat_cost; other daily tables emit a - constant zero so the SpendMetrics.flat_cost response shape stays uniform.""" - if table_name == "litellm_dailyteamspend": - return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" - return "0::float AS ptu_flat_cost" - - -def _build_aggregated_sql_query( - *, - table_name: str, - entity_id_field: str, - entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - start_date: str, - end_date: str, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path - timezone_offset_minutes: int | None = None, - include_current_utc_day: bool = False, -) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params - """Build a parameterized SQL GROUP BY query for aggregated daily activity. - - Groups by (date, api_key, model, model_group, custom_llm_provider, - mcp_namespaced_tool_name, endpoint) with SUMs on all metric columns. - The entity_id column is intentionally omitted from GROUP BY to collapse - rows across entities — this is where the biggest row reduction comes from. - - Returns: - Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw(). - """ - pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name) - if pg_table is None: - raise ValueError(f"Unknown table name: {table_name}") - - adjusted_start, adjusted_end = _adjust_dates_for_timezone( - start_date, end_date, timezone_offset_minutes, include_current_utc_day - ) - - where_clause, sql_params = _build_aggregated_where_clause( - entity_id_field=entity_id_field, - entity_id=entity_id, - adjusted_start=adjusted_start, - adjusted_end=adjusted_end, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - ) - - # Postgres computes every rollup level the response needs — per-date - # totals, per-(date, model), per-(date, model, api_key), per-provider, - # etc. — in a single pass via GROUPING SETS. The GROUPING() bitmask - # encodes which level a row belongs to so Python can dispatch rows - # straight into their buckets without re-summing. The leaf grouping - # is omitted on purpose: nothing in the response shape needs it once - # all the rollups are present. - # - # TODO: drop the successful_requests/failed_requests aggregates (and the - # total_successful_requests metadata they feed) once the admin UI reads SGR - # only from LiteLLM_DailyGatewayRequests. The remaining spend, token and - # api_requests rollups are still served from here. - sql_query: Final = f""" - SELECT - date, - api_key, - model, - COALESCE(NULLIF(model_group, ''), model) AS model_group, - custom_llm_provider, - mcp_namespaced_tool_name, - endpoint, - GROUPING(date, api_key, model, COALESCE(NULLIF(model_group, ''), model), - custom_llm_provider, mcp_namespaced_tool_name, - endpoint) AS group_level, - SUM(spend)::float AS spend, - {_ptu_flat_cost_select(table_name)}, - SUM(prompt_tokens)::bigint AS prompt_tokens, - SUM(completion_tokens)::bigint AS completion_tokens, - SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, - SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, - SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, - SUM(compression_savings_spend)::float AS compression_savings_spend, - SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, - SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, - SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, - SUM(api_requests)::bigint AS api_requests, - SUM(successful_requests)::bigint AS successful_requests, - SUM(failed_requests)::bigint AS failed_requests, - SUM(total_response_time_ms)::bigint AS total_response_time_ms, - SUM(timed_requests)::bigint AS timed_requests - FROM "{pg_table}" - WHERE {where_clause} - GROUP BY GROUPING SETS ( - (date), - (date, api_key), - (date, model), - (date, model, api_key), - (date, COALESCE(NULLIF(model_group, ''), model)), - (date, COALESCE(NULLIF(model_group, ''), model), api_key), - (date, custom_llm_provider), - (date, custom_llm_provider, api_key), - (date, mcp_namespaced_tool_name), - (date, mcp_namespaced_tool_name, api_key), - (date, endpoint), - (date, endpoint, api_key), - () - ) - """ - - return sql_query, sql_params - - -def _build_entity_rollup_sql_query( - *, - table_name: str, - entity_id_field: str, - entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - start_date: str, - end_date: str, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path - timezone_offset_minutes: int | None = None, - include_current_utc_day: bool = False, -) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params - """Per-entity companion to _build_aggregated_sql_query. - - Two rollup levels over the same WHERE clause — (date, entity) and - (date, entity, api_key) — told apart by GROUPING(api_key): 1 when the - api_key column is rolled up, 0 when it is part of the key. - """ - pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name) - if pg_table is None: - raise ValueError(f"Unknown table name: {table_name}") - - adjusted_start, adjusted_end = _adjust_dates_for_timezone( - start_date, end_date, timezone_offset_minutes, include_current_utc_day - ) - - where_clause, sql_params = _build_aggregated_where_clause( - entity_id_field=entity_id_field, - entity_id=entity_id, - adjusted_start=adjusted_start, - adjusted_end=adjusted_end, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - ) - - sql_query: Final = f""" - SELECT - "{entity_id_field}" AS entity_id, - date, - api_key, - GROUPING(api_key) AS api_key_rolled, - SUM(spend)::float AS spend, - {_ptu_flat_cost_select(table_name)}, - SUM(prompt_tokens)::bigint AS prompt_tokens, - SUM(completion_tokens)::bigint AS completion_tokens, - SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, - SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, - SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, - SUM(compression_savings_spend)::float AS compression_savings_spend, - SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, - SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, - SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, - SUM(api_requests)::bigint AS api_requests, - SUM(successful_requests)::bigint AS successful_requests, - SUM(failed_requests)::bigint AS failed_requests, - SUM(total_response_time_ms)::bigint AS total_response_time_ms, - SUM(timed_requests)::bigint AS timed_requests - FROM "{pg_table}" - WHERE {where_clause} - GROUP BY GROUPING SETS ( - (date, "{entity_id_field}"), - (date, "{entity_id_field}", api_key) - ) - """ - - return sql_query, sql_params - def _aggregate_spend_records_sync( *, records: Sequence[DailySpendRecord], - api_key_metadata: Mapping[str, _KeyMetadataDict], + api_key_metadata: Mapping[str, KeyMetadataRow], entity_id_field: str | None, entity_metadata_field: Mapping[str, dict[str, object]] | None, ) -> _AggregatedSpendData: @@ -954,7 +606,7 @@ def _aggregate_spend_records_sync( async def _aggregate_spend_records( *, - prisma_client: PrismaClient, + repository: DailyActivityRepository, records: Sequence[DailySpendRecord], entity_id_field: str | None, entity_metadata_field: Mapping[str, dict[str, object]] | None, @@ -968,11 +620,13 @@ async def _aggregate_spend_records( record.api_key for record in records if record.api_key and record.api_key != PTU_SENTINEL_API_KEY } - api_key_metadata: Mapping[str, _KeyMetadataDict] = MappingProxyType({}) - if api_keys: - api_key_metadata = await get_api_key_metadata( - prisma_client, api_keys, _spend_logs_window(frozenset(record.date for record in records)) + api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( + await repository.key_metadata( + frozenset(api_keys), _spend_logs_window(frozenset(record.date for record in records)) ) + if api_keys + else MappingProxyType({}) + ) return await asyncio.to_thread( _aggregate_spend_records_sync, @@ -983,8 +637,7 @@ async def _aggregate_spend_records( ) -# GROUPING() bitmask values for each grouping set emitted by -# _build_aggregated_sql_query. Per Postgres semantics, the rightmost argument +# GROUPING() bitmask values returned by the daily activity repository. Per Postgres semantics, the rightmost argument # is the least-significant bit. Argument order: # date, api_key, model, model_group, custom_llm_provider, # mcp_namespaced_tool_name, endpoint @@ -992,6 +645,7 @@ async def _aggregate_spend_records( # current grouping set's key), 0 when the column is part of the key. _GROUP_GRAND_TOTAL: Final = 127 # 0b1111111 — all rolled up _GROUP_DATE: Final = 63 # 0b0111111 — only date kept +_API_KEY_ROLLED_UP_BIT: Final = 32 # 0b0100000 _GROUP_DATE_API_KEY: Final = 31 # 0b0011111 _GROUP_DATE_MODEL: Final = 47 # 0b0101111 _GROUP_DATE_MODEL_API_KEY: Final = 15 # 0b0001111 @@ -1005,7 +659,7 @@ _GROUP_DATE_ENDPOINT: Final = 62 # 0b0111110 _GROUP_DATE_ENDPOINT_API_KEY: Final = 30 # 0b0011110 -def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics: +def _record_to_spend_metrics(record: RollupMetricsRow) -> SpendMetrics: """Build a SpendMetrics directly from one already-aggregated rollup row. SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total @@ -1036,8 +690,8 @@ def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics: def _aggregate_grouping_sets_records_sync( *, - records: Sequence[_GroupingSetsRow], - api_key_metadata: Mapping[str, _KeyMetadataDict], + records: Sequence[GroupingSetsRow], + api_key_metadata: Mapping[str, KeyMetadataRow], ) -> _AggregatedSpendData: """Build the response from rollup rows produced by the GROUPING SETS query. @@ -1162,17 +816,17 @@ def _aggregate_grouping_sets_records_sync( async def _aggregate_grouping_sets_records( *, - prisma_client: PrismaClient, - records: Sequence[_GroupingSetsRow], + repository: DailyActivityRepository, + records: Sequence[GroupingSetsRow], ) -> _AggregatedSpendData: """Async wrapper: fetch api_key_metadata, then dispatch on a worker thread.""" api_keys: Final[set[str]] = {r.api_key for r in records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY} - api_key_metadata: Mapping[str, _KeyMetadataDict] = MappingProxyType({}) - if api_keys: - api_key_metadata = await get_api_key_metadata( - prisma_client, api_keys, _spend_logs_window(frozenset(r.date for r in records)) - ) + api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( + await repository.key_metadata(frozenset(api_keys), _spend_logs_window(frozenset(r.date for r in records))) + if api_keys + else MappingProxyType({}) + ) return await asyncio.to_thread( _aggregate_grouping_sets_records_sync, @@ -1200,82 +854,43 @@ async def get_daily_activity( resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]] | None = None, ) -> SpendAnalyticsPaginatedResponse: - """Common function to get daily activity for any entity type. - - ``resolve_entity_metadata`` lets a caller resolve entity metadata from the - rows actually on the page (e.g. user_id -> user_email) instead of fetching - the whole entity table upfront, which matters when the entity set is - unbounded. - """ - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - + raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value}) if start_date is None or end_date is None: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "Please provide start_date and end_date"}, + status_code=status.HTTP_400_BAD_REQUEST, detail={"error": "Please provide start_date and end_date"} ) - try: - where_conditions: Final = _build_where_conditions( - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - include_current_utc_day=include_current_utc_day, + scope: Final = daily_activity_scope( + table_name, + entity_id_field, + entity_id, + exclude_entity_ids, + api_key, + start_date, + end_date, + model, + timezone_offset_minutes, + include_current_utc_day, ) - - spend_table: Final[TableActions[DailySpendRecord]] = getattr(prisma_client.db, table_name) - - # Get total count for pagination - total_count: Final[int] = await spend_table.count(where=where_conditions) - - # Fetch paginated results. - # ``date`` alone is not a unique sort key -- a busy tenant has many - # rows per date (one per api_key, model, model_group, provider, - # endpoint, ...), so offset pagination over ``date desc`` lands on - # arbitrary boundaries and the same row can be skipped on one page - # and returned on another. A client that pages through and sums the - # per-page metrics (the Usage dashboard) then gets a non-deterministic - # total. Adding ``id`` (the row's UUID primary key, present on both - # LiteLLM_DailyUserSpend and LiteLLM_DailyTeamSpend) as a tiebreaker - # gives every page a stable cursor (#30164). - daily_spend_data: Final[Sequence[DailySpendRecord]] = await spend_table.find_many( - where=where_conditions, - order=[ - {"date": "desc"}, - {"id": "asc"}, - ], - skip=(page - 1) * page_size, - take=page_size, - ) - + repository: Final = daily_activity_repository(prisma_client) + page_data: Final = await repository.daily_rows(scope, page=page, page_size=page_size) + daily_spend_data: Final = page_data.rows resolved_entity_metadata = entity_metadata_field if resolve_entity_metadata is not None: resolved_entity_metadata = { **(entity_metadata_field or {}), **(await resolve_entity_metadata(daily_spend_data)), } - aggregated: Final = await _aggregate_spend_records( - prisma_client=prisma_client, + repository=repository, records=daily_spend_data, entity_id_field=entity_id_field, entity_metadata_field=resolved_entity_metadata, ) - metadata_metrics = aggregated["totals"] if metadata_metrics_func: metadata_metrics = metadata_metrics_func(daily_spend_data) - return SpendAnalyticsPaginatedResponse( results=aggregated["results"], metadata=DailySpendMetadata( @@ -1297,24 +912,22 @@ async def get_daily_activity( total_response_time_ms=metadata_metrics.total_response_time_ms, total_timed_requests=metadata_metrics.timed_requests, page=page, - total_pages=-(-total_count // page_size), # Ceiling division - has_more=(page * page_size) < total_count, + total_pages=-(-page_data.total_count // page_size), + has_more=(page * page_size) < page_data.total_count, ), ) - - except Exception as e: - verbose_proxy_logger.exception("Error fetching daily activity: %s", e) + except Exception as exc: + verbose_proxy_logger.exception("Error fetching daily activity: %s", exc) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Failed to fetch analytics: {e}"}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Failed to fetch analytics: {exc}"} ) def _fold_entity_rollups_sync( *, results: Sequence[DailySpendData], - entity_rows: Sequence[_EntityRollupRow], - api_key_metadata: Mapping[str, _KeyMetadataDict], + entity_rows: Sequence[EntityRollupRow], + api_key_metadata: Mapping[str, KeyMetadataRow], entity_metadata_field: Mapping[str, dict[str, object]] | None, # mutable-ok: shared field shape ) -> None: """Write breakdown.entities onto the already-built per-day results.""" @@ -1345,105 +958,42 @@ def _fold_entity_rollups_sync( async def get_daily_activity_aggregated( - prisma_client: PrismaClient | None, - table_name: str, - entity_id_field: str, - entity_id: str | list[str] | None, - entity_metadata_field: Mapping[str, dict[str, object]] | None, - start_date: str | None, - end_date: str | None, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None = None, - timezone_offset_minutes: int | None = None, + repository: DailyActivityRepository, + scope: DailyActivityScope, + *, + entity_metadata_field: Mapping[str, dict[str, object]] | None = None, include_entity_breakdown: bool = False, - include_current_utc_day: bool = False, + api_key_limit: int = constants.USAGE_TOP_API_KEYS_DEFAULT, ) -> SpendAnalyticsPaginatedResponse: - """Aggregated variant that returns the full result set (no pagination). - - Uses SQL GROUP BY to aggregate rows in the database rather than fetching - all individual rows into Python. This collapses rows across entities - (users/teams/orgs), reducing ~150k rows to ~2-3k grouped rows. - - include_entity_breakdown runs a small companion rollup query and folds - `breakdown.entities` onto the response, as entity-scoped views like Team Usage need. - - Matches the response model of the paginated endpoint so the UI does not need to transform. - """ - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - - if start_date is None or end_date is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "Please provide start_date and end_date"}, - ) - try: - sql_query, sql_params = _build_aggregated_sql_query( - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - include_current_utc_day=include_current_utc_day, + aggregated_rows: Final = await repository.aggregated( + scope, include_entity_breakdown=include_entity_breakdown, api_key_limit=api_key_limit ) - - entity_query: Final = ( - _build_entity_rollup_sql_query( - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - include_current_utc_day=include_current_utc_day, - ) + records: Final = aggregated_rows.grouping_rows + aggregated: Final = await _aggregate_grouping_sets_records( + repository=repository, + records=records, + ) + entity_total_api_keys: Final[dict[str, int] | None] = ( + { + row.entity_id or "Unassigned": row.distinct_api_keys + for row in aggregated_rows.entity_rows or () + if row.api_key_rolled and row.distinct_api_keys is not None + } if include_entity_breakdown else None ) - - # Execute the GROUPING SETS query (one row per rollup level), alongside - # the per-entity companion rollup when the caller wants entities. - raw_rows, raw_entity_rows = ( - await asyncio.gather( - prisma_client.db.query_raw(sql_query, *sql_params), - prisma_client.db.query_raw(entity_query[0], *entity_query[1]), - ) - if entity_query is not None - else (await prisma_client.db.query_raw(sql_query, *sql_params), None) - ) - - records: Final = [_GroupingSetsRow(**row) for row in (raw_rows or [])] - - # The grouping-sets dispatcher places each row directly in its bucket - # using the row's GROUPING() bitmask. No Python-side summing needed. - aggregated: Final = await _aggregate_grouping_sets_records( - prisma_client=prisma_client, - records=records, - ) - - if raw_entity_rows: - entity_records: Final = tuple(_EntityRollupRow(**row) for row in raw_entity_rows) + if aggregated_rows.entity_rows: + entity_records: Final = aggregated_rows.entity_rows entity_api_keys: Final = frozenset( - r.api_key for r in entity_records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY + row.api_key for row in entity_records if row.api_key and row.api_key != PTU_SENTINEL_API_KEY ) - entity_key_metadata: Final = ( - await get_api_key_metadata( - prisma_client, entity_api_keys, _spend_logs_window(frozenset(r.date for r in entity_records)) + entity_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( + await repository.key_metadata( + entity_api_keys, _spend_logs_window(frozenset(row.date for row in entity_records)) ) if entity_api_keys - else {} + else MappingProxyType({}) ) await asyncio.to_thread( _fold_entity_rollups_sync, @@ -1452,7 +1002,6 @@ async def get_daily_activity_aggregated( api_key_metadata=entity_key_metadata, entity_metadata_field=entity_metadata_field, ) - return SpendAnalyticsPaginatedResponse( results=aggregated["results"], metadata=DailySpendMetadata( @@ -1478,12 +1027,13 @@ async def get_daily_activity_aggregated( page=1, total_pages=1, has_more=False, + api_key_limit=api_key_limit, + total_api_keys=aggregated_rows.distinct_api_keys, + entity_total_api_keys=entity_total_api_keys, ), ) - - except Exception as e: - verbose_proxy_logger.exception("Error fetching aggregated daily activity: %s", e) + except Exception as exc: + verbose_proxy_logger.exception("Error fetching aggregated daily activity: %s", exc) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Failed to fetch analytics: {e}"}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Failed to fetch analytics: {exc}"} ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index f6fe58e25b9..1fd63c6d456 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -54,6 +54,8 @@ from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventH from litellm.proxy.management.teams.access import is_team_admin from litellm.proxy.management_endpoints.common_daily_activity import ( DailySpendRecord, + daily_activity_repository, + daily_activity_scope, get_daily_activity, get_daily_activity_aggregated, ) @@ -3079,18 +3081,22 @@ async def get_user_daily_activity_aggregated( ) entity_id = user_id + repository: Final = daily_activity_repository(prisma_client) + scope: Final = daily_activity_scope( + "litellm_dailyuserspend", + "user_id", + entity_id, + None, + api_key, + start_date, + end_date, + model, + timezone, + include_current_utc_day, + ) return await get_daily_activity_aggregated( - prisma_client=prisma_client, - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id=entity_id, - entity_metadata_field=None, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - timezone_offset_minutes=timezone, - include_current_utc_day=include_current_utc_day, + repository, + scope, ) except HTTPException: diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 68f635f7799..040b27b5802 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -125,6 +125,8 @@ from litellm.proxy.hooks.model_max_budget_limiter import ( from litellm.proxy.management.teams.access import TEAM_OR_ORG_ADMIN, TeamRole, is_team_admin, team_access_denied from litellm.proxy.management.teams.dependencies import get_team_access from litellm.proxy.management_endpoints.common_daily_activity import ( + daily_activity_repository, + daily_activity_scope, get_daily_activity_aggregated, ) from litellm.proxy.management_endpoints.common_utils import ( @@ -6761,18 +6763,22 @@ async def get_team_daily_activity_aggregated( proxy_logging_obj=proxy_logging_obj, ) + repository: Final = daily_activity_repository(prisma_client) + activity_scope: Final = daily_activity_scope( + "litellm_dailyteamspend", + "team_id", + scope.team_ids, + scope.exclude_team_ids, + scope.api_key_filter, + start_date, + end_date, + model, + timezone, + ) return await get_daily_activity_aggregated( - prisma_client=prisma_client, - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=scope.team_ids, + repository, + activity_scope, entity_metadata_field=scope.team_alias_metadata, - start_date=start_date, - end_date=end_date, - model=model, - api_key=scope.api_key_filter, - exclude_entity_ids=scope.exclude_team_ids, - timezone_offset_minutes=timezone, include_entity_breakdown=True, ) diff --git a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py index 1265da99d89..3c2300f14fd 100644 --- a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py +++ b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py @@ -8,11 +8,13 @@ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, from datetime import date from typing import Final, Literal, NamedTuple, Protocol, cast, overload +from fastapi import HTTPException from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_COMPETITOR_DISCOVERY_MODEL +from litellm.proxy._types import CommonProxyErrors from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) @@ -259,22 +261,34 @@ async def _query_activity( ) -> SpendAnalyticsPaginatedResponse: """Shared helper that calls the daily activity query layer.""" from litellm.proxy.management_endpoints.common_daily_activity import ( + daily_activity_repository, + daily_activity_scope, get_daily_activity, get_daily_activity_aggregated, ) from litellm.proxy.proxy_server import prisma_client if use_aggregated: + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + repository: Final = daily_activity_repository(prisma_client) + scope: Final = daily_activity_scope( + table_name, + entity_id_field, + entity_id, + None, + None, + start_date, + end_date, + None, + None, + ) return await get_daily_activity_aggregated( - prisma_client=prisma_client, - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - entity_metadata_field=None, - start_date=start_date, - end_date=end_date, - model=None, - api_key=None, + repository, + scope, ) return await get_daily_activity( prisma_client=prisma_client, diff --git a/litellm/repositories/daily_activity_repository.py b/litellm/repositories/daily_activity_repository.py new file mode 100644 index 00000000000..e9d8c3bd309 --- /dev/null +++ b/litellm/repositories/daily_activity_repository.py @@ -0,0 +1,304 @@ +import asyncio +from collections.abc import AsyncIterator, Mapping, Sequence +from datetime import datetime +from itertools import groupby +from types import MappingProxyType +from typing import Final, Protocol + +from pydantic import StrictStr, TypeAdapter, ValidationError +from typing_extensions import assert_never + +from litellm import constants +from litellm._logging import verbose_proxy_logger +from litellm.repositories.chunked_in import find_many_in +from litellm.repositories.daily_activity_sql import ( + ExportCursor, + SqlQuery, + adjust_dates_for_timezone, + build_aggregated_sql, + build_cache_leakage_keys_sql, + build_entity_rollup_sql, + build_export_sql, + build_key_page_sql, + build_key_search_sql, + build_model_top_keys_sql, +) +from litellm.repositories.prisma_protocols import TableActions +from litellm.types.repositories.daily_activity import ( + AggregatedRows, + DailyActivityProxyReads, + DailyActivityRow, + DailyActivityScope, + DailyActivityTable, + DailyRowsPage, + EntityRollupRow, + ExportRow, + ExportType, + GroupingSetsRow, + KeyMetadataRow, + KeyPage, + KeySpendRow, + SpendLogsWindow, +) + + +class _VerificationTokenRow(Protocol): + token: str + key_alias: str | None + team_id: str | None + user_id: str | None + metadata: Mapping[str, object] | None + + +class _DeletedVerificationTokenRow(_VerificationTokenRow, Protocol): + deleted_at: datetime + + +class _QueryRaw(Protocol): + async def __call__(self, query: str, *values: object) -> Sequence[Mapping[str, object]] | None: ... + + +class _DailyActivityDatabase(Protocol): + query_raw: _QueryRaw + litellm_verificationtoken: TableActions[_VerificationTokenRow] + litellm_deletedverificationtoken: TableActions[_DeletedVerificationTokenRow] + + @property + def litellm_dailyuserspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyteamspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailytagspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyorganizationspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyenduserspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyagentspend(self) -> TableActions[DailyActivityRow]: ... + + +class DailyActivityDatabase(Protocol): + @property + def db(self) -> _DailyActivityDatabase: ... + + +_GROUPING_ADAPTER: Final = TypeAdapter(tuple[GroupingSetsRow, ...]) +_ENTITY_ADAPTER: Final = TypeAdapter(tuple[EntityRollupRow, ...]) +_KEY_SPEND_ADAPTER: Final = TypeAdapter(tuple[KeySpendRow, ...]) +_KEY_PAGE_TOTAL_ADAPTER: Final[TypeAdapter[int]] = TypeAdapter(int) +_EXPORT_ADAPTER: Final = TypeAdapter(tuple[ExportRow, ...]) +_METADATA_TAGS_ADAPTER: Final = TypeAdapter(list[StrictStr]) + + +def _metadata_tags(value: object) -> tuple[str, ...]: + stable_value: Final = value + if not isinstance(value, list): + return () + try: + return tuple(_METADATA_TAGS_ADAPTER.validate_python(stable_value)) + except ValidationError: + return () + + +def _daily_rows_table( + prisma_client: DailyActivityDatabase, table: DailyActivityTable +) -> TableActions[DailyActivityRow]: + if table is DailyActivityTable.USER: + return prisma_client.db.litellm_dailyuserspend + if table is DailyActivityTable.TEAM: + return prisma_client.db.litellm_dailyteamspend + if table is DailyActivityTable.TAG: + return prisma_client.db.litellm_dailytagspend + if table is DailyActivityTable.ORGANIZATION: + return prisma_client.db.litellm_dailyorganizationspend + if table is DailyActivityTable.CUSTOMER: + return prisma_client.db.litellm_dailyenduserspend + if table is DailyActivityTable.AGENT: + return prisma_client.db.litellm_dailyagentspend + assert_never(table) + raise AssertionError("unreachable") + + +def _next_export_cursor(batch: tuple[ExportRow, ...], export_type: ExportType) -> ExportCursor: + last: Final = batch[-1] + cursor_key: Final = ( + last.api_key + if export_type is ExportType.DAILY_WITH_KEYS + else last.model + if export_type is ExportType.DAILY_WITH_MODELS + else last.user_id + if export_type is ExportType.DAILY_WITH_USERS + else "" + ) + return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=cursor_key or "") + + +class DailyActivityRepository: + def __init__(self, prisma_client: DailyActivityDatabase, *, proxy_reads: DailyActivityProxyReads) -> None: + self._prisma_client = prisma_client + self._proxy_reads = proxy_reads + + async def _query(self, query: SqlQuery) -> tuple[Mapping[str, object], ...]: + first_line: Final = query.sql.lstrip().splitlines()[0].lstrip("(").strip() + verbose_proxy_logger.debug("DailyActivityRepository query: %s", first_line) + result: Sequence[Mapping[str, object]] | None = await self._prisma_client.db.query_raw(query.sql, *query.params) + if result is None: + return () + return tuple(result) + + async def aggregated( + self, scope: DailyActivityScope, *, include_entity_breakdown: bool, api_key_limit: int + ) -> AggregatedRows: + grouping_query: Final = build_aggregated_sql(scope, api_key_limit=api_key_limit) + entity_query: Final = ( + build_entity_rollup_sql(scope, api_key_limit=api_key_limit) if include_entity_breakdown else None + ) + grouping_result, entity_result = await asyncio.gather( + self._query(grouping_query), + self._query(entity_query) if entity_query is not None else asyncio.sleep(0, result=None), + ) + grouping_rows: Final = _GROUPING_ADAPTER.validate_python(grouping_result) + entity_rows: Final = None if entity_result is None else _ENTITY_ADAPTER.validate_python(entity_result) + distinct_api_keys: Final = next( + (row.distinct_api_keys for row in grouping_rows if row.distinct_api_keys is not None), 0 + ) + return AggregatedRows( + grouping_rows=grouping_rows, + entity_rows=entity_rows, + distinct_api_keys=distinct_api_keys, + ) + + async def search_keys(self, scope: DailyActivityScope, *, search: str, limit: int) -> tuple[str, ...]: + if not 1 <= limit <= constants.USAGE_KEY_SEARCH_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_KEY_SEARCH_MAX}") + query: Final = build_key_search_sql(scope, search=search, limit=limit) + rows: Final = _KEY_SPEND_ADAPTER.validate_python(await self._query(query)) + return tuple(row.api_key for row in rows) + + async def key_page(self, scope: DailyActivityScope, *, offset: int, limit: int) -> KeyPage: + query: Final = build_key_page_sql(scope, offset=offset, limit=limit) + result: Final = await self._query(query) + total_api_keys_value: Final = result[0].get("total_api_keys") if result else 0 + total_api_keys: Final = ( + _KEY_PAGE_TOTAL_ADAPTER.validate_python(total_api_keys_value) if total_api_keys_value is not None else 0 + ) + rows: Final = _KEY_SPEND_ADAPTER.validate_python(tuple(row for row in result if row.get("api_key") is not None)) + return KeyPage(rows=rows, total_api_keys=total_api_keys) + + async def model_top_keys( + self, scope: DailyActivityScope, *, model_group: str, by_model_group: bool, limit: int + ) -> tuple[KeySpendRow, ...]: + if not 1 <= limit <= constants.USAGE_MODEL_TOP_KEYS_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_MODEL_TOP_KEYS_MAX}") + query: Final = build_model_top_keys_sql( + scope, + model_group=model_group, + by_model_group=by_model_group, + limit=limit, + ) + return _KEY_SPEND_ADAPTER.validate_python(await self._query(query)) + + async def cache_leakage_keys(self, scope: DailyActivityScope, *, limit: int) -> tuple[KeySpendRow, ...]: + if not 1 <= limit <= constants.USAGE_CACHE_LEAKAGE_KEYS_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_CACHE_LEAKAGE_KEYS_MAX}") + query: Final = build_cache_leakage_keys_sql(scope, limit=limit) + return _KEY_SPEND_ADAPTER.validate_python(await self._query(query)) + + async def export_rows(self, scope: DailyActivityScope, *, export_type: ExportType) -> AsyncIterator[ExportRow]: + batch_size: Final = constants.USAGE_EXPORT_BATCH_SIZE + cursor: ExportCursor | None = None # rebind-ok: each page advances the export keyset cursor + while True: + batch: tuple[ExportRow, ...] = _EXPORT_ADAPTER.validate_python( + await self._query(build_export_sql(scope, export_type=export_type, after=cursor, batch_size=batch_size)) + ) + for row in batch: + yield row + if len(batch) < batch_size: + return + cursor = _next_export_cursor(batch, export_type) + + async def _active_token_rows(self, values: tuple[str, ...]) -> tuple[_VerificationTokenRow, ...]: + return await find_many_in(self._prisma_client.db.litellm_verificationtoken, "token", values) + + async def _deleted_token_rows(self, values: tuple[str, ...]) -> tuple[_DeletedVerificationTokenRow, ...]: + try: + return await find_many_in(self._prisma_client.db.litellm_deletedverificationtoken, "token", values) + except Exception as exc: + verbose_proxy_logger.warning("Could not read deleted verification token metadata: %s", exc) + return () + + async def key_metadata( + self, api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: + if not api_keys: + return {} + values: Final = tuple(api_keys) + active_rows: Final = await self._active_token_rows(values) + active: Final = MappingProxyType({row.token: self._metadata_row(row, key_exists=True) for row in active_rows}) + missing: Final = tuple(key for key in values if key not in active) + deleted_rows: Final = await self._deleted_token_rows(missing) + deleted_by_token: Final = MappingProxyType( + { + token: max(rows, key=lambda row: row.deleted_at) + for token, rows in groupby( + sorted(deleted_rows, key=lambda row: row.token), + key=lambda row: row.token, + ) + } + ) + deleted: Final = MappingProxyType( + { + key: self._metadata_row(deleted_by_token[key], key_exists=False) + for key in missing + if key in deleted_by_token + } + ) + resolved: Final = MappingProxyType({**deleted, **active}) + return await self._proxy_reads.recover_key_metadata(resolved, api_keys, window) + + @staticmethod + def _metadata_row(row: _VerificationTokenRow, *, key_exists: bool) -> KeyMetadataRow: + tags: Final = _metadata_tags(row.metadata.get("tags") if row.metadata is not None else None) + return KeyMetadataRow( + api_key=row.token, + key_alias=row.key_alias, + team_id=row.team_id, + user_id=row.user_id, + user_email=None, + key_exists=key_exists, + tags=tags, + ) + + async def daily_rows(self, scope: DailyActivityScope, *, page: int, page_size: int) -> DailyRowsPage: + table: Final = _daily_rows_table(self._prisma_client, scope.table) + adjusted_start, adjusted_end = adjust_dates_for_timezone( + scope.start_date, + scope.end_date, + scope.timezone_offset_minutes, + include_current_utc_day=scope.include_current_utc_day, + ) + entity_filter: Final = { + **({"in": list(scope.entity_ids)} if scope.entity_ids is not None else {}), + **({"not": {"in": list(scope.exclude_entity_ids)}} if scope.exclude_entity_ids else {}), + } + conditions: Final = { + "date": {"gte": adjusted_start, "lte": adjusted_end}, + **({scope.entity_id_field: entity_filter} if entity_filter else {}), + **({"model": scope.model} if scope.model else {}), + **({"api_key": {"in": list(scope.api_keys)}} if scope.api_keys is not None else {}), + } + count, rows = await asyncio.gather( + table.count(where=conditions), + table.find_many( + where=conditions, + skip=(page - 1) * page_size, + take=page_size, + order=({"date": "desc"}, {"id": "asc"}), + ), + ) + return DailyRowsPage(total_count=count, rows=tuple(rows)) diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py new file mode 100644 index 00000000000..96920b55eb0 --- /dev/null +++ b/litellm/repositories/daily_activity_sql.py @@ -0,0 +1,519 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from itertools import count, islice +from types import MappingProxyType +from typing import Final + +from typing_extensions import assert_never + +from litellm import constants +from litellm.constants import PTU_SENTINEL_API_KEY +from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable, ExportType + +_API_KEY_ROLLED_UP_BIT: Final = 32 +_MODEL_GROUP_EXPR: Final = "COALESCE(NULLIF(model_group, ''), model)" + + +@dataclass(frozen=True, slots=True) +class SqlQuery: + sql: str + params: tuple[object, ...] + + +@dataclass(frozen=True, slots=True) +class ExportCursor: + date: str + entity_id: str + group_key: str + + +PRISMA_TO_PG_TABLE: Final[Mapping[DailyActivityTable, str]] = MappingProxyType( + { + DailyActivityTable.USER: "LiteLLM_DailyUserSpend", + DailyActivityTable.TEAM: "LiteLLM_DailyTeamSpend", + DailyActivityTable.TAG: "LiteLLM_DailyTagSpend", + DailyActivityTable.ORGANIZATION: "LiteLLM_DailyOrganizationSpend", + DailyActivityTable.CUSTOMER: "LiteLLM_DailyEndUserSpend", + DailyActivityTable.AGENT: "LiteLLM_DailyAgentSpend", + } +) + + +def adjust_dates_for_timezone( + start_date: str, + end_date: str, + timezone_offset_minutes: int | None, + include_current_utc_day: bool = False, + utc_now: datetime | None = None, +) -> tuple[str, str]: + if not include_current_utc_day or timezone_offset_minutes is None: + return start_date, end_date + now: Final = utc_now if utc_now is not None else datetime.now(timezone.utc) + caller_local_today: Final = (now - timedelta(minutes=timezone_offset_minutes)).date().isoformat() + if end_date < caller_local_today: + return start_date, end_date + return start_date, max(end_date, now.date().isoformat()) + + +def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tuple[str, tuple[object, ...]]: + adjusted_start, adjusted_end = adjust_dates_for_timezone( + scope.start_date, + scope.end_date, + scope.timezone_offset_minutes, + scope.include_current_utc_day, + ) + entity_index: Final = start_index + 2 + has_entity_array: Final = scope.entity_ids is not None and bool(scope.entity_ids) + exclusion_index: Final = entity_index + int(has_entity_array) + model_index: Final = exclusion_index + int(bool(scope.exclude_entity_ids)) + api_keys_index: Final = model_index + int(bool(scope.model)) + conditions: Final = ( + f"date >= ${start_index}", + f"date <= ${start_index + 1}", + *( + ("FALSE",) + if scope.entity_ids == () + else (f'"{scope.entity_id_field}" = ANY(${entity_index}::text[])',) + if has_entity_array + else () + ), + *((f'NOT ("{scope.entity_id_field}" = ANY(${exclusion_index}::text[]))',) if scope.exclude_entity_ids else ()), + *((f"model = ${model_index}",) if scope.model else ()), + *( + ("FALSE",) + if scope.api_keys == () + else (f"api_key = ANY(${api_keys_index}::text[])",) + if scope.api_keys + else () + ), + ) + params: Final = ( + adjusted_start, + adjusted_end, + *((list(scope.entity_ids or ()),) if has_entity_array else ()), + *((list(scope.exclude_entity_ids),) if scope.exclude_entity_ids else ()), + *((scope.model,) if scope.model else ()), + *((list(scope.api_keys),) if scope.api_keys else ()), + ) + return " AND ".join(conditions), params + + +def _ptu_flat_cost_select(table: DailyActivityTable, *, aggregate: bool = True) -> str: + if table is DailyActivityTable.TEAM: + return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" if aggregate else "SUM(scoped.ptu_flat_cost)::float" + return "0::float AS ptu_flat_cost" if aggregate else "0::float" + + +def _rollup_metric_select(table: DailyActivityTable) -> str: + return f""" + SUM(spend)::float AS spend, + {_ptu_flat_cost_select(table)}, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, + SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, + SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, + SUM(compression_savings_spend)::float AS compression_savings_spend, + SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, + SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, + SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, + SUM(api_requests)::bigint AS api_requests, + SUM(successful_requests)::bigint AS successful_requests, + SUM(failed_requests)::bigint AS failed_requests, + SUM(total_response_time_ms)::bigint AS total_response_time_ms, + SUM(timed_requests)::bigint AS timed_requests""" + + +def _validate_api_key_limit(api_key_limit: int) -> None: + if not 1 <= api_key_limit <= constants.USAGE_TOP_API_KEYS_MAX: + raise ValueError(f"api_key_limit must be between 1 and {constants.USAGE_TOP_API_KEYS_MAX}") + + +def _top_api_keys_sql(pg_table: str, where_clause: str, *, sentinel_param: int, limit_param: int) -> str: + return f""" + SELECT api_key, COUNT(*) OVER () AS distinct_api_keys + FROM "{pg_table}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + LIMIT ${limit_param} + """ + + +def build_aggregated_sql(scope: DailyActivityScope, *, api_key_limit: int) -> SqlQuery: + pg_table: Final = PRISMA_TO_PG_TABLE[scope.table] + where_clause, where_params = build_where_clause(scope) + _validate_api_key_limit(api_key_limit) + sentinel_param: Final = len(where_params) + 1 + top_keys_limit_param: Final = len(where_params) + 2 + top_api_keys: Final = _top_api_keys_sql( + pg_table, where_clause, sentinel_param=sentinel_param, limit_param=top_keys_limit_param + ) + metric_select: Final = _rollup_metric_select(scope.table) + sql: Final = f""" + (SELECT + date, + NULL::text AS api_key, + model, + {_MODEL_GROUP_EXPR} AS model_group, + custom_llm_provider, + mcp_namespaced_tool_name, + endpoint, + (GROUPING(date) << 6) | {_API_KEY_ROLLED_UP_BIT} + | GROUPING(model, {_MODEL_GROUP_EXPR}, + custom_llm_provider, mcp_namespaced_tool_name, + endpoint) AS group_level, + NULL::bigint AS distinct_api_keys,{metric_select} + FROM "{pg_table}" + WHERE {where_clause} + GROUP BY GROUPING SETS ( + (date), + (date, model), + (date, {_MODEL_GROUP_EXPR}), + (date, custom_llm_provider), + (date, mcp_namespaced_tool_name), + (date, endpoint), + () + )) + UNION ALL + (WITH top_api_keys AS ( + {top_api_keys} + ) + SELECT + date, + api_key, + model, + {_MODEL_GROUP_EXPR} AS model_group, + custom_llm_provider, + mcp_namespaced_tool_name, + endpoint, + GROUPING(date, api_key, model, {_MODEL_GROUP_EXPR}, + custom_llm_provider, mcp_namespaced_tool_name, + endpoint) AS group_level, + MAX(top_api_keys.distinct_api_keys) AS distinct_api_keys,{metric_select} + FROM "{pg_table}" JOIN top_api_keys USING (api_key) + WHERE {where_clause} + GROUP BY GROUPING SETS ( + (date, api_key), + (date, model, api_key), + (date, {_MODEL_GROUP_EXPR}, api_key), + (date, custom_llm_provider, api_key), + (date, mcp_namespaced_tool_name, api_key), + (date, endpoint, api_key) + )) + """ + return SqlQuery( + sql=sql, + params=(*where_params, PTU_SENTINEL_API_KEY, api_key_limit), + ) + + +def build_entity_rollup_sql(scope: DailyActivityScope, *, api_key_limit: int) -> SqlQuery: + pg_table: Final = PRISMA_TO_PG_TABLE[scope.table] + where_clause, where_params = build_where_clause(scope) + _validate_api_key_limit(api_key_limit) + sentinel_param: Final = len(where_params) + 1 + top_keys_limit_param: Final = len(where_params) + 2 + top_api_keys: Final = _top_api_keys_sql( + pg_table, where_clause, sentinel_param=sentinel_param, limit_param=top_keys_limit_param + ) + metric_select: Final = _rollup_metric_select(scope.table) + sql: Final = f""" + WITH top_api_keys AS ( + {top_api_keys} + ), + entity_api_keys AS ( + SELECT COALESCE("{scope.entity_id_field}", '') AS entity_id, + COUNT(DISTINCT api_key)::bigint AS distinct_api_keys + FROM "{pg_table}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY COALESCE("{scope.entity_id_field}", '') + ) + (SELECT e.*, COALESCE(k.distinct_api_keys, 0)::bigint AS distinct_api_keys + FROM ( + SELECT COALESCE("{scope.entity_id_field}", '') AS entity_id, + date, + NULL::text AS api_key, + 1 AS api_key_rolled,{metric_select} + FROM "{pg_table}" + WHERE {where_clause} + GROUP BY date, COALESCE("{scope.entity_id_field}", '') + ) e + LEFT JOIN entity_api_keys k ON k.entity_id = e.entity_id) + UNION ALL + (SELECT COALESCE("{scope.entity_id_field}", '') AS entity_id, + date, + api_key, + 0 AS api_key_rolled,{metric_select}, + NULL::bigint AS distinct_api_keys + FROM "{pg_table}" JOIN top_api_keys USING (api_key) + WHERE {where_clause} + GROUP BY date, COALESCE("{scope.entity_id_field}", ''), api_key) + """ + return SqlQuery(sql=sql, params=(*where_params, PTU_SENTINEL_API_KEY, api_key_limit)) + + +def _key_spend_select() -> str: + return """ + COALESCE(SUM(spend), 0)::float AS spend, + COALESCE(SUM(prompt_tokens), 0)::bigint AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0)::bigint AS completion_tokens, + (COALESCE(SUM(prompt_tokens), 0) + COALESCE(SUM(completion_tokens), 0))::bigint AS total_tokens, + COALESCE(SUM(api_requests), 0)::bigint AS api_requests, + COALESCE(SUM(successful_requests), 0)::bigint AS successful_requests, + COALESCE(SUM(failed_requests), 0)::bigint AS failed_requests, + COALESCE(SUM(cache_read_input_tokens), 0)::bigint AS cache_read_input_tokens, + COALESCE(SUM(cache_creation_input_tokens), 0)::bigint AS cache_creation_input_tokens""" + + +def build_key_page_sql(scope: DailyActivityScope, *, offset: int, limit: int) -> SqlQuery: + if not 1 <= limit <= constants.USAGE_KEY_PAGE_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_KEY_PAGE_MAX}") + if offset < 0: + raise ValueError("offset must be non-negative") + where_clause, where_params = build_where_clause(scope) + sentinel_param: Final = len(where_params) + 1 + limit_param: Final = sentinel_param + 1 + offset_param: Final = limit_param + 1 + sql: Final = f""" + WITH ranked AS ( + SELECT api_key,{_key_spend_select()}, SUM(spend::numeric) AS rank_spend + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + ) + SELECT (SELECT COUNT(*) FROM ranked)::bigint AS total_api_keys, page.* + FROM (SELECT 1) AS one + LEFT JOIN LATERAL ( + SELECT * FROM ranked + ORDER BY rank_spend DESC, api_key + LIMIT ${limit_param} OFFSET ${offset_param} + ) AS page ON TRUE + """ + return SqlQuery(sql=sql, params=(*where_params, PTU_SENTINEL_API_KEY, limit, offset)) + + +def _bounded_limit(limit: int, *, minimum: int = 1) -> None: + if limit < minimum: + raise ValueError(f"limit must be at least {minimum}") + + +def build_key_search_sql(scope: DailyActivityScope, *, search: str, limit: int) -> SqlQuery: + _bounded_limit(limit) + where_clause, where_params = build_where_clause(scope) + search_param: Final = len(where_params) + 1 + sentinel_param: Final = search_param + 1 + limit_param: Final = sentinel_param + 1 + escaped: Final = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + sql: Final = f""" + SELECT api_key,{_key_spend_select()} + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} + AND api_key <> ${sentinel_param} + AND ( + api_key ILIKE ${search_param} ESCAPE '\\' + OR api_key IN ( + SELECT v.token FROM "LiteLLM_VerificationToken" v + LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = v.user_id + WHERE v.key_alias ILIKE ${search_param} ESCAPE '\\' + OR v.user_id ILIKE ${search_param} ESCAPE '\\' + OR u.user_email ILIKE ${search_param} ESCAPE '\\' + UNION + SELECT d.token FROM "LiteLLM_DeletedVerificationToken" d + LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = d.user_id + WHERE d.key_alias ILIKE ${search_param} ESCAPE '\\' + OR d.user_id ILIKE ${search_param} ESCAPE '\\' + OR u.user_email ILIKE ${search_param} ESCAPE '\\' + ) + ) + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + LIMIT ${limit_param} + """ + return SqlQuery(sql=sql, params=(*where_params, f"%{escaped}%", PTU_SENTINEL_API_KEY, limit)) + + +def build_model_top_keys_sql( + scope: DailyActivityScope, *, model_group: str, by_model_group: bool, limit: int +) -> SqlQuery: + _bounded_limit(limit) + where_clause, where_params = build_where_clause(scope) + model_param: Final = len(where_params) + 1 + sentinel_param: Final = model_param + 1 + limit_param: Final = sentinel_param + 1 + model_clause: Final = ( + f"COALESCE(NULLIF(model_group, ''), model) = ${model_param}" if by_model_group else f"model = ${model_param}" + ) + sql: Final = f""" + SELECT api_key,{_key_spend_select()} + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} AND {model_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + LIMIT ${limit_param} + """ + return SqlQuery(sql=sql, params=(*where_params, model_group, PTU_SENTINEL_API_KEY, limit)) + + +def build_cache_leakage_keys_sql(scope: DailyActivityScope, *, limit: int) -> SqlQuery: + _bounded_limit(limit) + where_clause, where_params = build_where_clause(scope) + sentinel_param: Final = len(where_params) + 1 + limit_param: Final = sentinel_param + 1 + sql: Final = f""" + SELECT api_key,{_key_spend_select()} + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + HAVING SUM(prompt_tokens) - SUM(cache_read_input_tokens) > 0 + ORDER BY SUM(prompt_tokens) - SUM(cache_read_input_tokens) DESC, api_key + LIMIT ${limit_param} + """ + return SqlQuery(sql=sql, params=(*where_params, PTU_SENTINEL_API_KEY, limit)) + + +def build_export_sql( + scope: DailyActivityScope, *, export_type: ExportType, after: ExportCursor | None, batch_size: int +) -> SqlQuery: + _bounded_limit(batch_size) + where_clause, where_params = build_where_clause(scope) + group_key, output_key, user_fields, type_joins = _export_grouping(export_type) + grouping_keys: Final = ( + f"scoped.date, COALESCE(scoped.\"{scope.entity_id_field}\", '')", + *((group_key,) if export_type is not ExportType.DAILY else ()), + ) + entity_joins: Final = ( + ('LEFT JOIN "LiteLLM_TeamTable" tt ON tt.team_id = scoped.team_id',) + if scope.table is DailyActivityTable.TEAM + else ('LEFT JOIN "LiteLLM_OrganizationTable" ot ON ot.organization_id = scoped.organization_id',) + if scope.table is DailyActivityTable.ORGANIZATION + else () + ) + joins: Final = (*type_joins, *entity_joins) + alias_expression: Final = ( + "MAX(tt.team_alias)" + if scope.table is DailyActivityTable.TEAM + else "MAX(ot.organization_alias)" + if scope.table is DailyActivityTable.ORGANIZATION + else "NULL::text" + ) + parameter_indexes: Final = count(len(where_params) + 1) + sentinel_param: Final = next(parameter_indexes) if export_type is not ExportType.DAILY else None + cursor_indexes: Final = tuple(islice(parameter_indexes, 3)) if after is not None else () + limit_param: Final = next(parameter_indexes) + cursor_clause, cursor_params = _export_cursor_clause( + scope, after=after, cursor_indexes=cursor_indexes, group_key=group_key + ) + sentinel_clause: Final = f" AND api_key <> ${sentinel_param}" if sentinel_param is not None else "" + table: Final = PRISMA_TO_PG_TABLE[scope.table] + flat_cost: Final = _ptu_flat_cost_select(scope.table, aggregate=False) + sql: Final = f""" + WITH scoped AS ( + SELECT * FROM "{table}" + WHERE {where_clause}{sentinel_clause} + ) + SELECT + scoped.date, + COALESCE(scoped."{scope.entity_id_field}", '') AS entity_id, + {alias_expression} AS entity_alias, + {output_key} AS api_key, + {user_fields}, + {"NULLIF(COALESCE(scoped.model, ''), '')" if export_type is ExportType.DAILY_WITH_MODELS else "NULL::text"} AS model, + COALESCE(SUM(scoped.spend), 0)::float AS spend, + {flat_cost} AS flat_cost, + COALESCE(SUM(scoped.prompt_tokens), 0)::bigint AS prompt_tokens, + COALESCE(SUM(scoped.completion_tokens), 0)::bigint AS completion_tokens, + COALESCE(SUM(scoped.api_requests), 0)::bigint AS api_requests, + COALESCE(SUM(scoped.successful_requests), 0)::bigint AS successful_requests, + COALESCE(SUM(scoped.failed_requests), 0)::bigint AS failed_requests, + COALESCE(SUM(scoped.cache_read_input_tokens), 0)::bigint AS cache_read_input_tokens, + COALESCE(SUM(scoped.cache_creation_input_tokens), 0)::bigint AS cache_creation_input_tokens + FROM scoped + {" ".join(joins)} + WHERE TRUE{cursor_clause} + GROUP BY {", ".join(grouping_keys)} + ORDER BY {", ".join(grouping_keys)} + LIMIT ${limit_param} + """ + return SqlQuery( + sql=sql, + params=( + *where_params, + *((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()), + *cursor_params, + batch_size, + ), + ) + + +def _export_grouping(export_type: ExportType) -> tuple[str, str, str, tuple[str, ...]]: + if export_type is ExportType.DAILY: + return ( + "''", + "NULL::text", + "NULL::text AS key_alias, NULL::text AS user_id, NULL::text AS user_email", + (), + ) + if export_type is ExportType.DAILY_WITH_KEYS: + return ( + "scoped.api_key", + "NULLIF(scoped.api_key, '')", + "MAX(COALESCE(vt.key_alias, dvt.key_alias)) AS key_alias, " + "MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, MAX(u.user_email) AS user_email", + ( + 'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key', + """LEFT JOIN LATERAL ( + SELECT key_alias, user_id + FROM "LiteLLM_DeletedVerificationToken" + WHERE token = scoped.api_key + ORDER BY deleted_at DESC + LIMIT 1 + ) dvt ON vt.token IS NULL""", + 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)', + ), + ) + if export_type is ExportType.DAILY_WITH_MODELS: + return ( + "COALESCE(scoped.model, '')", + "NULL::text", + "NULL::text AS key_alias, NULL::text AS user_id, NULL::text AS user_email", + (), + ) + if export_type is ExportType.DAILY_WITH_USERS: + return ( + "COALESCE(vt.user_id, dvt.user_id, '')", + "NULL::text", + "NULL::text AS key_alias, MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, " + "MAX(u.user_email) AS user_email", + ( + 'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key', + """LEFT JOIN LATERAL ( + SELECT key_alias, user_id + FROM "LiteLLM_DeletedVerificationToken" + WHERE token = scoped.api_key + ORDER BY deleted_at DESC + LIMIT 1 + ) dvt ON vt.token IS NULL""", + 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)', + ), + ) + assert_never(export_type) + raise AssertionError("unreachable") + + +def _export_cursor_clause( + scope: DailyActivityScope, + *, + after: ExportCursor | None, + cursor_indexes: tuple[int, ...], + group_key: str, +) -> tuple[str, tuple[object, ...]]: + if after is None: + return "", () + first_cursor_index: Final = cursor_indexes[0] + clause: Final = ( + f""" AND (scoped.date, COALESCE(scoped."{scope.entity_id_field}", ''), {group_key}) """ + f"> (${first_cursor_index}, ${cursor_indexes[1]}, ${cursor_indexes[2]})" + ) + return clause, (after.date, after.entity_id, after.group_key) diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 28488ba7de6..5f997945ec2 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -101,6 +101,22 @@ class DailySpendMetadata(BaseModel): page: int = Field(default=1) total_pages: int = Field(default=1) has_more: bool = Field(default=False) + api_key_limit: int | None = Field( + default=None, + description="When set, api_keys and every api_key_breakdown list at most this many keys, " + "ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.", + ) + total_api_keys: int | None = Field( + default=None, + description="Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key " + "lists are truncated to the highest-spend keys.", + ) + entity_total_api_keys: dict[str, int] | None = Field( + default=None, + description="Distinct API keys per entity over the requested range, set when the entity breakdown is " + "included. When an entity's count exceeds api_key_limit, its api_key_breakdown lists only its keys " + "among the top api_key_limit keys overall.", + ) class SpendAnalyticsPaginatedResponse(BaseModel): diff --git a/litellm/types/repositories/__init__.py b/litellm/types/repositories/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/types/repositories/daily_activity.py b/litellm/types/repositories/daily_activity.py new file mode 100644 index 00000000000..df302234398 --- /dev/null +++ b/litellm/types/repositories/daily_activity.py @@ -0,0 +1,192 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from types import MappingProxyType +from typing import Protocol, TypeAlias + + +class DailyActivityTable(str, Enum): + USER = "litellm_dailyuserspend" + TEAM = "litellm_dailyteamspend" + TAG = "litellm_dailytagspend" + ORGANIZATION = "litellm_dailyorganizationspend" + CUSTOMER = "litellm_dailyenduserspend" + AGENT = "litellm_dailyagentspend" + + +_ENTITY_FIELDS: Mapping[DailyActivityTable, frozenset[str]] = MappingProxyType( + { + DailyActivityTable.USER: frozenset(("user_id",)), + DailyActivityTable.TEAM: frozenset(("team_id",)), + DailyActivityTable.TAG: frozenset(("tag",)), + DailyActivityTable.ORGANIZATION: frozenset(("organization_id",)), + DailyActivityTable.CUSTOMER: frozenset(("end_user_id",)), + DailyActivityTable.AGENT: frozenset(("agent_id",)), + } +) + + +@dataclass(frozen=True, slots=True) +class DailyActivityScope: + table: DailyActivityTable + entity_id_field: str + entity_ids: tuple[str, ...] | None + exclude_entity_ids: tuple[str, ...] + api_keys: tuple[str, ...] | None + start_date: str + end_date: str + model: str | None + timezone_offset_minutes: int | None + include_current_utc_day: bool = False + + def __post_init__(self) -> None: + if self.entity_id_field not in _ENTITY_FIELDS[self.table]: + raise ValueError(f"Invalid entity_id_field {self.entity_id_field!r} for {self.table.value}") + + +@dataclass(frozen=True, slots=True) +class KeySpendRow: + api_key: str + spend: float + prompt_tokens: int + completion_tokens: int + total_tokens: int + api_requests: int + successful_requests: int + failed_requests: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + + +@dataclass(frozen=True, slots=True) +class KeyPage: + rows: tuple[KeySpendRow, ...] + total_api_keys: int + + +@dataclass(frozen=True, slots=True) +class KeyMetadataRow: + api_key: str + key_alias: str | None + team_id: str | None + user_id: str | None + user_email: str | None + key_exists: bool + tags: tuple[str, ...] + + +class ExportType(str, Enum): + DAILY = "daily" + DAILY_WITH_KEYS = "daily_with_keys" + DAILY_WITH_MODELS = "daily_with_models" + DAILY_WITH_USERS = "daily_with_users" + + +@dataclass(frozen=True, slots=True) +class ExportRow: + date: str + entity_id: str + entity_alias: str | None + api_key: str | None + key_alias: str | None + user_id: str | None + user_email: str | None + model: str | None + spend: float + flat_cost: float + prompt_tokens: int + completion_tokens: int + api_requests: int + successful_requests: int + failed_requests: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + + +@dataclass(frozen=True, slots=True) +class RollupMetricsRow: + date: str | None + api_key: str | None + spend: float | None + ptu_flat_cost: float | None = field(default=None, kw_only=True) + prompt_tokens: int | None + completion_tokens: int | None + cache_read_input_tokens: int | None + cache_creation_input_tokens: int | None + compression_saved_tokens: int | None + compression_savings_spend: float | None + prompt_caching_savings_spend: float | None + gateway_injected_caching_savings_spend: float | None + autorouter_savings_spend: float | None + api_requests: int | None + successful_requests: int | None + failed_requests: int | None + total_response_time_ms: int | None + timed_requests: int | None + + +@dataclass(frozen=True, slots=True) +class GroupingSetsRow(RollupMetricsRow): + model: str | None + model_group: str | None + custom_llm_provider: str | None + mcp_namespaced_tool_name: str | None + endpoint: str | None + group_level: int + distinct_api_keys: int | None + + +@dataclass(frozen=True, slots=True) +class EntityRollupRow(RollupMetricsRow): + entity_id: str | None + api_key_rolled: int + distinct_api_keys: int | None + + +@dataclass(frozen=True, slots=True) +class AggregatedRows: + grouping_rows: tuple[GroupingSetsRow, ...] + entity_rows: tuple[EntityRollupRow, ...] | None + distinct_api_keys: int + + +SpendLogsWindow: TypeAlias = tuple[datetime, datetime] + + +class DailyActivityProxyReads(Protocol): + async def recover_key_metadata( + self, resolved: Mapping[str, KeyMetadataRow], api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: ... + + +class DailyActivityRow(Protocol): + id: str + date: str + api_key: str + model: str | None + model_group: str | None + custom_llm_provider: str | None + mcp_namespaced_tool_name: str | None + endpoint: str | None + prompt_tokens: int + completion_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + compression_saved_tokens: int + compression_savings_spend: float + prompt_caching_savings_spend: float + gateway_injected_caching_savings_spend: float + autorouter_savings_spend: float + spend: float + api_requests: int + successful_requests: int + failed_requests: int + total_response_time_ms: int + timed_requests: int + + +@dataclass(frozen=True, slots=True) +class DailyRowsPage: + total_count: int + rows: tuple[DailyActivityRow, ...] diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt index 17e8d32dde0..9acc92e2afd 100644 --- a/tests/code_coverage_tests/unbounded_in_baseline.txt +++ b/tests/code_coverage_tests/unbounded_in_baseline.txt @@ -39,14 +39,6 @@ litellm/proxy/management_endpoints/auto_router_endpoints.py start_shadow_eval pr litellm/proxy/management_endpoints/auto_router_endpoints.py start_shadow_eval prisma token.in `list(data.api_key_ids)` 0 litellm/proxy/management_endpoints/auto_router_endpoints.py start_shadow_eval prisma user_id.in `list(data.user_ids)` 0 litellm/proxy/management_endpoints/budget_management_endpoints.py info_budget prisma budget_id.in `data.budgets` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_aggregated_where_clause raw-sql api_key.IN `IN ({placeholders})` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_aggregated_where_clause raw-sql {entity_id_field}.IN `IN ({placeholders})` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_aggregated_where_clause raw-sql {entity_id_field}.IN `IN ({placeholders})` 1 -litellm/proxy/management_endpoints/common_daily_activity.py _build_where_conditions prisma [entity_id_field].in `entity_id` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_where_conditions prisma api_key.in `api_key` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_where_conditions prisma not.in `exclude_entity_ids` 0 -litellm/proxy/management_endpoints/common_daily_activity.py get_api_key_metadata prisma token.in `list(api_keys)` 0 -litellm/proxy/management_endpoints/common_daily_activity.py get_api_key_metadata prisma token.in `list(missing_keys)` 0 litellm/proxy/management_endpoints/common_utils.py _team_admin_can_invite_user prisma team_id.in `admin_user_obj.teams` 0 litellm/proxy/management_endpoints/common_utils.py _user_has_admin_privileges prisma team_id.in `user_obj.teams` 0 litellm/proxy/management_endpoints/customer_endpoints.py delete_end_user prisma user_id.in `data.user_ids` 0 @@ -149,4 +141,7 @@ litellm/proxy/utils.py PrismaClient.get_data prisma budget_id.in `budget_id_list litellm/proxy/utils.py PrismaClient.get_data prisma team_id.in `team_id_list` 0 litellm/proxy/utils.py PrismaClient.get_data prisma user_id.in `user_id_list` 0 litellm/proxy/utils.py prefetch_config_params prisma param_name.in `param_names` 0 +litellm/repositories/daily_activity_repository.py DailyActivityRepository.daily_rows prisma ?.in `list(scope.entity_ids)` 0 +litellm/repositories/daily_activity_repository.py DailyActivityRepository.daily_rows prisma api_key.in `list(scope.api_keys)` 0 +litellm/repositories/daily_activity_repository.py DailyActivityRepository.daily_rows prisma not.in `list(scope.exclude_entity_ids)` 0 litellm/router_utils/auto_router_model_naming.py raw-sql classifier_type.IN `IN ({_LLM_CLASSIFIER_TYPES_SQL})` 0 diff --git a/tests/integration/spend/_daily_activity_fixtures.py b/tests/integration/spend/_daily_activity_fixtures.py new file mode 100644 index 00000000000..d6e819edca6 --- /dev/null +++ b/tests/integration/spend/_daily_activity_fixtures.py @@ -0,0 +1,319 @@ +from itertools import product +from typing import Final + +import psycopg +from psycopg import sql + +_TABLE_NAMES: Final = ( + "LiteLLM_DailyUserSpend", + "LiteLLM_DailyTeamSpend", + "LiteLLM_VerificationToken", + "LiteLLM_DeletedVerificationToken", + "LiteLLM_UserTable", + "LiteLLM_TeamTable", +) + +_TAG_KEY_MEMBERSHIPS: Final = ( + ("tag-a", "entity-key-0"), + ("tag-a", "entity-key-1"), + ("tag-a", "entity-key-2"), + ("tag-a", "entity-key-3"), + ("tag-a", "entity-key-4"), + ("tag-b", "entity-key-0"), + ("tag-b", "entity-key-1"), + ("tag-b", "entity-key-5"), + ("tag-c", "entity-key-2"), + ("tag-c", "entity-key-3"), + ("tag-c", "entity-key-6"), + ("tag-c", "entity-key-7"), + ("tag-d", "entity-key-4"), + ("tag-d", "entity-key-5"), + ("tag-d", "entity-key-6"), + ("tag-d", "entity-key-7"), +) +_TAG_ACTIVITY_DATES: Final = ("2026-06-01", "2026-06-02") + + +def seed_daily_activity_fixture(connection: psycopg.Connection, *, schema: str, ptu_sentinel_api_key: str) -> None: + daily_user_table: Final = sql.Identifier(schema, "LiteLLM_DailyUserSpend") + daily_team_table: Final = sql.Identifier(schema, "LiteLLM_DailyTeamSpend") + verification_token_table: Final = sql.Identifier(schema, "LiteLLM_VerificationToken") + deleted_token_table: Final = sql.Identifier(schema, "LiteLLM_DeletedVerificationToken") + user_table: Final = sql.Identifier(schema, "LiteLLM_UserTable") + team_table: Final = sql.Identifier(schema, "LiteLLM_TeamTable") + keys: Final = ( + ("key-a", "model-popular", 100.0, 2, 1), + ("key-b", "model-popular", 90.0, 3, 1), + ("key-c", "model-popular", 80.0, 4, 1), + ("key-target", "model-target", 1.0, 5, 2), + ("key-cache", "model-cache", 2.0, 1000, 1), + ) + user_rows: Final = tuple( + ( + f"user-row-{index}", + "user-1", + "2026-06-01", + api_key, + model, + "", + "provider-a", + None, + "/v1/chat/completions", + prompt_tokens, + 2, + cache_read_tokens, + 0, + spend, + 1, + 1, + 0, + "2026-06-01 12:00:00", + ) + for index, (api_key, model, spend, prompt_tokens, cache_read_tokens) in enumerate(keys) + ) + team_rows: Final = tuple( + ( + f"team-row-{index}", + "team-1", + "2026-06-01", + api_key, + model, + "", + "provider-a", + None, + "/v1/chat/completions", + prompt_tokens, + 2, + cache_read_tokens, + 0, + spend, + 1, + 1, + 0, + 0.0, + "2026-06-01 12:00:00", + ) + for index, (api_key, model, spend, prompt_tokens, cache_read_tokens) in enumerate(keys) + ) + sentinel_user_row: Final = ( + "user-row-ptu", + "user-1", + "2026-06-01", + ptu_sentinel_api_key, + "model-ptu", + "", + "provider-a", + None, + "/v1/chat/completions", + 0, + 0, + 0, + 0, + 1000.0, + 0, + 0, + 0, + "2026-06-01 12:00:00", + ) + sentinel_team_row: Final = ( + "team-row-ptu", + "team-1", + "2026-06-01", + ptu_sentinel_api_key, + "model-ptu", + "", + "provider-a", + None, + "/v1/chat/completions", + 0, + 0, + 0, + 0, + 1000.0, + 0, + 0, + 0, + 42.0, + "2026-06-01 12:00:00", + ) + with connection.cursor() as cursor: + for table_name in _TABLE_NAMES: + cursor.execute( + sql.SQL("CREATE TABLE {} (LIKE {} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)").format( + sql.Identifier(schema, table_name), + sql.Identifier(table_name), + ) + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, user_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(daily_user_table), + (*user_rows, sentinel_user_row), + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, team_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, ptu_flat_cost, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(daily_team_table), + (*team_rows, sentinel_team_row), + ) + cursor.executemany( + sql.SQL( + "INSERT INTO {} (token, key_alias, team_id, user_id, metadata, models) VALUES (%s, %s, %s, %s, %s, %s)" + ).format(verification_token_table), + ( + ("key-a", "alias-a", "team-1", "user-1", '{"tags": ["blue", "gold"]}', []), + ("key-b", "alias-b", "team-1", "user-1", '{"tags": []}', []), + ("key-c", "alias-c", "team-1", "user-1", '{"tags": []}', []), + ("key-cache", "alias-cache", "team-1", "user-1", '{"tags": []}', []), + ), + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, token, key_alias, team_id, user_id, metadata, models, deleted_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """).format(deleted_token_table), + ( + ("deleted-old", "key-target", "older-target", "team-1", "user-1", '{"tags": []}', [], "2026-06-01"), + ( + "deleted-new", + "key-target", + "deleted-target", + "team-1", + "user-1", + '{"tags": ["archived"]}', + [], + "2026-06-02", + ), + ), + ) + cursor.execute( + sql.SQL("INSERT INTO {} (user_id, user_email, models) VALUES (%s, %s, %s)").format(user_table), + ("user-1", "user@example.com", []), + ) + cursor.execute( + sql.SQL("INSERT INTO {} (team_id, team_alias, admins, members, models) VALUES (%s, %s, %s, %s, %s)").format( + team_table + ), + ("team-1", "Usage Team", [], [], []), + ) + connection.commit() + + +def seed_daily_tag_activity_fixture(connection: psycopg.Connection, *, schema: str) -> None: + tag_table: Final = sql.Identifier(schema, "LiteLLM_DailyTagSpend") + rows: Final = tuple( + ( + f"tag-rollup-{row_index}", + tag, + date, + api_key, + "entity-rollup-model", + "", + "provider-a", + None, + "/v1/chat/completions", + row_index + 1, + float(row_index + 1), + row_index % 5 + 1, + f"{date} 12:00:00", + ) + for row_index, (date, (tag, api_key)) in enumerate(product(_TAG_ACTIVITY_DATES, _TAG_KEY_MEMBERSHIPS)) + ) + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("CREATE TABLE {} (LIKE {} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)").format( + tag_table, + sql.Identifier("LiteLLM_DailyTagSpend"), + ) + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, tag, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, spend, api_requests, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(tag_table), + rows, + ) + connection.commit() + + +def seed_daily_tag_float_tie_fixture(connection: psycopg.Connection, *, schema: str) -> None: + tag_table: Final = sql.Identifier(schema, "LiteLLM_DailyTagSpend") + key_spends: Final = ( + ("key-z", 0.1), + ("key-z", 0.2), + ("key-z", 0.3), + ("key-a", 0.3), + ("key-a", 0.2), + ("key-a", 0.1), + ) + rows: Final = ( + ( + f"float-tie-{row_index}", + "tag-float-tie", + "2026-06-01", + api_key, + "float-tie-model", + "", + "provider-a", + None, + "/v1/chat/completions", + 1, + spend, + 1, + "2026-06-01 12:00:00", + ) + for row_index, (api_key, spend) in enumerate(key_spends, start=1) + ) + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("CREATE TABLE {} (LIKE {} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)").format( + tag_table, + sql.Identifier("LiteLLM_DailyTagSpend"), + ) + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, tag, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, spend, api_requests, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(tag_table), + rows, + ) + connection.commit() + + +def seed_daily_team_unassigned_fixture( + connection: psycopg.Connection, *, schema: str, ptu_sentinel_api_key: str +) -> None: + team_table: Final = sql.Identifier(schema, "LiteLLM_DailyTeamSpend") + rows: Final = ( + ("unassigned-null", None, "key-unassigned-null", 3.0, 0.0), + ("unassigned-empty", "", "key-unassigned-empty", 7.0, 0.0), + ("unassigned-ptu", None, ptu_sentinel_api_key, 13.0, 13.0), + ) + with connection.cursor() as cursor: + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, team_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, spend, api_requests, ptu_flat_cost, updated_at) + VALUES (%s, %s, '2026-06-03', %s, 'model-a', '', 'provider-a', NULL, '/v1/chat/completions', + 1, %s, 1, %s, '2026-06-03 12:00:00') + """).format(team_table), + rows, + ) + connection.commit() diff --git a/tests/integration/spend/fixtures/daily_activity_team.json b/tests/integration/spend/fixtures/daily_activity_team.json new file mode 100644 index 00000000000..d833a695ede --- /dev/null +++ b/tests/integration/spend/fixtures/daily_activity_team.json @@ -0,0 +1 @@ +{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"team-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{"team_alias":"Usage Team"},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"team-1":5}}} diff --git a/tests/integration/spend/fixtures/daily_activity_user.json b/tests/integration/spend/fixtures/daily_activity_user.json new file mode 100644 index 00000000000..36701c20d9b --- /dev/null +++ b/tests/integration/spend/fixtures/daily_activity_user.json @@ -0,0 +1 @@ +{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"user-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"user-1":5}}} diff --git a/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py b/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py index 56c936a716f..33baf9d0e2e 100644 --- a/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py +++ b/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py @@ -6,7 +6,7 @@ from typing import Final import pytest from pydantic import JsonValue, TypeAdapter -from litellm.constants import PTU_SENTINEL_API_KEY +from litellm.constants import PTU_SENTINEL_API_KEY, USAGE_TOP_API_KEYS_DEFAULT from tests.integration._support.client import Gateway, object_value from tests.integration._support.database import write_rows @@ -43,57 +43,86 @@ def _row_id() -> str: return f"agg-{uuid.uuid4().hex}" +def _ranked_key_rows(day: str, count: int) -> list[tuple[object, ...]]: + return [ + ( + _row_id(), + f"user-{i:03d}", + day, + f"key-{i:03d}", + "gpt-5", + "", + "openai", + None, + "/v1/chat/completions", + 10, + 6.0 if i == 4 else float(i + 1), + 1, + 1, + ) + for i in range(count) + ] + + @pytest.mark.asyncio -async def test_get_daily_activity_aggregated_returns_every_api_key(gateway: Gateway) -> None: +async def test_get_daily_activity_aggregated_bounds_api_key_rollups(gateway: Gateway) -> None: + """key-004 and key-005 tie on spend exactly at the default api_key_limit cutoff; the api_key + tiebreaker keeps key-004 and drops key-005. The PTU sentinel outspends every key but takes no + slot. Dropped keys and the sentinel still count toward the totals and the model rollup.""" + key_count: Final = USAGE_TOP_API_KEYS_DEFAULT + 5 day: Final = _unique_day() _seed( day, [ - *[ - ( - _row_id(), - f"user-{i:03d}", - day, - f"key-{i:03d}", - "gpt-5", - "", - "openai", - None, - "/v1/chat/completions", - 10, - 6.0 if i == 4 else float(i + 1), - 1, - 1, - ) - for i in range(105) - ], + *_ranked_key_rows(day, key_count), (_row_id(), None, day, PTU_SENTINEL_API_KEY, "gpt-5", "", "azure", None, None, 0, 1000.0, 0, 0), ], ) + key_spend: Final = sum(6.0 if i == 4 else float(i + 1) for i in range(key_count)) try: body: Final = _activity(gateway, day) metadata: Final = object_value(body["metadata"]) - assert metadata["total_spend"] == pytest.approx(6566.0) - assert metadata["total_api_requests"] == 105 + assert metadata["total_spend"] == pytest.approx(key_spend + 1000.0) + assert metadata["total_api_requests"] == key_count + assert metadata["total_api_keys"] == key_count + assert metadata["api_key_limit"] == USAGE_TOP_API_KEYS_DEFAULT results: Final = _RESULTS.validate_python(body["results"]) assert len(results) == 1 result_day: Final = object_value(results[0]) - assert object_value(result_day["metrics"])["spend"] == pytest.approx(6566.0) + assert object_value(result_day["metrics"])["spend"] == pytest.approx(key_spend + 1000.0) breakdown: Final = object_value(result_day["breakdown"]) - expected_api_keys: Final = {f"key-{i:03d}" for i in range(105)} + expected_top: Final = {f"key-{i:03d}" for i in range(6, key_count)} | {"key-004"} api_keys: Final = object_value(breakdown["api_keys"]) - assert set(api_keys) == expected_api_keys + assert set(api_keys) == expected_top + assert object_value(object_value(api_keys["key-004"])["metrics"])["spend"] == 6.0 assert PTU_SENTINEL_API_KEY not in api_keys models: Final = object_value(breakdown["models"]) gpt5: Final = object_value(models["gpt-5"]) - assert object_value(gpt5["metrics"])["spend"] == pytest.approx(6566.0) - assert set(object_value(gpt5["api_key_breakdown"])) == expected_api_keys + assert object_value(gpt5["metrics"])["spend"] == pytest.approx(key_spend + 1000.0) + assert set(object_value(gpt5["api_key_breakdown"])) == expected_top providers: Final = object_value(breakdown["providers"]) openai: Final = object_value(providers["openai"]) - assert object_value(openai["metrics"])["spend"] == pytest.approx(5566.0) - assert set(object_value(openai["api_key_breakdown"])) == expected_api_keys + assert object_value(openai["metrics"])["spend"] == pytest.approx(key_spend) + assert set(object_value(openai["api_key_breakdown"])) == expected_top endpoints: Final = object_value(breakdown["endpoints"]) - assert object_value(object_value(endpoints["/v1/chat/completions"])["metrics"])["api_requests"] == 105 + assert object_value(object_value(endpoints["/v1/chat/completions"])["metrics"])["api_requests"] == key_count + finally: + _clean(day) + + +@pytest.mark.asyncio +async def test_get_daily_activity_aggregated_reports_exact_limit_key_count_as_complete(gateway: Gateway) -> None: + """With exactly USAGE_TOP_API_KEYS_DEFAULT keys nothing is dropped and total_api_keys equals the limit.""" + day: Final = _unique_day() + _seed(day, _ranked_key_rows(day, USAGE_TOP_API_KEYS_DEFAULT)) + try: + body: Final = _activity(gateway, day) + metadata: Final = object_value(body["metadata"]) + assert metadata["total_api_keys"] == USAGE_TOP_API_KEYS_DEFAULT + assert metadata["api_key_limit"] == USAGE_TOP_API_KEYS_DEFAULT + results: Final = _RESULTS.validate_python(body["results"]) + api_keys: Final = object_value(object_value(object_value(results[0])["breakdown"])["api_keys"]) + assert set(api_keys) == {f"key-{i:03d}" for i in range(USAGE_TOP_API_KEYS_DEFAULT)} finally: _clean(day) @@ -126,7 +155,9 @@ async def test_get_daily_activity_aggregated_explicit_api_key_filter_scopes_resu ) try: body: Final = _activity(gateway, day, api_key="key-1") - assert object_value(body["metadata"])["total_spend"] == 2.0 + metadata: Final = object_value(body["metadata"]) + assert metadata["total_spend"] == 2.0 + assert metadata["total_api_keys"] == 1 results: Final = _RESULTS.validate_python(body["results"]) assert len(results) == 1 breakdown: Final = object_value(object_value(results[0])["breakdown"]) diff --git a/tests/integration/spend/test_daily_activity_repository.py b/tests/integration/spend/test_daily_activity_repository.py new file mode 100644 index 00000000000..c347e18bf64 --- /dev/null +++ b/tests/integration/spend/test_daily_activity_repository.py @@ -0,0 +1,624 @@ +import os +import uuid +from collections.abc import AsyncIterator, Mapping +from contextlib import asynccontextmanager +from dataclasses import dataclass +from math import isclose +from pathlib import Path +from types import MappingProxyType +from typing import Final, cast +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import psycopg +import pytest +from integration.spend._daily_activity_fixtures import ( + seed_daily_activity_fixture, + seed_daily_tag_activity_fixture, + seed_daily_tag_float_tie_fixture, + seed_daily_team_unassigned_fixture, +) +from prisma import Prisma +from psycopg import sql +from pydantic import TypeAdapter + +from litellm import constants +from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity_aggregated +from litellm.repositories.chunked_in import find_many_in +from litellm.repositories.daily_activity_repository import DailyActivityDatabase, DailyActivityRepository +from litellm.types.repositories.daily_activity import ( + DailyActivityProxyReads, + DailyActivityScope, + DailyActivityTable, + ExportType, + KeyMetadataRow, + SpendLogsWindow, +) + + +@dataclass(frozen=True, slots=True) +class _TagRollupMetrics: + tag: str | None + date: str + spend: float + api_requests: int + prompt_tokens: int + + +@dataclass(frozen=True, slots=True) +class _TagApiKeyCount: + tag: str | None + distinct_api_keys: int + + +@dataclass(frozen=True, slots=True) +class _TagKeyMembershipCount: + api_key: str + tag_count: int + + +@dataclass(frozen=True, slots=True) +class _TagFloatSpend: + api_key: str + spend: float + + +@dataclass(frozen=True, slots=True) +class _TagRankedKey: + api_key: str + + +@dataclass(frozen=True, slots=True) +class _TagDistinctKeyCount: + total_api_keys: int + + +_TAG_ROLLUP_METRICS_ADAPTER: Final = TypeAdapter(tuple[_TagRollupMetrics, ...]) +_TAG_API_KEY_COUNT_ADAPTER: Final = TypeAdapter(tuple[_TagApiKeyCount, ...]) +_TAG_KEY_MEMBERSHIP_COUNT_ADAPTER: Final = TypeAdapter(tuple[_TagKeyMembershipCount, ...]) +_TAG_FLOAT_SPEND_ADAPTER: Final = TypeAdapter(tuple[_TagFloatSpend, ...]) +_TAG_RANKED_KEY_ADAPTER: Final = TypeAdapter(tuple[_TagRankedKey, ...]) +_TAG_DISTINCT_KEY_COUNT_ADAPTER: Final = TypeAdapter(tuple[_TagDistinctKeyCount, ...]) + + +def _scoped_url(url: str, schema: str) -> str: + parsed: Final = urlsplit(url) + return urlunsplit(parsed._replace(query=urlencode({**dict(parse_qsl(parsed.query)), "schema": schema}))) + + +@asynccontextmanager +async def _daily_activity_database( + *, + include_tag_activity: bool = False, + include_tag_float_tie_activity: bool = False, + include_team_unassigned_activity: bool = False, +) -> AsyncIterator[Prisma]: + schema: Final = f"integration_{uuid.uuid4().hex}" + url: Final = os.environ["DATABASE_URL"] + with psycopg.connect(url, autocommit=True) as setup: + setup.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + with psycopg.connect(url) as connection: + seed_daily_activity_fixture( + connection, + schema=schema, + ptu_sentinel_api_key=constants.PTU_SENTINEL_API_KEY, + ) + if include_tag_activity: + seed_daily_tag_activity_fixture(connection, schema=schema) + if include_tag_float_tie_activity: + seed_daily_tag_float_tie_fixture(connection, schema=schema) + if include_team_unassigned_activity: + seed_daily_team_unassigned_fixture( + connection, schema=schema, ptu_sentinel_api_key=constants.PTU_SENTINEL_API_KEY + ) + database: Final = Prisma(datasource={"url": _scoped_url(url, schema)}) + await database.connect() + try: + yield database + finally: + await database.disconnect() + finally: + setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema))) + + +@dataclass(frozen=True, slots=True) +class _PrismaDatabase: + db: Prisma + + +@dataclass(frozen=True, slots=True) +class _ProxyReads(DailyActivityProxyReads): + database: Prisma + + async def recover_key_metadata( + self, resolved: Mapping[str, KeyMetadataRow], api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: + user_ids: Final = frozenset(row.user_id for row in resolved.values() if row.user_id) + user_rows: Final = await find_many_in(self.database.litellm_usertable, "user_id", user_ids) if user_ids else () + user_emails: Final = MappingProxyType({row.user_id: row.user_email for row in user_rows if row.user_email}) + return MappingProxyType( + { + key: KeyMetadataRow( + api_key=row.api_key, + key_alias=row.key_alias, + team_id=row.team_id, + user_id=row.user_id, + user_email=row.user_email or user_emails.get(row.user_id), + key_exists=row.key_exists, + tags=row.tags, + ) + for key, row in resolved.items() + } + ) + + +def _repository(database: Prisma) -> DailyActivityRepository: + client: Final = cast(DailyActivityDatabase, _PrismaDatabase(database)) + return DailyActivityRepository(client, proxy_reads=_ProxyReads(database)) + + +def _scope( + table: DailyActivityTable, + entity_id_field: str, + entity_id: str, + api_keys: tuple[str, ...] | None = None, +) -> DailyActivityScope: + return DailyActivityScope( + table=table, + entity_id_field=entity_id_field, + entity_ids=(entity_id,), + exclude_entity_ids=(), + api_keys=api_keys, + start_date="2026-06-01", + end_date="2026-06-01", + model=None, + timezone_offset_minutes=None, + ) + + +@pytest.mark.asyncio +async def test_repository_queries_and_exports_seeded_daily_activity(monkeypatch: pytest.MonkeyPatch) -> None: + async with _daily_activity_database() as database: + repository: Final = _repository(database) + team_scope: Final = _scope(DailyActivityTable.TEAM, "team_id", "team-1") + monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 2) + aggregate: Final = await repository.aggregated(team_scope, include_entity_breakdown=True, api_key_limit=3) + totals: Final = tuple(row for row in aggregate.grouping_rows if row.group_level == 127) + assert len(totals) == 1 + assert totals[0].spend == 1273.0 + assert totals[0].ptu_flat_cost == 42.0 + assert aggregate.distinct_api_keys == 5 + grouped_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + assert grouped_keys == frozenset(("key-a", "key-b", "key-c")) + entity_totals: Final = tuple(row for row in aggregate.entity_rows or () if row.api_key_rolled) + assert len(entity_totals) == 1 + assert entity_totals[0].spend == 1273.0 + assert entity_totals[0].ptu_flat_cost == 42.0 + + targeted_model_keys: Final = await repository.model_top_keys( + team_scope, model_group="model-target", by_model_group=False, limit=3 + ) + assert tuple(row.api_key for row in targeted_model_keys) == ("key-target",) + popular_model_keys: Final = await repository.model_top_keys( + team_scope, model_group="model-popular", by_model_group=False, limit=3 + ) + assert tuple(row.api_key for row in popular_model_keys) == ("key-a", "key-b", "key-c") + assert await repository.search_keys(team_scope, search="target", limit=10) == ("key-target",) + assert await repository.search_keys(team_scope, search="deleted-target", limit=20) == ("key-target",) + leakage_keys: Final = await repository.cache_leakage_keys(team_scope, limit=2) + assert tuple(row.api_key for row in leakage_keys) == ("key-cache", "key-c") + + exports: Final = tuple( + [row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY_WITH_KEYS)] + ) + assert tuple(row.api_key for row in exports) == ( + "key-a", + "key-b", + "key-c", + "key-cache", + "key-target", + ) + assert sum(row.spend for row in exports) == 273.0 + assert sum(row.flat_cost for row in exports) == 0.0 + deleted_key_export: Final = next(row for row in exports if row.api_key == "key-target") + assert (deleted_key_export.key_alias, deleted_key_export.user_id, deleted_key_export.user_email) == ( + "deleted-target", + "user-1", + "user@example.com", + ) + user_exports: Final = tuple( + [row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY_WITH_USERS)] + ) + assert len(user_exports) == 1 + assert (user_exports[0].user_id, user_exports[0].user_email, user_exports[0].spend) == ( + "user-1", + "user@example.com", + 273.0, + ) + daily_export: Final = tuple( + [row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY)] + ) + assert len(daily_export) == 1 + assert daily_export[0].spend == 1273.0 + assert daily_export[0].flat_cost == 42.0 + + metadata: Final = await repository.key_metadata(frozenset(("key-a", "key-target")), None) + assert metadata["key-a"].key_exists is True + assert metadata["key-a"].key_alias == "alias-a" + assert metadata["key-a"].tags == ("blue", "gold") + assert metadata["key-a"].user_email == "user@example.com" + assert metadata["key-target"].key_exists is False + assert metadata["key-target"].key_alias == "deleted-target" + assert metadata["key-target"].tags == ("archived",) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("search", "api_keys", "expected_keys"), + ( + ("needle-alias", None, ("needle-key",)), + ("needle-user", None, ("needle-key",)), + ("needle@example.com", None, ("needle-key",)), + ("needle-alias", ("key-a",), ()), + ), +) +async def test_search_keys_matches_token_metadata_outside_top_n_and_respects_scope( + search: str, + api_keys: tuple[str, ...] | None, + expected_keys: tuple[str, ...], +) -> None: + async with _daily_activity_database() as database: + await database.execute_raw( + """ + INSERT INTO "LiteLLM_DailyTeamSpend" ( + id, team_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, ptu_flat_cost, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::timestamp + ) + """, + "needle-row", + "team-1", + "2026-06-01", + "needle-key", + "model-needle", + "", + "provider-a", + None, + "/v1/chat/completions", + 1, + 1, + 0, + 0, + 0.5, + 1, + 1, + 0, + 0.0, + "2026-06-01 12:00:00", + ) + await database.execute_raw( + """ + INSERT INTO "LiteLLM_VerificationToken" (token, key_alias, team_id, user_id, metadata, models) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::text[]) + """, + "needle-key", + "needle-alias", + "team-1", + "needle-user", + '{"tags": []}', + [], + ) + await database.execute_raw( + """ + INSERT INTO "LiteLLM_UserTable" (user_id, user_email, models) + VALUES ($1, $2, $3::text[]) + """, + "needle-user", + "needle@example.com", + [], + ) + + repository: Final = _repository(database) + team_scope: Final = _scope(DailyActivityTable.TEAM, "team_id", "team-1", api_keys=api_keys) + aggregate: Final = await repository.aggregated(team_scope, include_entity_breakdown=False, api_key_limit=1) + top_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + assert "needle-key" not in top_keys + assert await repository.search_keys(team_scope, search=search, limit=10) == expected_keys + + +@pytest.mark.asyncio +async def test_aggregated_returns_totals_with_a_one_key_limit() -> None: + async with _daily_activity_database() as database: + aggregate: Final = await _repository(database).aggregated( + _scope(DailyActivityTable.TEAM, "team_id", "team-1"), + include_entity_breakdown=False, + api_key_limit=1, + ) + + totals: Final = tuple(row for row in aggregate.grouping_rows if row.group_level == 127) + per_key_rows: Final = tuple(row for row in aggregate.grouping_rows if row.api_key is not None) + per_key_names: Final = frozenset(row.api_key for row in per_key_rows) + assert len(totals) == 1 + assert totals[0].spend == 1273.0 + assert len(per_key_rows) == 6 + assert len(per_key_names) == 1 + + +@pytest.mark.asyncio +async def test_tag_entity_rollups_bound_keys_and_preserve_full_scope_totals() -> None: + async with _daily_activity_database(include_tag_activity=True) as database: + scope: Final = DailyActivityScope( + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-01", + end_date="2026-06-02", + model=None, + timezone_offset_minutes=None, + ) + repository: Final = _repository(database) + aggregate: Final = await repository.aggregated(scope, include_entity_breakdown=True, api_key_limit=3) + independent_metrics: Final = _TAG_ROLLUP_METRICS_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT tag, date, SUM(spend)::float AS spend, + SUM(api_requests)::bigint AS api_requests, + SUM(prompt_tokens)::bigint AS prompt_tokens + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 + GROUP BY tag, date + """, + "2026-06-01", + "2026-06-02", + ) + ) + independent_key_counts: Final = _TAG_API_KEY_COUNT_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT tag, COUNT(DISTINCT api_key)::bigint AS distinct_api_keys + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 AND api_key <> $3 + GROUP BY tag + """, + "2026-06-01", + "2026-06-02", + constants.PTU_SENTINEL_API_KEY, + ) + ) + independent_key_memberships: Final = _TAG_KEY_MEMBERSHIP_COUNT_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT api_key, COUNT(DISTINCT tag)::bigint AS tag_count + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 + GROUP BY api_key + """, + "2026-06-01", + "2026-06-02", + ) + ) + expected_metrics: Final = MappingProxyType({(row.date, row.tag): row for row in independent_metrics}) + expected_key_counts: Final = MappingProxyType( + {row.tag: row.distinct_api_keys for row in independent_key_counts} + ) + assert {row.date for row in independent_metrics} == {"2026-06-01", "2026-06-02"} + assert len(expected_key_counts) == 4 + assert len(independent_key_memberships) == 8 + assert all(row.tag_count == 2 for row in independent_key_memberships) + assert max(expected_key_counts.values()) > 3 + + entity_rows: Final = aggregate.entity_rows or () + rolled_rows: Final = tuple(row for row in entity_rows if row.api_key_rolled) + keyed_rows: Final = tuple(row for row in entity_rows if not row.api_key_rolled and row.api_key) + top_level_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + entity_day_keys: Final = MappingProxyType( + { + key: frozenset(row.api_key for row in keyed_rows if (row.date, row.entity_id) == key and row.api_key) + for key in frozenset((row.date, row.entity_id) for row in keyed_rows) + } + ) + assert entity_day_keys + assert max(len(keys) for keys in entity_day_keys.values()) <= 3 + assert frozenset(row.api_key for row in keyed_rows) <= top_level_keys + + rolled_by_entity_day: Final = MappingProxyType( + {(row.date, row.entity_id): row for row in rolled_rows if row.date is not None} + ) + assert set(rolled_by_entity_day) == set(expected_metrics) + for key, row in rolled_by_entity_day.items(): + assert row.spend is not None + assert isclose(row.spend, expected_metrics[key].spend, rel_tol=1e-9, abs_tol=1e-9) + assert row.api_requests == expected_metrics[key].api_requests + assert row.prompt_tokens == expected_metrics[key].prompt_tokens + assert row.distinct_api_keys == expected_key_counts[row.entity_id] + + response: Final = await get_daily_activity_aggregated( + repository, + scope, + include_entity_breakdown=True, + api_key_limit=3, + ) + assert response.metadata.entity_total_api_keys == { + tag: count for tag, count in expected_key_counts.items() if tag is not None + } + assert all( + all(len(entity.api_key_breakdown) <= 3 for entity in day.breakdown.entities.values()) + for day in response.results + ) + + +@pytest.mark.asyncio +async def test_key_pages_match_full_tag_ranking_and_aggregate_top_keys() -> None: + async with _daily_activity_database(include_tag_activity=True) as database: + scope: Final = DailyActivityScope( + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-01", + end_date="2026-06-02", + model=None, + timezone_offset_minutes=None, + ) + repository: Final = _repository(database) + first_page: Final = await repository.key_page(scope, offset=0, limit=3) + remaining_pages: Final = tuple( + [ + await repository.key_page(scope, offset=offset, limit=3) + for offset in range(3, first_page.total_api_keys, 3) + ] + ) + pages: Final = (first_page, *remaining_pages) + actual_keys: Final = tuple(row.api_key for page in pages for row in page.rows) + expected_rows: Final = _TAG_RANKED_KEY_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT api_key + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 AND api_key <> $3 + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + """, + "2026-06-01", + "2026-06-02", + constants.PTU_SENTINEL_API_KEY, + ) + ) + expected_keys: Final = tuple(row.api_key for row in expected_rows) + independent_count: Final = _TAG_DISTINCT_KEY_COUNT_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT COUNT(DISTINCT api_key)::bigint AS total_api_keys + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 AND api_key <> $3 + """, + "2026-06-01", + "2026-06-02", + constants.PTU_SENTINEL_API_KEY, + ) + )[0].total_api_keys + aggregate: Final = await repository.aggregated(scope, include_entity_breakdown=False, api_key_limit=3) + aggregate_top_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key is not None + ) + empty_page: Final = await repository.key_page(scope, offset=independent_count + 3, limit=3) + + assert actual_keys == expected_keys + assert len(actual_keys) == len(frozenset(actual_keys)) + assert all(page.total_api_keys == independent_count for page in pages) + assert first_page.total_api_keys == independent_count + assert frozenset(row.api_key for row in first_page.rows) == aggregate_top_keys + assert empty_page.rows == () + assert empty_page.total_api_keys == independent_count + + +@pytest.mark.asyncio +async def test_top_api_key_rank_is_order_independent_for_float_ties() -> None: + async with _daily_activity_database(include_tag_float_tie_activity=True) as database: + float_totals: Final = _TAG_FLOAT_SPEND_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT api_key, SUM(spend)::float AS spend + FROM "LiteLLM_DailyTagSpend" + WHERE tag = $1 AND date = $2 + GROUP BY api_key + """, + "tag-float-tie", + "2026-06-01", + ) + ) + float_spends: Final = MappingProxyType({row.api_key: row.spend for row in float_totals}) + assert float_spends["key-z"] > float_spends["key-a"] + + scope: Final = DailyActivityScope( + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-01", + end_date="2026-06-01", + model=None, + timezone_offset_minutes=None, + ) + aggregate: Final = await _repository(database).aggregated(scope, include_entity_breakdown=True, api_key_limit=1) + key_page: Final = await _repository(database).key_page(scope, offset=0, limit=1) + top_level_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + entity_keyed_keys: Final = frozenset( + row.api_key for row in aggregate.entity_rows or () if not row.api_key_rolled and row.api_key is not None + ) + expected_keys: Final = frozenset(("key-a",)) + assert (top_level_keys, entity_keyed_keys) == ( + expected_keys, + expected_keys, + ), f"plain float SUM totals: {float_spends}" + assert frozenset(row.api_key for row in key_page.rows) == expected_keys + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("table", "entity_field", "entity_id", "fixture_name"), + ( + (DailyActivityTable.USER, "user_id", "user-1", "daily_activity_user.json"), + (DailyActivityTable.TEAM, "team_id", "team-1", "daily_activity_team.json"), + ), +) +async def test_aggregated_response_matches_base_golden( + table: DailyActivityTable, entity_field: str, entity_id: str, fixture_name: str +) -> None: + async with _daily_activity_database() as database: + result: Final = await get_daily_activity_aggregated( + _repository(database), + _scope(table, entity_field, entity_id), + entity_metadata_field=MappingProxyType({"team-1": {"team_alias": "Usage Team"}}), + include_entity_breakdown=True, + ) + golden_path: Final = Path(__file__).with_name("fixtures") / fixture_name + assert result.model_dump_json() + "\n" == golden_path.read_text() + + +@pytest.mark.asyncio +async def test_team_entity_rollups_merge_null_and_empty_entity_ids() -> None: + async with _daily_activity_database(include_team_unassigned_activity=True) as database: + scope: Final = DailyActivityScope( + table=DailyActivityTable.TEAM, + entity_id_field="team_id", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-03", + end_date="2026-06-03", + model=None, + timezone_offset_minutes=None, + ) + aggregate: Final = await _repository(database).aggregated(scope, include_entity_breakdown=True, api_key_limit=3) + + totals: Final = tuple(row for row in aggregate.grouping_rows if row.group_level == 127) + assert len(totals) == 1 + assert totals[0].spend == 23.0 + assert aggregate.distinct_api_keys == 2 + + rolled_rows: Final = tuple(row for row in aggregate.entity_rows or () if row.api_key_rolled) + assert len(rolled_rows) == 1 + assert rolled_rows[0].entity_id == "" + assert rolled_rows[0].spend == 23.0 + assert rolled_rows[0].ptu_flat_cost == 13.0 + assert rolled_rows[0].distinct_api_keys == 2 + + keyed_rows: Final = tuple(row for row in aggregate.entity_rows or () if not row.api_key_rolled) + assert {row.entity_id for row in keyed_rows} == {""} + assert {row.api_key for row in keyed_rows} == {"key-unassigned-null", "key-unassigned-empty"} diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index 7cc5100037e..75e4a865137 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -1,28 +1,200 @@ -from collections.abc import Sequence -from datetime import datetime, timedelta, timezone +from collections.abc import Mapping, Sequence +from datetime import datetime from types import SimpleNamespace from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest +from fastapi import HTTPException +import litellm.proxy.management_endpoints.common_daily_activity as common_daily_activity_module +from litellm.constants import USAGE_TOP_API_KEYS_DEFAULT from litellm.proxy.management_endpoints.common_daily_activity import ( - _adjust_dates_for_timezone, - _build_aggregated_sql_query, - _build_entity_rollup_sql_query, _is_user_agent_tag, + _ProxyDailyActivityReads, _record_to_spend_metrics, + compute_tag_metadata_totals, + daily_activity_repository, + daily_activity_scope, get_api_key_metadata, get_daily_activity, - get_daily_activity_aggregated, update_metrics, ) +from litellm.proxy.management_endpoints.common_daily_activity import ( + get_daily_activity_aggregated as _get_daily_activity_aggregated, +) from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR -from litellm.proxy.utils import hash_token +from litellm.proxy.utils import PrismaClient, hash_token from litellm.types.proxy.management_endpoints.common_daily_activity import ( DailySpendMetadata, + SpendAnalyticsPaginatedResponse, SpendMetrics, ) +from litellm.types.repositories.daily_activity import GroupingSetsRow, KeyMetadataRow + + +async def _run_aggregated_daily_activity( + *, + prisma_client: PrismaClient, + table_name: str, + entity_id_field: str, + entity_id: str | list[str] | None, + entity_metadata_field: Mapping[str, dict[str, object]] | None = None, + start_date: str, + end_date: str, + model: str | None, + api_key: str | list[str] | None, + exclude_entity_ids: list[str] | None = None, + timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, + include_entity_breakdown: bool = False, + api_key_limit: int = USAGE_TOP_API_KEYS_DEFAULT, +) -> SpendAnalyticsPaginatedResponse: + repository: Final = daily_activity_repository(prisma_client) + scope: Final = daily_activity_scope( + table_name, + entity_id_field, + entity_id, + exclude_entity_ids, + api_key, + start_date, + end_date, + model, + timezone_offset_minutes, + include_current_utc_day, + ) + return await _get_daily_activity_aggregated( + repository, + scope, + entity_metadata_field=entity_metadata_field, + include_entity_breakdown=include_entity_breakdown, + api_key_limit=api_key_limit, + ) + + +async def get_daily_activity_aggregated( + *, + prisma_client: PrismaClient, + table_name: str, + entity_id_field: str, + entity_id: str | list[str] | None, + entity_metadata_field: Mapping[str, dict[str, object]] | None = None, + start_date: str, + end_date: str, + model: str | None, + api_key: str | list[str] | None, + exclude_entity_ids: list[str] | None = None, + timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, + include_entity_breakdown: bool = False, + api_key_limit: int = USAGE_TOP_API_KEYS_DEFAULT, +) -> SpendAnalyticsPaginatedResponse: + return await _run_aggregated_daily_activity( + prisma_client=prisma_client, + table_name=table_name, + entity_id_field=entity_id_field, + entity_id=entity_id, + entity_metadata_field=entity_metadata_field, + start_date=start_date, + end_date=end_date, + model=model, + api_key=api_key, + exclude_entity_ids=exclude_entity_ids, + timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, + include_entity_breakdown=include_entity_breakdown, + api_key_limit=api_key_limit, + ) + + +@pytest.mark.asyncio +async def test_get_daily_activity_requires_a_database(): + with pytest.raises(HTTPException) as error: + await get_daily_activity( + prisma_client=None, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id="user-1", + entity_metadata_field=None, + start_date="2026-06-16", + end_date="2026-06-16", + model=None, + api_key=None, + page=1, + page_size=10, + ) + + assert error.value.status_code == 500 + assert error.value.detail == {"error": common_daily_activity_module.CommonProxyErrors.db_not_connected_error.value} + + +@pytest.mark.asyncio +async def test_get_daily_activity_maps_repository_failures_to_http_errors(): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=0) + mock_table.find_many = AsyncMock(side_effect=RuntimeError("daily rows unavailable")) + mock_prisma.db.litellm_dailyuserspend = mock_table + + with pytest.raises(HTTPException) as error: + await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id="user-1", + entity_metadata_field=None, + start_date="2026-06-16", + end_date="2026-06-16", + model=None, + api_key=None, + page=1, + page_size=10, + ) + + assert error.value.status_code == 500 + assert error.value.detail == {"error": "Failed to fetch analytics: daily rows unavailable"} + + +@pytest.mark.asyncio +async def test_get_daily_activity_aggregated_maps_repository_failures_to_http_errors(): + repository = MagicMock() + repository.aggregated = AsyncMock(side_effect=RuntimeError("daily aggregate unavailable")) + scope = daily_activity_scope( + "litellm_dailyuserspend", + "user_id", + "user-1", + None, + None, + "2026-06-16", + "2026-06-16", + None, + None, + ) + + with pytest.raises(HTTPException) as error: + await _get_daily_activity_aggregated(repository, scope) + + assert error.value.status_code == 500 + assert error.value.detail == {"error": "Failed to fetch analytics: daily aggregate unavailable"} + + +def test_compute_tag_metadata_totals_deduplicates_and_ignores_user_agent_tags(): + smaller = _spend_record("key-1", spend=1.0) + smaller.request_id = "request-1" + smaller.tag = "environment: small" + larger = _spend_record("key-1", spend=4.0) + larger.request_id = "request-1" + larger.tag = "environment: large" + larger.api_requests = 1 + user_agent = _spend_record("key-2", spend=10.0) + user_agent.request_id = "request-2" + user_agent.tag = "User-Agent: test" + user_agent.api_requests = 3 + + totals = compute_tag_metadata_totals((smaller, larger, user_agent)) + + assert (totals.spend, totals.api_requests) == (4.0, 1) @pytest.mark.asyncio @@ -39,12 +211,12 @@ async def test_get_daily_activity_empty_entity_id_list(): mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) # Set the table name dynamically - mock_prisma.db.litellm_dailyspend = mock_table + mock_prisma.db.litellm_dailyteamspend = mock_table # Call the function with empty entity_id list - result = await get_daily_activity( + await get_daily_activity( prisma_client=mock_prisma, - table_name="litellm_dailyspend", + table_name="litellm_dailyteamspend", entity_id_field="team_id", entity_id=[], entity_metadata_field=None, @@ -87,11 +259,11 @@ async def test_get_daily_activity_order_has_id_tiebreaker(): mock_table.find_many = AsyncMock(return_value=[]) mock_prisma.db.litellm_verificationtoken = MagicMock() mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) - mock_prisma.db.litellm_dailyspend = mock_table + mock_prisma.db.litellm_dailyteamspend = mock_table await get_daily_activity( prisma_client=mock_prisma, - table_name="litellm_dailyspend", + table_name="litellm_dailyteamspend", entity_id_field="team_id", entity_id="team-1", entity_metadata_field=None, @@ -105,7 +277,7 @@ async def test_get_daily_activity_order_has_id_tiebreaker(): mock_table.find_many.assert_called_once() order = mock_table.find_many.call_args[1]["order"] - assert order == [{"date": "desc"}, {"id": "asc"}], ( + assert order == ({"date": "desc"}, {"id": "asc"}), ( f"order must include the id tiebreaker after date for stable offset pagination (see #30164); got {order!r}" ) @@ -169,6 +341,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": "/v1/chat/completions", "api_key": None, "group_level": 62, + "distinct_api_keys": None, "spend": 15.0, "prompt_tokens": 150, "completion_tokens": 75, @@ -181,31 +354,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": "/v1/embeddings", "api_key": None, "group_level": 62, - "spend": 3.0, - "prompt_tokens": 30, - "completion_tokens": 0, - "api_requests": 1, - "successful_requests": 1, - }, - # (date, endpoint, api_key) — populates the per-key sub-bucket - { - **base, - "date": "2024-01-01", - "endpoint": "/v1/chat/completions", - "api_key": "key-1", - "group_level": 30, - "spend": 15.0, - "prompt_tokens": 150, - "completion_tokens": 75, - "api_requests": 2, - "successful_requests": 2, - }, - { - **base, - "date": "2024-01-01", - "endpoint": "/v1/embeddings", - "api_key": "key-2", - "group_level": 30, + "distinct_api_keys": None, "spend": 3.0, "prompt_tokens": 30, "completion_tokens": 0, @@ -219,6 +368,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": None, "api_key": None, "group_level": 63, + "distinct_api_keys": None, "spend": 18.0, "prompt_tokens": 180, "completion_tokens": 75, @@ -232,12 +382,40 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": None, "api_key": None, "group_level": 127, + "distinct_api_keys": None, "spend": 18.0, "prompt_tokens": 180, "completion_tokens": 75, "api_requests": 3, "successful_requests": 3, }, + # (date, endpoint, api_key) — populates the per-key sub-bucket + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/chat/completions", + "api_key": "key-1", + "group_level": 30, + "distinct_api_keys": 2, + "spend": 15.0, + "prompt_tokens": 150, + "completion_tokens": 75, + "api_requests": 2, + "successful_requests": 2, + }, + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/embeddings", + "api_key": "key-2", + "group_level": 30, + "distinct_api_keys": 2, + "spend": 3.0, + "prompt_tokens": 30, + "completion_tokens": 0, + "api_requests": 1, + "successful_requests": 1, + }, ] mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows) @@ -313,6 +491,49 @@ async def test_get_api_key_metadata_returns_active_key_metadata(): assert result["active-key-hash-123"]["team_id"] == "team-abc" +@pytest.mark.asyncio +async def test_recovered_key_metadata_preserves_resolved_tags_after_user_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolved: Final = KeyMetadataRow( + api_key="key-hash", + key_alias="key alias", + team_id="team-id", + user_id="user-id", + user_email=None, + key_exists=True, + tags=("production", "internal"), + ) + attach_details: Final = AsyncMock( + return_value={ + "key-hash": { + "key_alias": "key alias", + "team_id": "team-id", + "user_id": "user-id", + "user_email": "user@example.com", + "key_exists": True, + } + } + ) + monkeypatch.setattr(common_daily_activity_module, "attach_user_details", attach_details) + reads: Final = _ProxyDailyActivityReads(MagicMock()) + + result: Final = await reads.recover_key_metadata({"key-hash": resolved}, frozenset(("key-hash",)), None) + + assert result == { + "key-hash": KeyMetadataRow( + api_key="key-hash", + key_alias="key alias", + team_id="team-id", + user_id="user-id", + user_email="user@example.com", + key_exists=True, + tags=("production", "internal"), + ) + } + attach_details.assert_awaited_once() + + @pytest.mark.asyncio async def test_get_api_key_metadata_falls_back_to_deleted_keys(): """Test that get_api_key_metadata should fall back to deleted keys table for missing keys.""" @@ -341,7 +562,6 @@ async def test_get_api_key_metadata_falls_back_to_deleted_keys(): # Verify deleted table was queried with the missing key mock_prisma.db.litellm_deletedverificationtoken.find_many.assert_called_once_with( where={"token": {"in": ["deleted-key-hash-456"]}}, - order={"deleted_at": "desc"}, ) @@ -437,11 +657,13 @@ async def test_get_api_key_metadata_regenerated_key_uses_most_recent_deleted_rec mock_deleted_1.token = "old-key-hash" mock_deleted_1.key_alias = "latest-alias" mock_deleted_1.team_id = "latest-team" + mock_deleted_1.deleted_at = datetime(2024, 1, 2) mock_deleted_2 = MagicMock() mock_deleted_2.token = "old-key-hash" mock_deleted_2.key_alias = "older-alias" mock_deleted_2.team_id = "older-team" + mock_deleted_2.deleted_at = datetime(2024, 1, 1) # Ordered by deleted_at desc, so first record is the most recent mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[mock_deleted_1, mock_deleted_2]) @@ -629,12 +851,15 @@ def test_key_metadata_includes_recovered_user_email(): meta = _key_metadata( { - "dirty-key": { - "key_alias": "batch-worker", - "team_id": "team-1", - "user_id": "alice", - "user_email": "alice@example.com", - } + "dirty-key": KeyMetadataRow( + api_key="dirty-key", + key_alias="batch-worker", + team_id="team-1", + user_id="alice", + user_email="alice@example.com", + key_exists=True, + tags=(), + ) }, "dirty-key", ) @@ -649,11 +874,15 @@ def test_key_metadata_includes_user_id_without_user_email(): meta = _key_metadata( { - "dirty-key": { - "key_alias": "batch-worker", - "team_id": "team-1", - "user_id": "user-123", - } + "dirty-key": KeyMetadataRow( + api_key="dirty-key", + key_alias="batch-worker", + team_id="team-1", + user_id="user-123", + user_email=None, + key_exists=True, + tags=(), + ) }, "dirty-key", ) @@ -694,11 +923,15 @@ def test_update_breakdown_metrics_includes_user_email(): user_id="alice", ) api_key_metadata = { - "dirty-key": { - "key_alias": "batch-worker", - "team_id": "team-1", - "user_email": "alice@example.com", - } + "dirty-key": KeyMetadataRow( + api_key="dirty-key", + key_alias="batch-worker", + team_id="team-1", + user_id=None, + user_email="alice@example.com", + key_exists=True, + tags=(), + ) } update_breakdown_metrics( @@ -870,6 +1103,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): "endpoint": "/v1/chat/completions", "api_key": None, "group_level": 62, + "distinct_api_keys": None, "spend": 10.0, "prompt_tokens": 100, "completion_tokens": 50, @@ -882,6 +1116,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): "endpoint": "/v1/chat/completions", "api_key": "deleted-key-hash", "group_level": 30, + "distinct_api_keys": 1, "spend": 10.0, "prompt_tokens": 100, "completion_tokens": 50, @@ -963,10 +1198,21 @@ async def test_aggregated_activity_flags_only_keys_that_key_info_can_still_resol return_value=[{**base, "api_key": key} for key in ("active-key", "deleted-key", "session-key")] ) mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( - return_value=[SimpleNamespace(token="active-key", key_alias="active", team_id=None, user_id="owner")] + return_value=[ + SimpleNamespace(token="active-key", key_alias="active", team_id=None, user_id="owner", metadata=None) + ] ) mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( - return_value=[SimpleNamespace(token="deleted-key", key_alias="deleted", team_id=None, user_id="owner")] + return_value=[ + SimpleNamespace( + token="deleted-key", + key_alias="deleted", + team_id=None, + user_id="owner", + metadata=None, + deleted_at=datetime(2024, 1, 2), + ) + ] ) mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[]) @@ -1128,261 +1374,6 @@ async def test_model_groups_breakdown_keys_by_public_name_with_model_fallback(): assert breakdown.models["claude-x"].metrics.spend == 2.0 -class TestAdjustDatesForTimezone: - """ - Regression tests for the timezone double-counting bug. - - Background: the previous implementation expanded the SQL date range by a full - UTC day on whichever side a non-UTC timezone offset pointed. Because spend is - bucketed in whole UTC days in the aggregation table, that expansion caused - single-day queries from non-UTC timezones to include a second full UTC day's - worth of data, producing approximately 2x over-counting. The sum of single-day - spends across a window then exceeded the equivalent multi-day aggregate, which - is mathematically impossible. - - These tests pin the function to a pass-through and assert the additivity - invariant that any future implementation must preserve. - """ - - @pytest.mark.parametrize( - "offset_minutes", - [ - None, - 0, - -330, # IST UTC+5:30 - -540, # JST UTC+9 - -60, # CET UTC+1 - 240, # AST UTC-4 - 300, # EST UTC-5 - 480, # PST UTC-8 - ], - ) - def test_returns_input_dates_unchanged_for_any_offset(self, offset_minutes): - start, end = _adjust_dates_for_timezone("2026-05-29", "2026-05-29", offset_minutes) - assert start == "2026-05-29" - assert end == "2026-05-29" - - def test_single_day_query_does_not_widen_to_two_utc_days(self): - """ - Pins the boundary that caused the original 2x bug: a single IST day must - not be translated into a SQL filter covering two UTC days. - """ - start, end = _adjust_dates_for_timezone("2026-05-29", "2026-05-29", -330) - assert start == end == "2026-05-29", ( - "Single-day IST query expanded to a multi-day UTC range; this is " - "the regression that produced approximately 2x over-counting." - ) - - def test_multi_day_range_endpoints_are_preserved(self): - start, end = _adjust_dates_for_timezone("2026-05-29", "2026-06-02", -330) - assert (start, end) == ("2026-05-29", "2026-06-02") - - @pytest.mark.parametrize("offset_minutes", [-330, 480]) - def test_single_day_sums_match_multi_day_window(self, offset_minutes): - """ - Additivity invariant: querying each day in a window separately and summing - the resulting SQL ranges must cover exactly the same range as querying the - whole window at once. The bug broke this; without it, single-day sums - exceeded the multi-day total by ~50% over a 5-day IST window. - """ - days = ["2026-05-29", "2026-05-30", "2026-05-31", "2026-06-01", "2026-06-02"] - single_day_ranges = [_adjust_dates_for_timezone(d, d, offset_minutes) for d in days] - multi_day_range = _adjust_dates_for_timezone(days[0], days[-1], offset_minutes) - - per_day_starts = [r[0] for r in single_day_ranges] - per_day_ends = [r[1] for r in single_day_ranges] - assert min(per_day_starts) == multi_day_range[0] - assert max(per_day_ends) == multi_day_range[1] - assert per_day_starts == days - assert per_day_ends == days - - -class TestAdjustDatesForTimezoneLiveEnd: - """ - Regression tests for the stale-evening bug: a caller west of UTC whose range - ends on their local "today" was capped at that local date's UTC bucket, so - once UTC rolled past their local midnight (5pm PT), everything sent that - evening sat in the next UTC bucket and the dashboard reported $0 for it - until local midnight. A range that reaches the caller's current day and - opts in via include_current_utc_day must extend to today's UTC bucket; the - only part of that bucket outside the range is the future, which is empty, - so the extension cannot over-count. Callers that do not opt in keep the - pass-through byte for byte. - """ - - PT_EVENING_UTC: Final = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) - - def test_pt_evening_range_ending_today_extends_to_utc_today(self): - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-06", "2026-08-06") - - def test_without_opt_in_live_range_keeps_pass_through(self): - start, end = _adjust_dates_for_timezone("2026-07-06", "2026-08-05", 420, utc_now=self.PT_EVENING_UTC) - assert (start, end) == ("2026-07-06", "2026-08-05") - - def test_pt_historical_range_is_untouched(self): - start, end = _adjust_dates_for_timezone( - "2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-01", "2026-08-04") - - def test_east_of_utc_local_today_already_covers_utc_today(self): - ist_evening_utc: Final = datetime(2026, 8, 5, 17, 0, tzinfo=timezone.utc) - start, end = _adjust_dates_for_timezone( - "2026-07-07", "2026-08-06", -330, include_current_utc_day=True, utc_now=ist_evening_utc - ) - assert (start, end) == ("2026-07-07", "2026-08-06") - - def test_missing_offset_stays_pass_through_even_for_live_range(self): - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-05", None, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-06", "2026-08-05") - - def test_utc_caller_range_ending_today_is_unchanged(self): - utc_noon: Final = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-05", 0, include_current_utc_day=True, utc_now=utc_noon - ) - assert (start, end) == ("2026-07-06", "2026-08-05") - - def test_future_end_date_extends_no_further_than_requested(self): - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-09", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-06", "2026-08-09") - - -class TestBuildAggregatedSqlQuery: - """ - Asserts the SQL emitted by the aggregated query path stays anchored to the - user-supplied date range. The original bug shipped a function that returned - expanded dates from _adjust_dates_for_timezone, so the regression surface is - not just the helper but the SQL it feeds into. - """ - - @pytest.mark.parametrize("offset_minutes", [None, 0, -330, 480]) - def test_sql_date_bounds_are_user_supplied_dates(self, offset_minutes): - sql, params = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-05-29", - end_date="2026-05-29", - model=None, - api_key=None, - timezone_offset_minutes=offset_minutes, - ) - - assert params[0] == "2026-05-29" - assert params[1] == "2026-05-29" - assert "date >= $1" in sql - assert "date <= $2" in sql - - @pytest.mark.parametrize("build", [_build_aggregated_sql_query, _build_entity_rollup_sql_query]) - def test_include_current_utc_day_extends_live_end_bound(self, build): - """ - An offset larger than 24h keeps the caller's local date behind UTC at any - wall-clock hour, so the live-end extension is deterministic: a range ending - on the caller's local today must reach today's UTC bucket (LIT-5818, guards - the #36051 behavior on the aggregated path). - """ - offset_minutes: Final = 1500 - caller_local_today: Final = (datetime.now(timezone.utc) - timedelta(minutes=offset_minutes)).date().isoformat() - utc_today: Final = datetime.now(timezone.utc).date().isoformat() - - _sql, params = build( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-05-01", - end_date=caller_local_today, - model=None, - api_key=None, - timezone_offset_minutes=offset_minutes, - include_current_utc_day=True, - ) - - assert params[0] == "2026-05-01" - assert params[1] == utc_today - - def test_optional_filters_appear_in_params_in_order(self): - sql, params = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-05-29", - end_date="2026-06-02", - model="bedrock/global.anthropic.claude-opus-4-8", - api_key="sk-test", - timezone_offset_minutes=-330, - ) - - assert params == [ - "2026-05-29", - "2026-06-02", - "user-1", - "bedrock/global.anthropic.claude-opus-4-8", - "sk-test", - ] - assert "model = $4" in sql - assert "api_key = $5" in sql - - -class TestAggregatedEmptyEntityFilter: - _BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query) - - @pytest.mark.parametrize("build", _BUILDERS) - def test_empty_entity_list_emits_no_degenerate_in_clause(self, build): - sql, params = build( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=[], - start_date="2026-08-01", - end_date="2026-08-19", - model=None, - api_key=None, - ) - - normalized = " ".join(sql.split()) - assert "IN ()" not in normalized - assert '"team_id" IN' not in normalized - assert params == ["2026-08-01", "2026-08-19"] - - @pytest.mark.parametrize("build", _BUILDERS) - def test_empty_entity_list_matches_nothing_rather_than_everything(self, build): - sql, _ = build( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=[], - start_date="2026-08-01", - end_date="2026-08-19", - model=None, - api_key=None, - ) - - assert "FALSE" in " ".join(sql.split()) - - @pytest.mark.parametrize("build", _BUILDERS) - def test_populated_entity_list_still_filters_on_its_ids(self, build): - sql, params = build( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=["team-alpha", "team-beta"], - start_date="2026-08-01", - end_date="2026-08-19", - model=None, - api_key=None, - ) - - normalized = " ".join(sql.split()) - assert '"team_id" IN ($3, $4)' in normalized - assert "FALSE" not in normalized - assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta"] - - @pytest.mark.asyncio async def test_get_daily_activity_aggregated_empty_result_set(): """Regression test for the empty-range 500. @@ -1405,6 +1396,7 @@ async def test_get_daily_activity_aggregated_empty_result_set(): "mcp_namespaced_tool_name": None, "endpoint": None, "group_level": 127, + "distinct_api_keys": None, "spend": None, "prompt_tokens": None, "completion_tokens": None, @@ -1438,6 +1430,7 @@ async def test_get_daily_activity_aggregated_empty_result_set(): assert result.results == [] assert result.metadata.total_spend == 0.0 + assert result.metadata.entity_total_api_keys is None assert result.metadata.total_prompt_tokens == 0 assert result.metadata.total_completion_tokens == 0 assert result.metadata.total_tokens == 0 @@ -1517,20 +1510,6 @@ class TestEverySavingsDriverSurvivesTheReadPath: assert drivers, "expected the dashboard response to expose at least one savings driver" return drivers - def test_every_driver_is_summed_by_the_rollup_query(self): - sql, _ = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-07-01", - end_date="2026-07-31", - model=None, - api_key=None, - timezone_offset_minutes=None, - ) - for driver in self._drivers(): - assert f"SUM({driver})" in sql, f"{driver} is never summed, so it reads as zero" - def test_every_driver_is_accumulated_across_rows(self): for driver in self._drivers(): record = _no_spend_record() @@ -1558,20 +1537,6 @@ class TestResponseTimeSurvivesTheReadPath: _FIELDS = ("total_response_time_ms", "timed_requests") - def test_both_halves_are_summed_by_the_rollup_query(self): - sql, _ = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-09-01", - end_date="2026-09-30", - model=None, - api_key=None, - timezone_offset_minutes=None, - ) - for field in self._FIELDS: - assert f"SUM({field})" in sql, f"{field} is never summed, so the average reads as zero" - def test_accumulating_rows_keeps_sum_and_count_paired(self): first = _no_spend_record() first.total_response_time_ms = 1500 @@ -1608,6 +1573,12 @@ def ptu_cost_attribution_enabled(monkeypatch): def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost=0.0): return SimpleNamespace( api_key=api_key, + user_id=None, + team_id=None, + tag=None, + organization_id=None, + end_user_id=None, + agent_id=None, model=model, model_group=None, mcp_namespaced_tool_name=None, @@ -1630,6 +1601,7 @@ def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost= successful_requests=0, failed_requests=0, ptu_flat_cost=ptu_flat_cost, + request_id=None, ) @@ -1669,9 +1641,7 @@ def _grouping_row( spend=0.0, ptu_flat_cost=0.0, ): - from litellm.proxy.management_endpoints.common_daily_activity import _GroupingSetsRow - - return _GroupingSetsRow( + return GroupingSetsRow( date="2024-01-01", api_key=api_key, model=model, @@ -1680,6 +1650,7 @@ def _grouping_row( mcp_namespaced_tool_name=mcp_namespaced_tool_name, endpoint=endpoint, group_level=group_level, + distinct_api_keys=None, spend=spend, ptu_flat_cost=ptu_flat_cost, prompt_tokens=0, @@ -2186,57 +2157,6 @@ class TestFlagIsNotReadOnTheHotPath: assert reads > 0 -def test_entity_rollup_sql_query_and_api_key_list_filter(): - """The entity rollup companion query keeps its own two grouping sets keyed - by GROUPING(api_key), shares the WHERE builder (list api_key becomes a - parameterized IN, an empty list must match nothing), and the main - aggregated query stays entity-free.""" - from litellm.proxy.management_endpoints.common_daily_activity import ( - _build_entity_rollup_sql_query, - ) - - sql, params = _build_entity_rollup_sql_query( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=None, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=["key-1", "key-2"], - ) - assert '"team_id" AS entity_id' in sql - assert "GROUPING(api_key) AS api_key_rolled" in sql - assert '(date, "team_id"),' in sql - assert '(date, "team_id", api_key)' in sql - assert "api_key IN ($3, $4)" in sql - assert "SUM(ptu_flat_cost)::float" in sql - assert params == ["2024-01-01", "2024-01-31", "key-1", "key-2"] - - plain_sql, _ = _build_aggregated_sql_query( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=None, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=None, - ) - assert "entity_id" not in plain_sql - assert "GROUPING(date" in plain_sql - - empty_sql, empty_params = _build_aggregated_sql_query( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=None, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=[], - ) - assert "FALSE" in empty_sql - assert empty_params == ["2024-01-01", "2024-01-31"] - - @pytest.mark.asyncio async def test_get_daily_activity_aggregated_with_entity_breakdown(): """include_entity_breakdown must run the companion entity rollup query and @@ -2268,19 +2188,44 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): "successful_requests": 0, } main_rows = [ - {**base, "date": None, "group_level": 127, "spend": 18.0}, - {**base, "date": "2024-01-01", "group_level": 63, "spend": 18.0}, - {**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "spend": 18.0}, - {**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "spend": 12.0}, + {**base, "date": None, "group_level": 127, "distinct_api_keys": None, "spend": 18.0}, + {**base, "date": "2024-01-01", "group_level": 63, "distinct_api_keys": None, "spend": 18.0}, + {**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "distinct_api_keys": None, "spend": 18.0}, + {**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "distinct_api_keys": 1, "spend": 12.0}, ] - entity_base = { - key: value - for key, value in base.items() - if key not in ("model", "model_group", "custom_llm_provider", "mcp_namespaced_tool_name", "endpoint") + entity_base: Final = { + **{ + key: value + for key, value in base.items() + if key not in ("model", "model_group", "custom_llm_provider", "mcp_namespaced_tool_name", "endpoint") + }, + "distinct_api_keys": None, } entity_rows = [ - {**entity_base, "date": "2024-01-01", "entity_id": "team-a", "api_key_rolled": 1, "spend": 12.0}, - {**entity_base, "date": "2024-01-01", "entity_id": "team-b", "api_key_rolled": 1, "spend": 6.0}, + { + **entity_base, + "date": "2024-01-01", + "entity_id": "team-a", + "api_key_rolled": 1, + "distinct_api_keys": 3, + "spend": 12.0, + }, + { + **entity_base, + "date": "2024-01-01", + "entity_id": "team-b", + "api_key_rolled": 1, + "distinct_api_keys": 2, + "spend": 4.0, + }, + { + **entity_base, + "date": "2024-01-01", + "entity_id": None, + "api_key_rolled": 1, + "distinct_api_keys": 1, + "spend": 2.0, + }, { **entity_base, "date": "2024-01-01", @@ -2295,7 +2240,17 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): "entity_id": "team-b", "api_key": "key-2", "api_key_rolled": 0, - "spend": 6.0, + "distinct_api_keys": None, + "spend": 4.0, + }, + { + **entity_base, + "date": "2024-01-01", + "entity_id": None, + "api_key": "key-3", + "api_key_rolled": 0, + "distinct_api_keys": None, + "spend": 2.0, }, ] @@ -2320,22 +2275,25 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): main_sql = mock_prisma.db.query_raw.call_args_list[0][0][0] entity_sql = mock_prisma.db.query_raw.call_args_list[1][0][0] assert "entity_id" not in main_sql - assert '"team_id" AS entity_id' in entity_sql - assert '(date, "team_id"),' in entity_sql + assert "COALESCE(\"team_id\", '') AS entity_id" in entity_sql + assert "GROUP BY date, COALESCE(\"team_id\", '')" in entity_sql + assert '"team_id" AS entity_id' not in entity_sql assert result.metadata.total_spend == 18.0 + assert result.metadata.entity_total_api_keys == {"team-a": 3, "team-b": 2, "Unassigned": 1} assert len(result.results) == 1 daily = result.results[0] assert daily.metrics.spend == 18.0 entities = daily.breakdown.entities - assert set(entities) == {"team-a", "team-b"} + assert set(entities) == {"team-a", "team-b", "Unassigned"} assert entities["team-a"].metrics.spend == 12.0 assert entities["team-a"].metadata == {"team_alias": "Alpha"} assert entities["team-a"].api_key_breakdown["key-1"].metrics.spend == 12.0 - assert entities["team-b"].metrics.spend == 6.0 + assert entities["Unassigned"].api_key_breakdown["key-3"].metrics.spend == 2.0 + assert entities["team-b"].metrics.spend == 4.0 assert entities["team-b"].metadata == {} - assert entities["team-b"].api_key_breakdown["key-2"].metrics.spend == 6.0 + assert entities["team-b"].api_key_breakdown["key-2"].metrics.spend == 4.0 # Rollups with the entity bit set must still land in their usual buckets assert daily.breakdown.models["gpt-4o"].metrics.spend == 18.0 diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index d6be455a321..be8628d7f46 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2574,19 +2574,18 @@ async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch, assert result is mock_response # Verify the helper was called with the right parameters - mock_get_daily_agg.assert_called_once_with( - prisma_client=mock_prisma_client, - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id=None, # global view: no user_id filter - entity_metadata_field=None, - start_date="2025-02-01", - end_date="2025-02-28", - model="gpt-4", - api_key=None, - timezone_offset_minutes=480, - include_current_utc_day=include_current_utc_day, - ) + mock_get_daily_agg.assert_called_once() + repository, scope = mock_get_daily_agg.call_args.args + assert repository is not None + assert scope.table.value == "litellm_dailyuserspend" + assert scope.entity_id_field == "user_id" + assert scope.entity_ids is None + assert scope.start_date == "2025-02-01" + assert scope.end_date == "2025-02-28" + assert scope.model == "gpt-4" + assert scope.api_keys is None + assert scope.timezone_offset_minutes == 480 + assert scope.include_current_utc_day is include_current_utc_day @pytest.mark.asyncio @@ -2655,7 +2654,9 @@ async def test_get_user_daily_activity_aggregated_non_admin_cannot_view_other_us assert result is mock_response mock_get_daily_agg.assert_called_once() - assert mock_get_daily_agg.call_args.kwargs["entity_id"] == "regular-user-123" + repository, scope = mock_get_daily_agg.call_args.args + assert repository is not None + assert scope.entity_ids == ("regular-user-123",) @pytest.mark.asyncio diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index 6d56d325dc3..764193c9650 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -14669,15 +14669,17 @@ async def test_get_team_daily_activity_aggregated_scopes_and_flags(mock_db_clien ) mock_aggregated.assert_called_once() - call_kwargs = mock_aggregated.call_args[1] - assert call_kwargs["api_key"] == ["user_key_1"] - assert call_kwargs["entity_id"] == [team_id] + repository, scope = mock_aggregated.call_args.args + call_kwargs = mock_aggregated.call_args.kwargs + assert repository is not None + assert scope.api_keys == ("user_key_1",) + assert scope.entity_ids == (team_id,) assert call_kwargs["entity_metadata_field"] == { team_id: {"team_alias": "Test Team"} } assert call_kwargs["include_entity_breakdown"] is True - assert call_kwargs["timezone_offset_minutes"] == 480 - assert call_kwargs["table_name"] == "litellm_dailyteamspend" + assert scope.timezone_offset_minutes == 480 + assert scope.table.value == "litellm_dailyteamspend" @pytest.mark.asyncio diff --git a/tests/unit/proxy/proxy_server/test_lifecycle.py b/tests/unit/proxy/proxy_server/test_lifecycle.py index 4047473e9d7..ba5501315d9 100644 --- a/tests/unit/proxy/proxy_server/test_lifecycle.py +++ b/tests/unit/proxy/proxy_server/test_lifecycle.py @@ -17,19 +17,16 @@ Pins covered: from __future__ import annotations -import asyncio import inspect import json import logging import os import subprocess from collections.abc import Awaitable, Callable -from typing import List, Optional, Union from unittest.mock import AsyncMock, MagicMock, patch import pytest from apscheduler.schedulers.asyncio import AsyncIOScheduler -from fastapi import FastAPI from pydantic import BaseModel from typing_extensions import TypedDict @@ -682,16 +679,16 @@ class _SampleTD(TypedDict): def test_resolve_typed_dict_type_finds_class_in_optional(): - typ = Optional[_SampleTD] + typ = _SampleTD | None result = _resolve_typed_dict_type(typ) observed = { - "input_repr": "Optional[_SampleTD]", + "input_repr": "_SampleTD | None", "result_is_sample_td": result is _SampleTD, "result_is_class": isinstance(result, type), } assert normalize(observed) == { - "input_repr": "Optional[_SampleTD]", + "input_repr": "_SampleTD | None", "result_is_sample_td": True, "result_is_class": True, } @@ -717,7 +714,7 @@ class _SampleModelB(BaseModel): def test_resolve_pydantic_type_extracts_non_none_args_from_union(): - typ = Union[_SampleModelA, _SampleModelB, None] + typ = _SampleModelA | _SampleModelB | None result = _resolve_pydantic_type(typ) observed = { diff --git a/tests/unit/repositories/test_daily_activity_repository.py b/tests/unit/repositories/test_daily_activity_repository.py new file mode 100644 index 00000000000..f0bbfd32d2c --- /dev/null +++ b/tests/unit/repositories/test_daily_activity_repository.py @@ -0,0 +1,549 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Final + +import pytest +from pydantic import ValidationError + +from litellm import constants +from litellm.repositories.daily_activity_repository import DailyActivityRepository +from litellm.repositories.daily_activity_sql import ( + ExportCursor, + build_cache_leakage_keys_sql, + build_entity_rollup_sql, + build_export_sql, + build_key_page_sql, + build_key_search_sql, + build_model_top_keys_sql, +) +from litellm.types.repositories.daily_activity import ( + DailyActivityScope, + DailyActivityTable, + ExportType, + KeyMetadataRow, + KeyPage, + KeySpendRow, + SpendLogsWindow, +) + + +@dataclass(frozen=True, slots=True) +class _FakeVerificationToken: + token: str + key_alias: str | None + team_id: str | None + user_id: str | None + metadata: object | None + + +@dataclass(frozen=True, slots=True) +class _FakeDeletedVerificationToken(_FakeVerificationToken): + deleted_at: datetime + + +def _scope( + *, + table: DailyActivityTable = DailyActivityTable.USER, + entity_ids: tuple[str, ...] | None = ("user-1",), + api_keys: tuple[str, ...] | None = None, + exclude_entity_ids: tuple[str, ...] = (), + model: str | None = None, +) -> DailyActivityScope: + entity_field: Final = { + DailyActivityTable.USER: "user_id", + DailyActivityTable.TEAM: "team_id", + DailyActivityTable.TAG: "tag", + DailyActivityTable.ORGANIZATION: "organization_id", + DailyActivityTable.CUSTOMER: "end_user_id", + DailyActivityTable.AGENT: "agent_id", + }[table] + return DailyActivityScope( + table=table, + entity_id_field=entity_field, + entity_ids=entity_ids, + exclude_entity_ids=exclude_entity_ids, + api_keys=api_keys, + start_date="2026-01-01", + end_date="2026-01-31", + model=model, + timezone_offset_minutes=None, + ) + + +def _key_spend_row(api_key: str) -> dict[str, object]: + return { + "api_key": api_key, + "spend": 1.0, + "prompt_tokens": 10, + "completion_tokens": 2, + "total_tokens": 12, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + "cache_read_input_tokens": 3, + "cache_creation_input_tokens": 1, + } + + +def _export_row(api_key: str | None) -> dict[str, object]: + return { + "date": "2026-01-01", + "entity_id": "user-1", + "entity_alias": None, + "api_key": api_key, + "key_alias": None, + "user_id": None, + "user_email": None, + "model": None, + "spend": 1.0, + "flat_cost": 0.0, + "prompt_tokens": 10, + "completion_tokens": 2, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + "cache_read_input_tokens": 3, + "cache_creation_input_tokens": 1, + } + + +class _FakeTable: + def __init__(self, rows: Sequence[object] = ()) -> None: + self.rows: Final = tuple(rows) + self.find_many_calls: list[Mapping[str, object]] = [] + self.count_calls: list[Mapping[str, object]] = [] + self.pagination_calls: list[tuple[int | None, int | None, tuple[Mapping[str, str], ...] | None]] = [] + + async def find_many( + self, + *, + where: Mapping[str, object], + skip: int | None = None, + take: int | None = None, + order: tuple[Mapping[str, str], ...] | None = None, + ) -> tuple[object, ...]: + self.find_many_calls.append(where) + self.pagination_calls.append((skip, take, order)) + if "token" not in where: + return self.rows + token_filter: Final = where["token"] + if not isinstance(token_filter, Mapping): + return () + token_values: Final = token_filter.get("in") + if not isinstance(token_values, list): + return () + return tuple(row for row in self.rows if isinstance(row, _FakeVerificationToken) and row.token in token_values) + + async def count(self, *, where: Mapping[str, object]) -> int: + self.count_calls.append(where) + return len(self.rows) + + +class _FailingTable(_FakeTable): + def __init__(self, failure: str) -> None: + super().__init__() + self.failure: Final = failure + + async def find_many( + self, + *, + where: Mapping[str, object], + skip: int | None = None, + take: int | None = None, + order: tuple[Mapping[str, str], ...] | None = None, + ) -> tuple[object, ...]: + raise RuntimeError(f"{self.failure}: {where!r} {skip!r} {take!r} {order!r}") + + +class _FakeDatabase: + def __init__(self, responses: Sequence[Sequence[Mapping[str, object]] | None] = ()) -> None: + self.responses = tuple(responses) + self.query_calls: list[tuple[str, tuple[object, ...]]] = [] + self.litellm_verificationtoken = _FakeTable() + self.litellm_deletedverificationtoken = _FakeTable() + self.litellm_dailyuserspend = _FakeTable() + self.litellm_dailyteamspend = _FakeTable() + self.litellm_dailytagspend = _FakeTable() + self.litellm_dailyorganizationspend = _FakeTable() + self.litellm_dailyenduserspend = _FakeTable() + self.litellm_dailyagentspend = _FakeTable() + + async def query_raw(self, query: str, *params: object) -> Sequence[Mapping[str, object]] | None: + self.query_calls.append((query, params)) + response_index: Final = len(self.query_calls) - 1 + if response_index >= len(self.responses): + return () + return self.responses[response_index] + + +class _FakePrismaClient: + def __init__(self, database: _FakeDatabase) -> None: + self.db: Final = database + + +class _ProxyReads: + def __init__(self) -> None: + self.recovery_calls: list[tuple[Mapping[str, KeyMetadataRow], frozenset[str], SpendLogsWindow | None]] = [] + + async def recover_key_metadata( + self, + resolved: Mapping[str, KeyMetadataRow], + api_keys: frozenset[str], + window: SpendLogsWindow | None, + ) -> Mapping[str, KeyMetadataRow]: + self.recovery_calls.append((resolved, api_keys, window)) + return resolved + + +def _repository( + database: _FakeDatabase, proxy_reads: _ProxyReads | None = None +) -> tuple[DailyActivityRepository, _ProxyReads]: + reads: Final = proxy_reads if proxy_reads is not None else _ProxyReads() + return DailyActivityRepository(_FakePrismaClient(database), proxy_reads=reads), reads + + +@pytest.mark.asyncio +async def test_key_methods_send_builder_queries_with_caller_limits() -> None: + database = _FakeDatabase(((_key_spend_row("key-a"),), (_key_spend_row("key-b"),), (_key_spend_row("key-c"),))) + repository, _ = _repository(database) + scope = _scope() + + assert await repository.search_keys(scope, search="key", limit=2) == ("key-a",) + model_keys: Final = await repository.model_top_keys(scope, model_group="model-a", by_model_group=True, limit=2) + leakage_keys: Final = await repository.cache_leakage_keys(scope, limit=2) + + assert tuple(row.api_key for row in model_keys) == ("key-b",) + assert tuple(row.api_key for row in leakage_keys) == ("key-c",) + assert model_keys[0].spend == 1.0 + assert leakage_keys[0].prompt_tokens - leakage_keys[0].cache_read_input_tokens == 7 + assert database.query_calls == [ + ( + build_key_search_sql(scope, search="key", limit=2).sql, + build_key_search_sql(scope, search="key", limit=2).params, + ), + ( + build_model_top_keys_sql(scope, model_group="model-a", by_model_group=True, limit=2).sql, + build_model_top_keys_sql(scope, model_group="model-a", by_model_group=True, limit=2).params, + ), + ( + build_cache_leakage_keys_sql(scope, limit=2).sql, + build_cache_leakage_keys_sql(scope, limit=2).params, + ), + ] + + +@pytest.mark.asyncio +async def test_key_page_maps_rows_and_keeps_total_for_an_empty_page() -> None: + database = _FakeDatabase( + ( + ({"total_api_keys": 2, **_key_spend_row("key-a")},), + ({"total_api_keys": 2, "api_key": None},), + ) + ) + repository, _ = _repository(database) + scope = _scope() + + first_page: Final = await repository.key_page(scope, offset=0, limit=1) + empty_page: Final = await repository.key_page(scope, offset=2, limit=1) + + assert first_page == KeyPage( + rows=( + KeySpendRow( + api_key="key-a", + spend=1.0, + prompt_tokens=10, + completion_tokens=2, + total_tokens=12, + api_requests=1, + successful_requests=1, + failed_requests=0, + cache_read_input_tokens=3, + cache_creation_input_tokens=1, + ), + ), + total_api_keys=2, + ) + assert empty_page == KeyPage(rows=(), total_api_keys=2) + assert database.query_calls == [ + ( + build_key_page_sql(scope, offset=0, limit=1).sql, + build_key_page_sql(scope, offset=0, limit=1).params, + ), + ( + build_key_page_sql(scope, offset=2, limit=1).sql, + build_key_page_sql(scope, offset=2, limit=1).params, + ), + ] + + +@pytest.mark.asyncio +async def test_key_methods_reject_limits_outside_bounds() -> None: + database = _FakeDatabase() + repository, _ = _repository(database) + + with pytest.raises(ValueError, match="limit"): + await repository.search_keys(_scope(), search="key", limit=0) + with pytest.raises(ValueError, match="limit"): + await repository.model_top_keys(_scope(), model_group="model-a", by_model_group=False, limit=0) + with pytest.raises(ValueError, match="limit"): + await repository.cache_leakage_keys(_scope(), limit=0) + with pytest.raises(ValueError, match="limit"): + await repository.search_keys(_scope(), search="key", limit=constants.USAGE_KEY_SEARCH_MAX + 1) + with pytest.raises(ValueError, match="limit"): + await repository.model_top_keys( + _scope(), model_group="model-a", by_model_group=False, limit=constants.USAGE_MODEL_TOP_KEYS_MAX + 1 + ) + with pytest.raises(ValueError, match="limit"): + await repository.cache_leakage_keys(_scope(), limit=constants.USAGE_CACHE_LEAKAGE_KEYS_MAX + 1) + assert database.query_calls == [] + + +@pytest.mark.asyncio +async def test_key_spend_validation_rejects_malformed_rows() -> None: + repository, _ = _repository(_FakeDatabase((({"api_key": "missing-metrics"},),))) + + with pytest.raises(ValidationError): + await repository.search_keys(_scope(), search="key", limit=1) + + +@pytest.mark.asyncio +async def test_key_metadata_prefers_active_rows_and_recovers_all_requested_keys() -> None: + database = _FakeDatabase() + active: Final = _FakeVerificationToken( + token="active", + key_alias="current", + team_id="team-active", + user_id="user-active", + metadata={"tags": ["production", "internal"]}, + ) + deleted_active_duplicate: Final = _FakeDeletedVerificationToken( + token="active", + key_alias="stale", + team_id="team-stale", + user_id="user-stale", + metadata={"tags": []}, + deleted_at=datetime(2026, 1, 3, tzinfo=timezone.utc), + ) + deleted_older: Final = _FakeDeletedVerificationToken( + token="deleted", + key_alias="older", + team_id=None, + user_id=None, + metadata={"tags": "invalid"}, + deleted_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + ) + deleted_newer: Final = _FakeDeletedVerificationToken( + token="deleted", + key_alias="newer", + team_id=None, + user_id=None, + metadata={"tags": ["archived"]}, + deleted_at=datetime(2026, 1, 4, tzinfo=timezone.utc), + ) + malformed_non_list: Final = _FakeVerificationToken( + token="malformed-non-list", + key_alias=None, + team_id=None, + user_id=None, + metadata={"tags": "invalid"}, + ) + malformed_list: Final = _FakeVerificationToken( + token="malformed-list", + key_alias=None, + team_id=None, + user_id=None, + metadata={"tags": [1]}, + ) + database.litellm_verificationtoken = _FakeTable((active, malformed_non_list, malformed_list)) + database.litellm_deletedverificationtoken = _FakeTable((deleted_active_duplicate, deleted_older, deleted_newer)) + proxy_reads: Final = _ProxyReads() + repository, _ = _repository(database, proxy_reads) + window: Final = (datetime(2026, 1, 1), datetime(2026, 2, 1)) + requested: Final = frozenset(("active", "deleted", "malformed-non-list", "malformed-list", "unresolved")) + + result = await repository.key_metadata(requested, window) + + assert result["active"] == KeyMetadataRow( + api_key="active", + key_alias="current", + team_id="team-active", + user_id="user-active", + user_email=None, + key_exists=True, + tags=("production", "internal"), + ) + assert result["deleted"].key_alias == "newer" + assert result["deleted"].key_exists is False + assert result["deleted"].tags == ("archived",) + assert result["malformed-non-list"].tags == () + assert result["malformed-list"].tags == () + assert len(database.litellm_deletedverificationtoken.find_many_calls) == 1 + assert set(database.litellm_deletedverificationtoken.find_many_calls[0]["token"]["in"]) == { + "deleted", + "unresolved", + } + assert proxy_reads.recovery_calls == [ + ( + result, + requested, + window, + ) + ] + + +@pytest.mark.asyncio +async def test_key_metadata_continues_with_active_rows_when_deleted_lookup_fails() -> None: + database = _FakeDatabase() + active: Final = _FakeVerificationToken( + token="active", + key_alias="current", + team_id=None, + user_id=None, + metadata={"tags": []}, + ) + database.litellm_verificationtoken = _FakeTable((active,)) + database.litellm_deletedverificationtoken = _FailingTable("deleted token query failed") + repository, proxy_reads = _repository(database) + + result = await repository.key_metadata(frozenset(("active", "deleted")), None) + + assert result["active"].key_alias == "current" + assert tuple(proxy_reads.recovery_calls[0][0]) == ("active",) + assert proxy_reads.recovery_calls[0][1] == frozenset(("active", "deleted")) + + +@pytest.mark.asyncio +async def test_key_metadata_empty_set_does_not_query_tables() -> None: + database = _FakeDatabase() + repository, proxy_reads = _repository(database) + + assert await repository.key_metadata(frozenset(), None) == {} + assert database.litellm_verificationtoken.find_many_calls == [] + assert proxy_reads.recovery_calls == [] + + +@pytest.mark.asyncio +async def test_key_metadata_propagates_active_token_lookup_failures() -> None: + database = _FakeDatabase() + database.litellm_verificationtoken = _FailingTable("active token query failed") + repository, _ = _repository(database) + + with pytest.raises(RuntimeError, match="active token query failed"): + await repository.key_metadata(frozenset(("active",)), None) + + assert database.litellm_deletedverificationtoken.find_many_calls == [] + + +@pytest.mark.asyncio +async def test_aggregated_normalizes_a_null_raw_query_result() -> None: + database = _FakeDatabase((None,)) + repository, _ = _repository(database) + + result = await repository.aggregated( + _scope(), include_entity_breakdown=False, api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT + ) + + assert result.grouping_rows == () + assert result.entity_rows is None + assert result.distinct_api_keys == 0 + assert len(database.query_calls) == 1 + + +@pytest.mark.asyncio +async def test_aggregated_passes_api_key_limit_to_entity_rollup_query() -> None: + database = _FakeDatabase(((), ())) + repository, _ = _repository(database) + scope = _scope(table=DailyActivityTable.TEAM) + + result = await repository.aggregated(scope, include_entity_breakdown=True, api_key_limit=3) + + assert result.entity_rows == () + assert database.query_calls[1] == ( + build_entity_rollup_sql(scope, api_key_limit=3).sql, + build_entity_rollup_sql(scope, api_key_limit=3).params, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("table", "entity_field"), + [ + (DailyActivityTable.USER, "user_id"), + (DailyActivityTable.TEAM, "team_id"), + (DailyActivityTable.TAG, "tag"), + (DailyActivityTable.ORGANIZATION, "organization_id"), + (DailyActivityTable.CUSTOMER, "end_user_id"), + (DailyActivityTable.AGENT, "agent_id"), + ], +) +async def test_daily_rows_selects_the_table_and_applies_filters_and_pagination( + table: DailyActivityTable, entity_field: str +) -> None: + database = _FakeDatabase() + repository, _ = _repository(database) + scope = _scope( + table=table, + entity_ids=("entity-1",), + exclude_entity_ids=("excluded-1",), + api_keys=("key-1",), + model="model-1", + ) + + result = await repository.daily_rows(scope, page=3, page_size=2) + + expected_where: Final = { + "date": {"gte": "2026-01-01", "lte": "2026-01-31"}, + entity_field: {"in": ["entity-1"], "not": {"in": ["excluded-1"]}}, + "model": "model-1", + "api_key": {"in": ["key-1"]}, + } + tables: Final = { + DailyActivityTable.USER: database.litellm_dailyuserspend, + DailyActivityTable.TEAM: database.litellm_dailyteamspend, + DailyActivityTable.TAG: database.litellm_dailytagspend, + DailyActivityTable.ORGANIZATION: database.litellm_dailyorganizationspend, + DailyActivityTable.CUSTOMER: database.litellm_dailyenduserspend, + DailyActivityTable.AGENT: database.litellm_dailyagentspend, + } + selected_table: Final = tables[table] + + assert result.total_count == 0 + assert result.rows == () + assert selected_table.count_calls == [expected_where] + assert selected_table.find_many_calls == [expected_where] + assert selected_table.pagination_calls == [(4, 2, ({"date": "desc"}, {"id": "asc"}))] + assert sum(len(daily_table.find_many_calls) for daily_table in tables.values()) == 1 + + +@pytest.mark.asyncio +async def test_export_is_lazy_and_uses_the_last_row_as_the_next_cursor(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 2) + database = _FakeDatabase( + ( + (_export_row("key-1"), _export_row("key-2")), + (_export_row("key-3"), _export_row("key-4")), + (_export_row("key-5"),), + ) + ) + repository, _ = _repository(database) + rows = repository.export_rows(_scope(), export_type=ExportType.DAILY_WITH_KEYS) + + assert database.query_calls == [] + assert (await rows.__anext__()).api_key == "key-1" + assert len(database.query_calls) == 1 + results = [row async for row in rows] + + assert [row.api_key for row in results] == ["key-2", "key-3", "key-4", "key-5"] + assert len(database.query_calls) == 3 + assert database.query_calls[1][1][-4:] == ("2026-01-01", "user-1", "key-2", 2) + assert database.query_calls[2][1][-4:] == ("2026-01-01", "user-1", "key-4", 2) + assert ( + build_export_sql( + _scope(), + export_type=ExportType.DAILY_WITH_KEYS, + after=ExportCursor("2026-01-01", "user-1", "key-2"), + batch_size=2, + ).params + == database.query_calls[1][1] + ) diff --git a/tests/unit/repositories/test_daily_activity_sql.py b/tests/unit/repositories/test_daily_activity_sql.py new file mode 100644 index 00000000000..67f15712b11 --- /dev/null +++ b/tests/unit/repositories/test_daily_activity_sql.py @@ -0,0 +1,411 @@ +from datetime import datetime, timezone +from typing import Final + +import pytest + +from litellm import constants +from litellm.constants import PTU_SENTINEL_API_KEY +from litellm.repositories.daily_activity_sql import ( + ExportCursor, + adjust_dates_for_timezone, + build_aggregated_sql, + build_cache_leakage_keys_sql, + build_entity_rollup_sql, + build_export_sql, + build_key_page_sql, + build_key_search_sql, + build_model_top_keys_sql, + build_where_clause, +) +from litellm.types.proxy.management_endpoints.common_daily_activity import SpendMetrics +from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable, ExportType + + +def _scope( + *, + table: DailyActivityTable = DailyActivityTable.USER, + entity_ids: tuple[str, ...] | None = ("user-1",), + exclude_entity_ids: tuple[str, ...] = (), + api_keys: tuple[str, ...] | None = None, + model: str | None = None, + timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, + start_date: str = "2026-01-01", + end_date: str = "2026-01-31", +) -> DailyActivityScope: + entity_field = { + DailyActivityTable.USER: "user_id", + DailyActivityTable.TEAM: "team_id", + DailyActivityTable.TAG: "tag", + DailyActivityTable.ORGANIZATION: "organization_id", + DailyActivityTable.CUSTOMER: "end_user_id", + DailyActivityTable.AGENT: "agent_id", + }[table] + return DailyActivityScope( + table=table, + entity_id_field=entity_field, + entity_ids=entity_ids, + exclude_entity_ids=exclude_entity_ids, + api_keys=api_keys, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, + ) + + +def test_where_clause_binds_each_filter_as_a_single_array_parameter() -> None: + scope = _scope( + entity_ids=("user-1", "user-2"), + exclude_entity_ids=("user-3",), + api_keys=("key-1", "key-2"), + model="gpt-test", + ) + + sql, params = build_where_clause(scope) + + assert sql == ( + 'date >= $1 AND date <= $2 AND "user_id" = ANY($3::text[]) ' + 'AND NOT ("user_id" = ANY($4::text[])) AND model = $5 AND api_key = ANY($6::text[])' + ) + assert params == ( + "2026-01-01", + "2026-01-31", + ["user-1", "user-2"], + ["user-3"], + "gpt-test", + ["key-1", "key-2"], + ) + + +@pytest.mark.parametrize( + ("entity_ids", "api_keys", "expected_sql", "expected_params"), + [ + (None, None, "date >= $1 AND date <= $2", ("2026-01-01", "2026-01-31")), + ((), None, "date >= $1 AND date <= $2 AND FALSE", ("2026-01-01", "2026-01-31")), + (None, (), "date >= $1 AND date <= $2 AND FALSE", ("2026-01-01", "2026-01-31")), + ], +) +def test_where_clause_distinguishes_no_filter_from_empty_membership( + entity_ids: tuple[str, ...] | None, + api_keys: tuple[str, ...] | None, + expected_sql: str, + expected_params: tuple[object, ...], +) -> None: + scope = _scope(entity_ids=entity_ids, api_keys=api_keys) + + sql, params = build_where_clause(scope) + + assert sql == expected_sql + assert params == expected_params + + +def test_key_page_sql_orders_exact_spend_and_binds_scope_before_page() -> None: + query = build_key_page_sql(_scope(), offset=7, limit=3) + + assert query.params == ( + "2026-01-01", + "2026-01-31", + ["user-1"], + PTU_SENTINEL_API_KEY, + 3, + 7, + ) + assert "SUM(spend::numeric) AS rank_spend" in query.sql + assert "ORDER BY rank_spend DESC, api_key" in query.sql + assert "(SELECT COUNT(*) FROM ranked)::bigint AS total_api_keys" in query.sql + + +@pytest.mark.parametrize( + ("offset", "limit", "error"), + ( + (0, 0, "limit must be between"), + (0, constants.USAGE_KEY_PAGE_MAX + 1, "limit must be between"), + (-1, 1, "offset must be non-negative"), + ), +) +def test_key_page_sql_rejects_invalid_page_bounds(offset: int, limit: int, error: str) -> None: + with pytest.raises(ValueError, match=error): + build_key_page_sql(_scope(), offset=offset, limit=limit) + + +def test_scope_rejects_an_entity_field_not_allowed_for_its_table() -> None: + with pytest.raises(ValueError, match="Invalid entity_id_field"): + DailyActivityScope( + table=DailyActivityTable.USER, + entity_id_field="team_id", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-01-01", + end_date="2026-01-31", + model=None, + timezone_offset_minutes=None, + ) + + +def test_timezone_adjustment_only_extends_an_opted_in_live_range() -> None: + now = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) + + assert adjust_dates_for_timezone("2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=now) == ( + "2026-07-06", + "2026-08-06", + ) + assert adjust_dates_for_timezone("2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=now) == ( + "2026-07-01", + "2026-08-04", + ) + + +@pytest.mark.parametrize("offset_minutes", [None, 0, -330, -540, -60, 240, 300, 480]) +def test_timezone_adjustment_preserves_daily_bucket_dates(offset_minutes: int | None) -> None: + assert adjust_dates_for_timezone("2026-05-29", "2026-05-29", offset_minutes) == ( + "2026-05-29", + "2026-05-29", + ) + + +@pytest.mark.parametrize("offset_minutes", [-330, 480]) +def test_timezone_adjustment_preserves_single_day_additivity(offset_minutes: int) -> None: + days: Final = ("2026-05-29", "2026-05-30", "2026-05-31", "2026-06-01", "2026-06-02") + single_day_ranges: Final = tuple(adjust_dates_for_timezone(day, day, offset_minutes) for day in days) + multi_day_range: Final = adjust_dates_for_timezone(days[0], days[-1], offset_minutes) + + assert tuple(start for start, _ in single_day_ranges) == days + assert tuple(end for _, end in single_day_ranges) == days + assert (min(start for start, _ in single_day_ranges), max(end for _, end in single_day_ranges)) == multi_day_range + + +def test_timezone_adjustment_live_end_handles_offset_and_opt_in_cases() -> None: + pt_evening: Final = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) + ist_evening: Final = datetime(2026, 8, 5, 17, 0, tzinfo=timezone.utc) + utc_noon: Final = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-06") + assert adjust_dates_for_timezone("2026-07-06", "2026-08-05", 420, utc_now=pt_evening) == ( + "2026-07-06", + "2026-08-05", + ) + assert adjust_dates_for_timezone( + "2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-01", "2026-08-04") + assert adjust_dates_for_timezone( + "2026-07-07", "2026-08-06", -330, include_current_utc_day=True, utc_now=ist_evening + ) == ("2026-07-07", "2026-08-06") + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", None, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-05") + assert adjust_dates_for_timezone("2026-07-06", "2026-08-05", 0, include_current_utc_day=True, utc_now=utc_noon) == ( + "2026-07-06", + "2026-08-05", + ) + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-09", 420, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-09") + + +@pytest.mark.parametrize("offset_minutes", [None, 0, -330, 480]) +def test_aggregated_query_uses_the_caller_date_bounds(offset_minutes: int | None) -> None: + query = build_aggregated_sql( + _scope( + timezone_offset_minutes=offset_minutes, + start_date="2026-05-29", + end_date="2026-05-29", + ), + api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT, + ) + + assert query.params[:2] == ("2026-05-29", "2026-05-29") + assert "date >= $1" in query.sql + assert "date <= $2" in query.sql + + +def test_aggregate_query_sums_all_savings_drivers_and_response_time() -> None: + query = build_aggregated_sql(_scope(), api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT) + fields: Final = tuple(field for field in SpendMetrics.model_fields if field.endswith("_savings_spend")) + ( + "total_response_time_ms", + "timed_requests", + ) + + assert fields + assert all(f"SUM({field})" in query.sql for field in fields) + + +def test_aggregated_query_binds_sentinel_and_api_key_limit_after_scope_values() -> None: + scope = _scope(entity_ids=None, api_keys=("key-1",)) + + query = build_aggregated_sql(scope, api_key_limit=3) + + assert "api_key <> $4" in query.sql + assert "LIMIT $5" in query.sql + assert 'FROM "LiteLLM_DailyUserSpend"' in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + ["key-1"], + PTU_SENTINEL_API_KEY, + 3, + ) + + +@pytest.mark.parametrize("api_key_limit", [0, constants.USAGE_TOP_API_KEYS_MAX + 1]) +def test_aggregated_query_rejects_api_key_limits_outside_bounds(api_key_limit: int) -> None: + with pytest.raises(ValueError, match="api_key_limit"): + build_aggregated_sql(_scope(), api_key_limit=api_key_limit) + + +def test_entity_rollup_bounds_keys_and_reuses_scope_filters() -> None: + query = build_entity_rollup_sql( + _scope(table=DailyActivityTable.TEAM, entity_ids=None, api_keys=("key-1", "key-2")), + api_key_limit=3, + ) + + assert query.sql.count("COALESCE(\"team_id\", '') AS entity_id") == 3 + assert query.sql.count("GROUP BY date, COALESCE(\"team_id\", '')") == 2 + assert '"team_id" AS entity_id' not in query.sql + assert "JOIN top_api_keys USING (api_key)" in query.sql + assert "api_key = ANY($3::text[])" in query.sql + assert query.sql.count("api_key = ANY($3::text[])") == 4 + assert query.sql.count("api_key <> $4") == 2 + assert query.sql.count("ORDER BY SUM(spend::numeric) DESC, api_key") == 1 + assert "k.entity_id = e.entity_id" in query.sql + assert "LIMIT $5" in query.sql + assert query.params == ("2026-01-01", "2026-01-31", ["key-1", "key-2"], PTU_SENTINEL_API_KEY, 3) + + +@pytest.mark.parametrize("api_key_limit", [0, constants.USAGE_TOP_API_KEYS_MAX + 1]) +def test_entity_rollup_rejects_api_key_limits_outside_bounds(api_key_limit: int) -> None: + with pytest.raises(ValueError, match="api_key_limit"): + build_entity_rollup_sql(_scope(), api_key_limit=api_key_limit) + + +def test_search_query_escapes_pattern_metacharacters_and_binds_limit() -> None: + query = build_key_search_sql(_scope(entity_ids=None), search=r"foo%_\bar", limit=4) + + assert "OR api_key IN (" in query.sql + assert 'SELECT v.token FROM "LiteLLM_VerificationToken" v' in query.sql + assert 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = v.user_id' in query.sql + assert 'SELECT d.token FROM "LiteLLM_DeletedVerificationToken" d' in query.sql + assert 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = d.user_id' in query.sql + assert "d.key_alias ILIKE $3 ESCAPE" in query.sql + assert "d.user_id ILIKE $3 ESCAPE" in query.sql + assert "api_key ILIKE $3 ESCAPE" in query.sql + assert "v.key_alias ILIKE $3 ESCAPE" in query.sql + assert "v.user_id ILIKE $3 ESCAPE" in query.sql + assert "u.user_email ILIKE $3 ESCAPE" in query.sql + assert query.sql.count("ILIKE $3 ESCAPE") == 7 + assert "api_key <> $4" in query.sql + assert "ORDER BY SUM(spend::numeric) DESC, api_key" in query.sql + assert "LIMIT $5" in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + r"%foo\%\_\\bar%", + PTU_SENTINEL_API_KEY, + 4, + ) + + +def test_model_and_cache_key_queries_bind_filters_sentinel_and_limits() -> None: + model_query = build_model_top_keys_sql( + _scope(entity_ids=None), model_group="public-model", by_model_group=True, limit=5 + ) + leakage_query = build_cache_leakage_keys_sql(_scope(entity_ids=None), limit=20) + + assert "COALESCE(NULLIF(model_group, ''), model) = $3" in model_query.sql + assert "api_key <> $4" in model_query.sql + assert "ORDER BY SUM(spend::numeric) DESC, api_key" in model_query.sql + assert model_query.params == ("2026-01-01", "2026-01-31", "public-model", PTU_SENTINEL_API_KEY, 5) + assert "HAVING SUM(prompt_tokens) - SUM(cache_read_input_tokens) > 0" in leakage_query.sql + assert "ORDER BY SUM(prompt_tokens) - SUM(cache_read_input_tokens) DESC, api_key" in leakage_query.sql + assert leakage_query.params == ("2026-01-01", "2026-01-31", PTU_SENTINEL_API_KEY, 20) + + +@pytest.mark.parametrize( + "builder", + [ + lambda: build_key_search_sql(_scope(), search="x", limit=0), + lambda: build_model_top_keys_sql(_scope(), model_group="x", by_model_group=False, limit=0), + lambda: build_cache_leakage_keys_sql(_scope(), limit=0), + lambda: build_export_sql(_scope(), export_type=ExportType.DAILY, after=None, batch_size=0), + ], +) +def test_query_builders_reject_nonpositive_limits(builder) -> None: + with pytest.raises(ValueError, match="limit must be at least 1"): + builder() + + +@pytest.mark.parametrize( + ("export_type", "group_key", "key_filter", "joins"), + [ + (ExportType.DAILY, "''", "", ""), + (ExportType.DAILY_WITH_KEYS, "scoped.api_key", "api_key <> $3", 'LEFT JOIN "LiteLLM_VerificationToken"'), + (ExportType.DAILY_WITH_MODELS, "COALESCE(scoped.model, '')", "api_key <> $3", ""), + ( + ExportType.DAILY_WITH_USERS, + "COALESCE(vt.user_id, dvt.user_id, '')", + "api_key <> $3", + 'LEFT JOIN "LiteLLM_VerificationToken"', + ), + ], +) +def test_export_groups_by_requested_key_and_binds_cursor_after_scope( + export_type: ExportType, group_key: str, key_filter: str, joins: str +) -> None: + query = build_export_sql( + _scope(entity_ids=None), + export_type=export_type, + after=ExportCursor(date="2026-01-12", entity_id="user-2", group_key="group-3"), + batch_size=2, + ) + + assert group_key in query.sql + assert key_filter in query.sql + assert joins in query.sql + assert "(scoped.date, COALESCE(scoped.\"user_id\", '')," in query.sql + order_keys: Final = ( + "scoped.date, COALESCE(scoped.\"user_id\", '')", + *((group_key,) if export_type is not ExportType.DAILY else ()), + ) + assert f"ORDER BY {', '.join(order_keys)}" in query.sql + expected_limit_index: Final = "$6" if export_type is ExportType.DAILY else "$7" + assert f"LIMIT {expected_limit_index}" in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + *((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()), + "2026-01-12", + "user-2", + "group-3", + 2, + ) + + +@pytest.mark.parametrize("export_type", [ExportType.DAILY_WITH_KEYS, ExportType.DAILY_WITH_USERS]) +def test_export_uses_latest_deleted_key_metadata(export_type: ExportType) -> None: + query = build_export_sql(_scope(entity_ids=None), export_type=export_type, after=None, batch_size=2) + + assert 'FROM "LiteLLM_DeletedVerificationToken"' in query.sql + assert "ORDER BY deleted_at DESC" in query.sql + assert "COALESCE(vt.user_id, dvt.user_id)" in query.sql + + +@pytest.mark.parametrize("export_type", tuple(ExportType)) +def test_export_without_cursor_omits_cursor_predicate_and_parameters(export_type: ExportType) -> None: + query = build_export_sql( + _scope(entity_ids=None), + export_type=export_type, + after=None, + batch_size=2, + ) + + assert "WHERE TRUE AND (scoped.date" not in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + *((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()), + 2, + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ebc6d0e70cc..7bce039cd3e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -30182,6 +30182,18 @@ export interface components { }; /** DailySpendMetadata */ DailySpendMetadata: { + /** + * Api Key Limit + * @description When set, api_keys and every api_key_breakdown list at most this many keys, ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key. + */ + api_key_limit?: number | null; + /** + * Entity Total Api Keys + * @description Distinct API keys per entity over the requested range, set when the entity breakdown is included. When an entity's count exceeds api_key_limit, its api_key_breakdown lists only its keys among the top api_key_limit keys overall. + */ + entity_total_api_keys?: { + [key: string]: number; + } | null; /** * Has More * @default false @@ -30192,6 +30204,11 @@ export interface components { * @default 1 */ page: number; + /** + * Total Api Keys + * @description Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key lists are truncated to the highest-spend keys. + */ + total_api_keys?: number | null; /** * Total Api Requests * @default 0 From 339f5b6a4dc7c30e9281861dbe631c69a18a12f6 Mon Sep 17 00:00:00 2001 From: shrey-berri Date: Thu, 1 Oct 2026 16:42:46 -0700 Subject: [PATCH 05/83] fix(bedrock): add beta for mid-conversation tool changes (#43833) --- litellm/llms/anthropic/common_utils.py | 25 ++++++++- .../pass_through/messages/transformation.py | 8 ++- .../anthropic_claude3_transformation.py | 3 ++ litellm/types/llms/anthropic.py | 1 + ...est_anthropic_messages_per_turn_control.py | 30 +++++++++++ .../anthropic/test_anthropic_common_utils.py | 54 +++++++++++++++++++ .../test_anthropic_claude3_transformation.py | 51 ++++++++++++++++++ 7 files changed, 169 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 30e521b7671..65c2fccceeb 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -27,11 +27,13 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( from litellm.litellm_core_utils.prompt_templates.factory import ( THOUGHT_SIGNATURE_SEPARATOR, ) +from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.llms.anthropic import ( ANTHROPIC_HOSTED_TOOLS, ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER, + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, ANTHROPIC_OAUTH_BETA_HEADER, ANTHROPIC_OAUTH_TOKEN_PREFIX, ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, @@ -344,6 +346,18 @@ class AnthropicModelInfo(BaseLLMModelInfo): return False return thinking.get("type") in ("adaptive", "enabled") and thinking.get("display") == "updates" + def is_mid_conversation_tool_change_used(self, messages: Sequence[object]) -> bool: + for message in messages: + if message_field(message, "role") != "system": + continue + for block in parts_of(message_field(message, "content")): + if ( + message_field(block, "type") in ("tool_addition", "tool_removal") + and message_field(message_field(block, "tool"), "type") == "tool_reference" + ): + return True + return False + def is_mid_conversation_output_config_used(self, messages: list[AllMessageValues]) -> bool: """ Return if "output_config" is in a message @@ -881,6 +895,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): custom_llm_provider: str, is_mid_conversation_output_config_used: bool = False, is_thinking_display_updates_used: bool = False, + is_mid_conversation_tool_change_used: bool = False, ) -> list[str]: """ Get list of common beta headers based on the features that are active. @@ -919,7 +934,10 @@ class AnthropicModelInfo(BaseLLMModelInfo): thinking_display_betas: Final = ( (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else () ) - return list(set(betas).union(thinking_display_betas)) + tool_change_betas: Final = ( + (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else () + ) + return list(set(betas).union(thinking_display_betas, tool_change_betas)) @staticmethod def _make_api_key_auth_header(api_key: str, api_base: str | None, use_bearer_for_custom_base: bool = False) -> dict: @@ -953,6 +971,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): use_bearer_for_custom_base: bool = False, is_mid_conversation_output_config_used: bool = False, is_thinking_display_updates_used: bool = False, + is_mid_conversation_tool_change_used: bool = False, ) -> dict: betas: Final = set() # Anthropic no longer requires the prompt-caching beta header @@ -1010,7 +1029,8 @@ class AnthropicModelInfo(BaseLLMModelInfo): betas.update(user_anthropic_beta_headers) all_betas: Final = betas.union( - (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else () + (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else (), + (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else (), ) # Don't send any beta headers to Vertex, except web search which is required @@ -1080,6 +1100,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): file_id_used=file_id_used, is_mid_conversation_output_config_used=is_mid_conversation_output_config_used, is_thinking_display_updates_used=self.is_thinking_display_updates_used(optional_params.get("thinking")), + is_mid_conversation_tool_change_used=self.is_mid_conversation_tool_change_used(messages), web_search_tool_used=web_search_tool_used, is_vertex_request=optional_params.get("is_vertex_request", False), user_anthropic_beta_headers=user_anthropic_beta_headers, diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index b1be92e49b6..2fbb51ec949 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -12,6 +12,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import ( from litellm.types.llms.anthropic import ( ANTHROPIC_ADVISOR_TOOL_TYPE, ANTHROPIC_BETA_HEADER_VALUES, + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, AnthropicMessagesRequest, ) @@ -694,7 +695,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if AnthropicModelInfo().is_thinking_display_updates_used(optional_params.get("thinking")) else () ) - all_beta_values: Final = beta_values.union(thinking_display_betas) + tool_change_betas: Final = ( + (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) + if AnthropicModelInfo().is_mid_conversation_tool_change_used(messages) + else () + ) + all_beta_values: Final = beta_values.union(thinking_display_betas, tool_change_betas) if not all_beta_values: return headers diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 73da7c41a09..6234ca3a9c3 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -549,6 +549,9 @@ class AmazonAnthropicClaudeMessagesConfig( is_thinking_display_updates_used=anthropic_model_info.is_thinking_display_updates_used( anthropic_messages_request.get("thinking") ), + is_mid_conversation_tool_change_used=anthropic_model_info.is_mid_conversation_tool_change_used( + outgoing_messages_typed + ), ) beta_set.update(auto_betas) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 6cd0e55c517..ee357cd6581 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -777,6 +777,7 @@ ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24" ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER: Final = "mid-conversation-output-config-2026-07-01" ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER: Final = "thinking-display-updates-2026-08-18" +ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER: Final = "mid-conversation-tool-changes-2026-07-01" ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER: Final = "fine-grained-tool-streaming-2025-05-14" diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py index ef1fac9e120..05c12c6a285 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py @@ -150,3 +150,33 @@ def test_native_messages_thinking_display_updates_beta(display: str | None, expl ) assert headers.get("anthropic-beta", "").split(",").count(beta) == int(display == "updates" or explicit_beta) + + +@pytest.mark.parametrize("action", (None, "tool_addition", "tool_removal")) +@pytest.mark.parametrize("explicit_beta", (False, True)) +def test_native_messages_tool_changes_beta(action: str | None, explicit_beta: bool) -> None: + from typing import Final + + from litellm.types.llms.anthropic import ( + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, + ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, + ) + + beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + content: Final = ( + [{"type": action, "tool": {"type": "tool_reference", "name": "mcp__test__ping"}}] + if action + else "Answer briefly" + ) + headers, _ = AnthropicMessagesConfig().validate_anthropic_messages_environment( + headers={"anthropic-beta": beta} if explicit_beta else {}, + model="claude-fable-5-1", + messages=["not a message dict", {"role": "user", "content": "Hello"}, {"role": "system", "content": content}], + optional_params={"thinking": {"type": "adaptive", "display": "updates"}}, + litellm_params={}, + api_key="sk-ant-test", + ) + + assert headers.get("anthropic-beta", "").split(",").count(beta) == int(action is not None or explicit_beta) + + assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER in headers.get("anthropic-beta", "").split(",") diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index 0904a20a16b..68e2e9650d5 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -2450,3 +2450,57 @@ def test_shared_legacy_thinking_translation_preserves_supported_display( ) assert optional_params["thinking"] == expected_thinking + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +@pytest.mark.parametrize("action", (None, "tool_addition", "tool_removal")) +@pytest.mark.parametrize("explicit_beta", (False, True)) +def test_validate_environment_adds_tool_changes_beta(action: str | None, explicit_beta: bool) -> None: + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + + beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + content: Final = ( + [{"type": action, "tool": {"type": "tool_reference", "name": "mcp__test__ping"}}] + if action + else "Answer briefly" + ) + headers: Final = AnthropicModelInfo().validate_environment( + headers={"anthropic-beta": beta} if explicit_beta else {}, + model="claude-fable-5-1", + messages=[{"role": "user", "content": "Hello"}, {"role": "system", "content": content}], + optional_params={}, + litellm_params={}, + api_key=FAKE_REGULAR_KEY, + ) + + assert headers.get("anthropic-beta", "").split(",").count(beta) == int(action is not None or explicit_beta) + assert headers["x-api-key"] == FAKE_REGULAR_KEY + + +@pytest.mark.parametrize( + ("role", "content"), + ( + ("user", [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]), + ("assistant", [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]), + ("system", "tool_addition"), + ("system", None), + ("system", ["tool_addition"]), + ("system", [{"type": "tool_reference", "name": "ping"}]), + ("system", [{"type": "tool_addition", "tool": {"type": "tool_definition", "definition": {"name": "ping"}}}]), + ), +) +def test_tool_changes_beta_requires_system_tool_reference(role: str, content: object) -> None: + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + + headers: Final = AnthropicModelInfo().validate_environment( + headers={}, + model="claude-fable-5-1", + messages=[{"role": role, "content": content}], + optional_params={}, + litellm_params={}, + api_key=FAKE_REGULAR_KEY, + ) + + assert ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER not in headers.get("anthropic-beta", "").split(",") diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index d7f451dd6ee..a269d556262 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -3527,3 +3527,54 @@ def test_bedrock_clear_thinking_preserves_display_updates() -> None: assert result.get("thinking") == {"type": "adaptive", "display": "updates"} assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER in result.get("anthropic_beta", []) + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +@pytest.mark.parametrize("action", (None, "tool_addition", "tool_removal")) +@pytest.mark.parametrize("explicit_beta", (False, True)) +def test_bedrock_messages_tool_changes_beta(action: str | None, explicit_beta: bool) -> None: + from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + from litellm.types.router import GenericLiteLLMParams + + beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + content: Final = ( + [{"type": action, "tool": {"type": "tool_reference", "name": "mcp__test__ping"}}] + if action + else "Answer briefly" + ) + messages: Final = [{"role": "user", "content": "Hello"}, {"role": "system", "content": content}] + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="global.anthropic.claude-fable-5-1", + messages=messages, + anthropic_messages_optional_request_params={"max_tokens": 512}, + litellm_params=GenericLiteLLMParams(), + headers={"anthropic-beta": beta} if explicit_beta else {}, + ) + + assert result.get("anthropic_beta", []).count(beta) == int(action is not None or explicit_beta) + assert result["messages"] == messages + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +@pytest.mark.parametrize("explicit_beta", (False, True)) +def test_bedrock_removed_tool_change_does_not_add_beta(explicit_beta: bool) -> None: + from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + from litellm.types.router import GenericLiteLLMParams + + beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="global.anthropic.claude-fable-5-1", + messages=[ + { + "role": "system", + "content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}], + }, + {"role": "user", "content": "Reply with OK"}, + ], + anthropic_messages_optional_request_params={"max_tokens": 512}, + litellm_params=GenericLiteLLMParams(), + headers={"anthropic-beta": beta} if explicit_beta else {}, + ) + + assert result["messages"] == [{"role": "user", "content": "Reply with OK"}] + assert result.get("anthropic_beta", []).count(beta) == int(explicit_beta) From 54a51c80df0aebd45a40b208498071252aca6d4b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:57:23 -0700 Subject: [PATCH 06/83] feat(proxy): bounded daily activity routes for all usage entities (#43408) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/routes/allowlist.py | 1 + litellm/proxy/_lazy_openapi_snapshot.json | 1046 +++++++++ litellm/proxy/_types.py | 52 + litellm/proxy/agent_endpoints/endpoints.py | 175 +- .../common_daily_activity.py | 33 +- .../customer_endpoints.py | 52 +- .../daily_activity_routes.py | 579 +++++ .../daily_activity_scopes.py | 472 ++++ .../internal_user_endpoints.py | 147 +- .../organization_endpoints.py | 89 +- .../tag_management_endpoints.py | 4 +- .../management_endpoints/team_endpoints.py | 102 +- litellm/proxy/proxy_server.py | 2 + .../common_daily_activity.py | 45 + .../endpointaudit/coverage_allowlist.txt | 29 + .../unbounded_in_baseline.txt | 5 +- .../daily_activity_team_aggregated.json | 1169 ++++++++++ .../golden/daily_activity_team_paginated.json | 1197 ++++++++++ .../daily_activity_user_aggregated.json | 1002 ++++++++ .../golden/daily_activity_user_paginated.json | 1198 ++++++++++ .../spend/test_daily_activity_routes.py | 514 +++++ tests/unit/proxy/auth/test_route_checks.py | 89 +- .../test_activity_tenant_scoping.py | 56 + .../test_common_daily_activity.py | 17 +- .../test_daily_activity_routes.py | 1261 ++++++++++ .../test_internal_user_endpoints.py | 182 -- .../test_organization_endpoints.py | 2 +- .../test_team_endpoints.py | 125 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2041 +++++++++++++++++ 29 files changed, 11031 insertions(+), 655 deletions(-) create mode 100644 litellm/proxy/management_endpoints/daily_activity_routes.py create mode 100644 litellm/proxy/management_endpoints/daily_activity_scopes.py create mode 100644 tests/integration/spend/golden/daily_activity_team_aggregated.json create mode 100644 tests/integration/spend/golden/daily_activity_team_paginated.json create mode 100644 tests/integration/spend/golden/daily_activity_user_aggregated.json create mode 100644 tests/integration/spend/golden/daily_activity_user_paginated.json create mode 100644 tests/integration/spend/test_daily_activity_routes.py create mode 100644 tests/unit/proxy/management_endpoints/test_daily_activity_routes.py diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index 80ca0ef22bb..d7f3e615c67 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -60,6 +60,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( # Tools / agents (registry & policy admin) "/v1/tool/", "/v1/agents", + "/agent/daily/activity/", # Guardrails admin "/v2/guardrails/", # MCP server admin + BYOK OAuth flow (UI-initiated) + dynamic per-server endpoints diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index a9d88c50380..5de6e91a0f2 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3432,6 +3432,53 @@ "title": "BreakdownMetrics", "type": "object" }, + "DailyActivityKeyPageResponse": { + "properties": { + "api_keys": { + "items": { + "$ref": "#/components/schemas/KeySpendActivityRow" + }, + "title": "Api Keys", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total_api_keys": { + "title": "Total Api Keys", + "type": "integer" + } + }, + "required": [ + "api_keys", + "total_api_keys", + "offset", + "limit" + ], + "title": "DailyActivityKeyPageResponse", + "type": "object" + }, + "DailyActivityKeySearchResponse": { + "properties": { + "api_keys": { + "items": { + "$ref": "#/components/schemas/KeyActivityRow" + }, + "title": "Api Keys", + "type": "array" + } + }, + "required": [ + "api_keys" + ], + "title": "DailyActivityKeySearchResponse", + "type": "object" + }, "DailySpendData": { "properties": { "breakdown": { @@ -3653,6 +3700,16 @@ "title": "EntraIdentityConfig", "type": "object" }, + "ExportType": { + "enum": [ + "daily", + "daily_with_keys", + "daily_with_models", + "daily_with_users" + ], + "title": "ExportType", + "type": "string" + }, "HTTPAuthSecurityScheme": { "description": "Defines a security scheme using HTTP authentication.", "properties": { @@ -3708,6 +3765,27 @@ "title": "HTTPValidationError", "type": "object" }, + "KeyActivityRow": { + "properties": { + "api_key": { + "title": "Api Key", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/KeyMetadata" + }, + "metrics": { + "$ref": "#/components/schemas/SpendMetrics" + } + }, + "required": [ + "api_key", + "metrics", + "metadata" + ], + "title": "KeyActivityRow", + "type": "object" + }, "KeyMetadata": { "description": "Metadata for a key", "properties": { @@ -3786,6 +3864,78 @@ "title": "KeyMetricWithMetadata", "type": "object" }, + "KeySpendActivityRow": { + "properties": { + "api_key": { + "title": "Api Key", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/KeyMetadata" + }, + "metrics": { + "$ref": "#/components/schemas/KeySpendMetrics" + } + }, + "required": [ + "api_key", + "metrics", + "metadata" + ], + "title": "KeySpendActivityRow", + "type": "object" + }, + "KeySpendMetrics": { + "properties": { + "api_requests": { + "default": 0, + "title": "Api Requests", + "type": "integer" + }, + "cache_creation_input_tokens": { + "default": 0, + "title": "Cache Creation Input Tokens", + "type": "integer" + }, + "cache_read_input_tokens": { + "default": 0, + "title": "Cache Read Input Tokens", + "type": "integer" + }, + "completion_tokens": { + "default": 0, + "title": "Completion Tokens", + "type": "integer" + }, + "failed_requests": { + "default": 0, + "title": "Failed Requests", + "type": "integer" + }, + "prompt_tokens": { + "default": 0, + "title": "Prompt Tokens", + "type": "integer" + }, + "spend": { + "default": 0.0, + "title": "Spend", + "type": "number" + }, + "successful_requests": { + "default": 0, + "title": "Successful Requests", + "type": "integer" + }, + "total_tokens": { + "default": 0, + "title": "Total Tokens", + "type": "integer" + } + }, + "title": "KeySpendMetrics", + "type": "object" + }, "MakeAgentsPublicRequest": { "properties": { "agent_ids": { @@ -3874,6 +4024,32 @@ "title": "MetricWithMetadata", "type": "object" }, + "ModelTopKeysResponse": { + "properties": { + "api_keys": { + "items": { + "$ref": "#/components/schemas/KeySpendActivityRow" + }, + "title": "Api Keys", + "type": "array" + }, + "by_model_group": { + "title": "By Model Group", + "type": "boolean" + }, + "model": { + "title": "Model", + "type": "string" + } + }, + "required": [ + "model", + "by_model_group", + "api_keys" + ], + "title": "ModelTopKeysResponse", + "type": "object" + }, "MutualTLSSecurityScheme": { "description": "Defines a security scheme using mTLS authentication.", "properties": { @@ -4475,6 +4651,876 @@ ] } }, + "/agent/daily/activity/aggregated": { + "get": { + "operationId": "get_agent_daily_activity_aggregated_agent_daily_activity_aggregated_get", + "parameters": [ + { + "in": "query", + "name": "api_key_limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Api Key Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpendAnalyticsPaginatedResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Aggregated", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/aggregated/keys": { + "get": { + "operationId": "get_agent_daily_activity_aggregated_keys_agent_daily_activity_aggregated_keys_get", + "parameters": [ + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailyActivityKeyPageResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Aggregated Keys", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/aggregated/model_top_keys": { + "get": { + "operationId": "get_agent_daily_activity_model_top_keys_agent_daily_activity_aggregated_model_top_keys_get", + "parameters": [ + { + "in": "query", + "name": "model_group", + "required": true, + "schema": { + "minLength": 1, + "title": "Model Group", + "type": "string" + } + }, + { + "in": "query", + "name": "by_model_group", + "required": false, + "schema": { + "default": true, + "title": "By Model Group", + "type": "boolean" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 5, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelTopKeysResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Model Top Keys", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/aggregated/search": { + "get": { + "operationId": "get_agent_daily_activity_aggregated_search_agent_daily_activity_aggregated_search_get", + "parameters": [ + { + "in": "query", + "name": "search", + "required": true, + "schema": { + "minLength": 1, + "title": "Search", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailyActivityKeySearchResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Aggregated Search", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/export": { + "get": { + "operationId": "get_agent_daily_activity_export_agent_daily_activity_export_get", + "parameters": [ + { + "in": "query", + "name": "export_type", + "required": false, + "schema": { + "$ref": "#/components/schemas/ExportType", + "default": "daily" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "default": "csv", + "enum": [ + "csv", + "json" + ], + "title": "Format", + "type": "string" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "text/csv": { + "schema": { + "type": "string" + } + } + }, + "description": "Streamed daily activity export" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Export", + "tags": [ + "agents" + ] + } + }, "/v1/agents": { "get": { "description": "Example usage:\n```\ncurl -X GET \"http://localhost:4000/v1/agents\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer your-key\" ```\n\nPass `?health_check=true` to filter out agents whose URL is unreachable:\n```\ncurl -X GET \"http://localhost:4000/v1/agents?health_check=true\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer your-key\" ```\n\nPass `?query=` to get the best matching agents ranked by semantic similarity:\n```\ncurl -X GET \"http://localhost:4000/v1/agents?query=translate+a+PDF+document&top_k=5\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer your-key\" ```\n\nReturns: List[AgentResponse]", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a471fb6f6f8..a745213f9e6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -699,6 +699,10 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.TEAM_KEY_BULK_UPDATE.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY_AGGREGATED.value, + "/team/daily/activity/aggregated/keys", + "/team/daily/activity/aggregated/search", + "/team/daily/activity/aggregated/model_top_keys", + "/team/daily/activity/export", KeyManagementRoutes.SPEND_LOGS.value, KeyManagementRoutes.SPEND_LOGS_V2.value, KeyManagementRoutes.KEY_RESET_SPEND.value, @@ -725,6 +729,11 @@ class LiteLLMRoutes(enum.Enum): "/user/list", "/user/daily/activity", "/user/daily/activity/aggregated", + "/user/daily/activity/aggregated/keys", + "/user/daily/activity/aggregated/search", + "/user/daily/activity/aggregated/model_top_keys", + "/user/daily/activity/export", + "/user/daily/activity/aggregated/cache_leakage_keys", # team "/team/new", "/team/update", @@ -742,6 +751,10 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_bulk_update", "/team/daily/activity", "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/keys", + "/team/daily/activity/aggregated/search", + "/team/daily/activity/aggregated/model_top_keys", + "/team/daily/activity/export", "/team/spend/by_user", # gateway request counts (SGR); deployment-wide, admin-only "/gateway/daily/activity", @@ -870,6 +883,11 @@ class LiteLLMRoutes(enum.Enum): # Tag usage endpoints scope internal users to tags produced by # their own keys in tag_management_endpoints.py. "/tag/daily/activity", + "/tag/daily/activity/aggregated", + "/tag/daily/activity/aggregated/keys", + "/tag/daily/activity/aggregated/search", + "/tag/daily/activity/aggregated/model_top_keys", + "/tag/daily/activity/export", "/tag/list", "/v1/models/{model_id}", "/models/{model_id}", @@ -894,6 +912,11 @@ class LiteLLMRoutes(enum.Enum): # Tag usage endpoints scope internal viewers to tags produced by # their own keys in tag_management_endpoints.py. "/tag/daily/activity", + "/tag/daily/activity/aggregated", + "/tag/daily/activity/aggregated/keys", + "/tag/daily/activity/aggregated/search", + "/tag/daily/activity/aggregated/model_top_keys", + "/tag/daily/activity/export", "/tag/list", ] ) @@ -913,6 +936,10 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_update", "/team/daily/activity", "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/keys", + "/team/daily/activity/aggregated/search", + "/team/daily/activity/aggregated/model_top_keys", + "/team/daily/activity/export", "/team/spend/by_user", "/team/{team_id}/members/me", # POST/GET the team's logging callbacks, and DELETE one of them. Every @@ -928,9 +955,19 @@ class LiteLLMRoutes(enum.Enum): "/model/delete", "/user/daily/activity", "/user/daily/activity/aggregated", + "/user/daily/activity/aggregated/keys", + "/user/daily/activity/aggregated/search", + "/user/daily/activity/aggregated/model_top_keys", + "/user/daily/activity/export", + "/user/daily/activity/aggregated/cache_leakage_keys", # Endpoint restricts results to organizations the caller is ORG_ADMIN # of; a caller who administers none gets an empty result set. "/organization/daily/activity", + "/organization/daily/activity/aggregated", + "/organization/daily/activity/aggregated/keys", + "/organization/daily/activity/aggregated/search", + "/organization/daily/activity/aggregated/model_top_keys", + "/organization/daily/activity/export", "/user/available_roles", # read-only role metadata; any authenticated user may read # Claude Code gateway: the signed-in CLI fetches its managed settings and posts its own telemetry "/claude_code_gateway/managed/settings", @@ -1009,9 +1046,24 @@ class LiteLLMRoutes(enum.Enum): "/user/available_users", "/user/available_roles", "/user/daily/activity", + "/user/daily/activity/aggregated", + "/user/daily/activity/aggregated/keys", + "/user/daily/activity/aggregated/search", + "/user/daily/activity/aggregated/model_top_keys", + "/user/daily/activity/export", + "/user/daily/activity/aggregated/cache_leakage_keys", "/team/daily/activity", "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/keys", + "/team/daily/activity/aggregated/search", + "/team/daily/activity/aggregated/model_top_keys", + "/team/daily/activity/export", "/tag/daily/activity", + "/tag/daily/activity/aggregated", + "/tag/daily/activity/aggregated/keys", + "/tag/daily/activity/aggregated/search", + "/tag/daily/activity/aggregated/model_top_keys", + "/tag/daily/activity/export", "/tag/list", "/audit", "/audit/{id}", diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 875b62103b9..c0f34a24a0a 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -13,7 +13,7 @@ import os import uuid from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import Annotated, Final, TypedDict +from typing import Annotated, Final, NamedTuple, TypedDict from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import ValidationError @@ -47,7 +47,11 @@ from litellm.proxy.agent_endpoints.agent_search import ( global_agent_search_index, search_agents, ) -from litellm.proxy.agent_endpoints.auth.agent_permission_handler import accessible_agents +from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( + AgentRequestHandler, + UnrestrictedAgentAccess, + accessible_agents, +) from litellm.proxy.agent_endpoints.identity import reject_legacy_identity from litellm.proxy.agent_endpoints.identity_store import AgentIdentityStore from litellm.proxy.agent_endpoints.kill_switch import ( @@ -63,7 +67,8 @@ from litellm.proxy.agent_endpoints.managed_identity import raise_identity_failur from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity -from litellm.proxy.utils import get_custom_url +from litellm.proxy.utils import PrismaClient, get_custom_url +from litellm.repositories.chunked_in import find_many_in from litellm.types.agents import ( AgentCard, AgentConfig, @@ -303,6 +308,73 @@ async def _rank_agents_by_query( assert_never(outcome) +class _AgentDailyActivityScope(NamedTuple): + agent_ids: tuple[str, ...] | None + agent_metadata: Mapping[str, dict[str, object]] + + +async def _owned_agent_ids(*, user_id: str | None, prisma_client: PrismaClient) -> frozenset[str]: + if user_id is None: + return frozenset() + owned_records: Final = await agents_table(prisma_client).find_many(where={"created_by": user_id}) + return frozenset(agent.agent_id for agent in owned_records) + + +async def _permitted_daily_activity_agent_ids( + *, user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient +) -> frozenset[str]: + access: Final = await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict) + if isinstance(access, UnrestrictedAgentAccess): + return await _owned_agent_ids(user_id=user_api_key_dict.user_id, prisma_client=prisma_client) + return access.agent_ids + + +async def _resolve_daily_activity_agent_ids( + *, + agent_ids: tuple[str, ...] | None, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, +) -> tuple[str, ...] | None: + from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view + + if _user_has_admin_view(user_api_key_dict): + return agent_ids + permitted_agent_ids: Final = await _permitted_daily_activity_agent_ids( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + return ( + tuple(agent_id for agent_id in agent_ids if agent_id in permitted_agent_ids) + if agent_ids + else tuple(permitted_agent_ids) + ) + + +async def resolve_agent_daily_activity_scope( + *, + agent_ids: tuple[str, ...] | None, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, +) -> _AgentDailyActivityScope: + await check_feature_access_for_user(user_api_key_dict, "agents") + + resolved_agent_ids: Final = await _resolve_daily_activity_agent_ids( + agent_ids=agent_ids, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + + agent_records: Final = ( + await agents_table(prisma_client).find_many(where={}) + if resolved_agent_ids is None + else await find_many_in(agents_table(prisma_client), "agent_id", resolved_agent_ids) + ) + agent_metadata: Final[Mapping[str, dict[str, object]]] = MappingProxyType( + {agent.agent_id: {"agent_name": agent.agent_name} for agent in agent_records} + ) + return _AgentDailyActivityScope(resolved_agent_ids, agent_metadata) + + @router.get( "/v1/agents", tags=["[beta] A2A Agents"], @@ -1288,82 +1360,39 @@ async def get_agent_daily_activity( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - agent_ids_list = agent_ids.split(",") if agent_ids else None - exclude_agent_ids_list: list[str] | None = None - if exclude_agent_ids: - exclude_agent_ids_list = exclude_agent_ids.split(",") if exclude_agent_ids else None - - # Without scoping, an empty `agent_ids` query returned every agent's - # spend/token rows on the proxy. Restrict non-admin callers to the - # agents they're permitted to invoke (or that they created), and - # intersect their explicit `agent_ids` filter with the same allowlist. - from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( - AgentRequestHandler, - RestrictedAgentAccess, - UnrestrictedAgentAccess, + requested_agent_ids: Final = tuple(agent_ids.split(",")) if agent_ids else None + exclude_agent_ids_list: Final[list[str] | None] = exclude_agent_ids.split(",") if exclude_agent_ids else None + agent_scope: Final = await resolve_agent_daily_activity_scope( + agent_ids=requested_agent_ids, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, ) - from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view - - where_condition: Final[dict[str, object]] = {} - if not _user_has_admin_view(user_api_key_dict): - permitted_agent_ids: list[str] = [] - # An unrestricted caller is not "see everything" for activity scoping. Fall - # back to the agents the caller created so they cannot enumerate other - # tenants' agents. - # Guard against `user_id is None`: a literal None in Prisma - # `where={"created_by": None}` resolves to ``created_by IS NULL`` - # and would expose every ownerless agent's rows. - match await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict): - case RestrictedAgentAccess(allowed_agent_ids): - permitted_agent_ids = list(allowed_agent_ids) - case UnrestrictedAgentAccess(): - if user_api_key_dict.user_id is not None: - owned_records: Final = await agents_table(prisma_client).find_many( - where={"created_by": user_api_key_dict.user_id} - ) - permitted_agent_ids = [a.agent_id for a in owned_records] - - if agent_ids_list: - permitted_agent_id_set: Final = set(permitted_agent_ids) - agent_ids_list = [aid for aid in agent_ids_list if aid in permitted_agent_id_set] - else: - agent_ids_list = list(permitted_agent_ids) - - # No accessible agents → return an empty page without querying. - if not agent_ids_list: - return SpendAnalyticsPaginatedResponse( - results=[], - metadata=DailySpendMetadata( - total_spend=0.0, - total_prompt_tokens=0, - total_completion_tokens=0, - total_tokens=0, - total_api_requests=0, - total_successful_requests=0, - total_failed_requests=0, - total_cache_read_input_tokens=0, - total_cache_creation_input_tokens=0, - total_compression_saved_tokens=0, - page=page, - total_pages=0, - has_more=False, - ), - ) - - if agent_ids_list: - where_condition["agent_id"] = {"in": list(agent_ids_list)} - - agent_records: Final = await agents_table(prisma_client).find_many(where=where_condition) - agent_metadata: Final[Mapping[str, dict[str, object]]] = { - agent.agent_id: {"agent_name": agent.agent_name} for agent in agent_records - } + if agent_scope.agent_ids == (): + return SpendAnalyticsPaginatedResponse( + results=[], + metadata=DailySpendMetadata( + total_spend=0.0, + total_prompt_tokens=0, + total_completion_tokens=0, + total_tokens=0, + total_api_requests=0, + total_successful_requests=0, + total_failed_requests=0, + total_cache_read_input_tokens=0, + total_cache_creation_input_tokens=0, + total_compression_saved_tokens=0, + page=page, + total_pages=0, + has_more=False, + ), + ) return await get_daily_activity( prisma_client=prisma_client, table_name="litellm_dailyagentspend", entity_id_field="agent_id", - entity_id=agent_ids_list, - entity_metadata_field=agent_metadata, + entity_id=None if agent_scope.agent_ids is None else list(agent_scope.agent_ids), + entity_metadata_field=agent_scope.agent_metadata, exclude_entity_ids=exclude_agent_ids_list, start_date=start_date, end_date=end_date, diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 59d1d2bf01d..e13d623c73b 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -4,10 +4,10 @@ from collections.abc import Set as AbstractSet from dataclasses import dataclass, replace from datetime import datetime, timedelta from types import MappingProxyType -from typing import Final, Protocol +from typing import Final, Literal, NoReturn, Protocol from fastapi import HTTPException, status -from typing_extensions import ReadOnly, TypedDict +from typing_extensions import ReadOnly, TypedDict, assert_never from litellm import constants from litellm._logging import verbose_proxy_logger @@ -45,6 +45,27 @@ from litellm.types.repositories.daily_activity import ( ) +@dataclass(frozen=True, slots=True) +class ScopeDenied: + status_code: Literal[403, 404] + reason: str + + +@dataclass(frozen=True, slots=True) +class InvalidDateRange: + reason: str + + +def raise_public(error: ScopeDenied | InvalidDateRange) -> NoReturn: + match error: + case ScopeDenied(): + raise HTTPException(status_code=error.status_code, detail={"error": error.reason}) + case InvalidDateRange(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail={"error": error.reason}) + case _: + assert_never(error) + + class DailySpendRecord(Protocol): @property def date(self) -> str: ... @@ -402,7 +423,7 @@ def update_breakdown_metrics( return breakdown -def _spend_logs_window(dates: AbstractSet[str | None]) -> tuple[datetime, datetime] | None: +def spend_logs_window(dates: AbstractSet[str | None]) -> tuple[datetime, datetime] | None: parsed: Final = sorted(day for day in (_parse_spend_date(raw) for raw in dates) if day is not None) if not parsed: return None @@ -622,7 +643,7 @@ async def _aggregate_spend_records( api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( await repository.key_metadata( - frozenset(api_keys), _spend_logs_window(frozenset(record.date for record in records)) + frozenset(api_keys), spend_logs_window(frozenset(record.date for record in records)) ) if api_keys else MappingProxyType({}) @@ -823,7 +844,7 @@ async def _aggregate_grouping_sets_records( api_keys: Final[set[str]] = {r.api_key for r in records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY} api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( - await repository.key_metadata(frozenset(api_keys), _spend_logs_window(frozenset(r.date for r in records))) + await repository.key_metadata(frozenset(api_keys), spend_logs_window(frozenset(r.date for r in records))) if api_keys else MappingProxyType({}) ) @@ -990,7 +1011,7 @@ async def get_daily_activity_aggregated( ) entity_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( await repository.key_metadata( - entity_api_keys, _spend_logs_window(frozenset(row.date for row in entity_records)) + entity_api_keys, spend_logs_window(frozenset(row.date for row in entity_records)) ) if entity_api_keys else MappingProxyType({}) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index b35bc01b4d0..7236fd12e9d 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -12,7 +12,8 @@ All /customer management endpoints #### END-USER/CUSTOMER MANAGEMENT #### from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Final, Protocol, TypeVar, overload +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypeVar, overload import fastapi from fastapi import APIRouter, Depends, HTTPException, Request @@ -41,6 +42,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( ) from litellm.proxy.utils import handle_exception_on_proxy from litellm.repositories.budget_repository import BudgetRepository +from litellm.repositories.chunked_in import find_many_in from litellm.repositories.table_repositories import EndUserRepository from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, @@ -490,6 +492,33 @@ async def new_end_user( raise handle_exception_on_proxy(e) +class _CustomerDailyActivityScope(NamedTuple): + end_user_ids: tuple[str, ...] | None + end_user_metadata: Mapping[str, dict[str, object]] + + +async def resolve_customer_daily_activity_scope( + *, + end_user_ids: tuple[str, ...] | None, + prisma_client: "PrismaClient", +) -> _CustomerDailyActivityScope: + end_user_table: Final = _typed_table(EndUserRepository(prisma_client)) + end_user_aliases: Final = ( + await find_many_in(end_user_table, "user_id", end_user_ids) + if end_user_ids is not None + else await end_user_table.find_many(where={}) + ) + metadata: Final = MappingProxyType({end_user.user_id: {"alias": end_user.alias} for end_user in end_user_aliases}) + return _CustomerDailyActivityScope(end_user_ids, metadata) + + +def customer_daily_activity_is_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: + return user_api_key_dict.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) + + @router.get( "/customer/info", tags=["Customer Management"], @@ -888,10 +917,7 @@ async def get_customer_daily_activity( """ Get daily activity for specific organizations or all accessible organizations. """ - if ( - user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY - ): + if not customer_daily_activity_is_admin(user_api_key_dict): raise HTTPException( status_code=401, detail={"error": f"Admin-only endpoint. Your user role={user_api_key_dict.user_role}"}, @@ -906,24 +932,22 @@ async def get_customer_daily_activity( ) # Parse comma-separated ids - end_user_ids_list: Final = end_user_ids.split(",") if end_user_ids else None + end_user_ids_list: Final = tuple(end_user_ids.split(",")) if end_user_ids else None exclude_end_user_ids_list: list[str] | None = None if exclude_end_user_ids: exclude_end_user_ids_list = exclude_end_user_ids.split(",") if exclude_end_user_ids else None - # Fetch organization aliases for metadata - where_condition: Final = dict[str, object]() - if end_user_ids_list: - where_condition["user_id"] = {"in": list(end_user_ids_list)} - end_user_aliases: Final = await _typed_table(EndUserRepository(prisma_client)).find_many(where=where_condition) + customer_scope: Final = await resolve_customer_daily_activity_scope( + end_user_ids=end_user_ids_list, + prisma_client=prisma_client, + ) - # Query daily activity for organizations return await get_daily_activity( prisma_client=prisma_client, table_name="litellm_dailyenduserspend", entity_id_field="end_user_id", - entity_id=end_user_ids_list, - entity_metadata_field={e.user_id: {"alias": e.alias} for e in end_user_aliases}, + entity_id=None if customer_scope.end_user_ids is None else list(customer_scope.end_user_ids), + entity_metadata_field=customer_scope.end_user_metadata, exclude_entity_ids=exclude_end_user_ids_list, start_date=start_date, end_date=end_date, diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py new file mode 100644 index 00000000000..065f23c3829 --- /dev/null +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -0,0 +1,579 @@ +import csv +import io +import json +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import asdict, fields, replace +from datetime import datetime +from typing import Annotated, Final, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.encoders import jsonable_encoder +from fastapi.responses import StreamingResponse + +from litellm import constants +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.common_daily_activity import ( + InvalidDateRange, + ScopeDenied, + daily_activity_repository, + get_daily_activity_aggregated, + raise_public, + spend_logs_window, +) +from litellm.proxy.management_endpoints.daily_activity_scopes import ( + AGENT_RESOLVER, + CUSTOMER_RESOLVER, + ORGANIZATION_RESOLVER, + TAG_RESOLVER, + TEAM_RESOLVER, + USER_RESOLVER, + EntityQuery, + EntityScopeResolver, + ResolvedScope, +) +from litellm.proxy.management_endpoints.team_endpoints import aggregated_date_range_error +from litellm.proxy.management_helpers.utils import management_endpoint_wrapper +from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw +from litellm.repositories.daily_activity_repository import DailyActivityRepository +from litellm.types.proxy.management_endpoints.common_daily_activity import ( + CacheLeakageKeysResponse, + DailyActivityKeyPageResponse, + DailyActivityKeySearchResponse, + KeyActivityRow, + KeyMetadata, + KeySpendActivityRow, + KeySpendMetrics, + ModelTopKeysResponse, + SpendAnalyticsPaginatedResponse, + SpendMetrics, +) +from litellm.types.repositories.daily_activity import ( + ExportRow, + ExportType, + KeyMetadataRow, + KeySpendRow, +) + +router = APIRouter() + + +def get_daily_activity_prisma_client() -> PrismaClient: + return get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value) + + +def get_daily_activity_repository() -> DailyActivityRepository: + return daily_activity_repository(get_daily_activity_prisma_client()) + + +def _date_range_error(query: EntityQuery, *, user_aggregated: bool) -> InvalidDateRange | None: + if user_aggregated: + if query.start_date is None or query.end_date is None: + return InvalidDateRange(reason="Please provide start_date and end_date") + return None + + range_error: Final[str | None] = aggregated_date_range_error(query.start_date, query.end_date) + return None if range_error is None else InvalidDateRange(reason=range_error) + + +async def _resolved_scope( + resolver: EntityScopeResolver, + query: EntityQuery, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + *, + user_aggregated: bool, +) -> ResolvedScope: + date_error: Final[InvalidDateRange | None] = _date_range_error(query, user_aggregated=user_aggregated) + if date_error is not None: + raise_public(date_error) + result: ResolvedScope | ScopeDenied = await resolver.resolve(user_api_key_dict, query, prisma_client) + if isinstance(result, ScopeDenied): + raise_public(result) + return result + + +def _sum_metrics(metrics: Sequence[SpendMetrics]) -> SpendMetrics: + return SpendMetrics( + spend=sum(metric.spend for metric in metrics), + flat_cost=sum(metric.flat_cost for metric in metrics), + prompt_tokens=sum(metric.prompt_tokens for metric in metrics), + completion_tokens=sum(metric.completion_tokens for metric in metrics), + cache_read_input_tokens=sum(metric.cache_read_input_tokens for metric in metrics), + cache_creation_input_tokens=sum(metric.cache_creation_input_tokens for metric in metrics), + compression_saved_tokens=sum(metric.compression_saved_tokens for metric in metrics), + compression_savings_spend=sum(metric.compression_savings_spend for metric in metrics), + prompt_caching_savings_spend=sum(metric.prompt_caching_savings_spend for metric in metrics), + gateway_injected_caching_savings_spend=sum(metric.gateway_injected_caching_savings_spend for metric in metrics), + autorouter_savings_spend=sum(metric.autorouter_savings_spend for metric in metrics), + total_tokens=sum(metric.total_tokens for metric in metrics), + successful_requests=sum(metric.successful_requests for metric in metrics), + failed_requests=sum(metric.failed_requests for metric in metrics), + api_requests=sum(metric.api_requests for metric in metrics), + total_response_time_ms=sum(metric.total_response_time_ms for metric in metrics), + timed_requests=sum(metric.timed_requests for metric in metrics), + ) + + +def _key_metadata(api_key: str, metadata: Mapping[str, KeyMetadataRow]) -> KeyMetadata: + row: Final[KeyMetadataRow | None] = metadata.get(api_key) + if row is None: + return KeyMetadata() + return KeyMetadata( + key_alias=row.key_alias, + team_id=row.team_id, + user_id=row.user_id, + user_email=row.user_email, + key_exists=row.key_exists, + ) + + +def _key_activity_row(row: KeySpendRow, metadata: Mapping[str, KeyMetadataRow]) -> KeySpendActivityRow: + return KeySpendActivityRow( + api_key=row.api_key, + metrics=KeySpendMetrics( + spend=row.spend, + prompt_tokens=row.prompt_tokens, + completion_tokens=row.completion_tokens, + total_tokens=row.total_tokens, + api_requests=row.api_requests, + successful_requests=row.successful_requests, + failed_requests=row.failed_requests, + cache_read_input_tokens=row.cache_read_input_tokens, + cache_creation_input_tokens=row.cache_creation_input_tokens, + ), + metadata=_key_metadata(row.api_key, metadata), + ) + + +async def _key_activity_rows( + repository: DailyActivityRepository, + rows: Sequence[KeySpendRow], + resolved_scope: ResolvedScope, +) -> list[KeySpendActivityRow]: + spend_window: Final[tuple[datetime, datetime] | None] = spend_logs_window( + frozenset((resolved_scope.scope.start_date, resolved_scope.scope.end_date)) + ) + metadata: Final[Mapping[str, KeyMetadataRow]] = await repository.key_metadata( + frozenset(row.api_key for row in rows), + spend_window, + ) + return [_key_activity_row(row, metadata) for row in rows] + + +def _export_filename( + entity: str, + start_date: str, + end_date: str, + export_type: ExportType, + file_format: Literal["csv", "json"], +) -> str: + extension: Final[str] = "csv" if file_format == "csv" else "json" + return f"{entity}-usage-{start_date}-{end_date}-{export_type.value}.{extension}" + + +def _content_disposition( + entity: str, + start_date: str, + end_date: str, + export_type: ExportType, + file_format: Literal["csv", "json"], +) -> str: + filename: Final = _export_filename(entity, start_date, end_date, export_type, file_format) + return f'attachment; filename="{filename}"' + + +def _fold_key_metrics(api_key: str, response: SpendAnalyticsPaginatedResponse) -> KeyActivityRow | None: + metrics: Final[tuple[SpendMetrics, ...]] = tuple( + day.breakdown.api_keys[api_key].metrics for day in response.results if api_key in day.breakdown.api_keys + ) + if not metrics: + return None + metadata: Final[KeyMetadata] = next( + day.breakdown.api_keys[api_key].metadata for day in response.results if api_key in day.breakdown.api_keys + ) + return KeyActivityRow(api_key=api_key, metrics=_sum_metrics(metrics), metadata=metadata) + + +def _search_rows(keys: Sequence[str], response: SpendAnalyticsPaginatedResponse) -> list[KeyActivityRow]: + return [row for key in keys if (row := _fold_key_metrics(key, response)) is not None] + + +def _csv_cell(value: object) -> object: + if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")): + return f"'{value}" + return value + + +def _csv_row(values: Sequence[object]) -> bytes: + output: Final[io.StringIO] = io.StringIO(newline="") + csv.writer(output, lineterminator="\r\n").writerow(tuple(_csv_cell(value) for value in values)) + return output.getvalue().encode() + + +def _stream_export_rows( + first_row: ExportRow | None, + rows: AsyncIterator[ExportRow], + file_format: Literal["csv", "json"], +) -> AsyncIterator[bytes]: + async def stream() -> AsyncIterator[bytes]: + if file_format == "csv": + yield _csv_row(tuple(field.name for field in fields(ExportRow))) + if first_row is not None: + yield _csv_row(tuple(asdict(first_row).values())) + async for row in rows: + yield _csv_row(tuple(asdict(row).values())) + return + + if first_row is None: + yield b"[]" + return + yield b"[" + json.dumps(jsonable_encoder(first_row), separators=(",", ":")).encode() + async for row in rows: + yield b"," + json.dumps(jsonable_encoder(row), separators=(",", ":")).encode() + yield b"]" + + return stream() + + +def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None: + @management_endpoint_wrapper + async def aggregated( + entity_query: Annotated[EntityQuery, Depends(resolver.query)], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)], + prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)], + api_key_limit: Annotated[ + int, Query(ge=1, le=constants.USAGE_TOP_API_KEYS_MAX) + ] = constants.USAGE_TOP_API_KEYS_DEFAULT, + ) -> SpendAnalyticsPaginatedResponse: + try: + resolved: ResolvedScope = await _resolved_scope( + resolver, + entity_query, + user_api_key_dict, + prisma_client, + user_aggregated=resolver.entity == "user", + ) + return await get_daily_activity_aggregated( + repository, + resolved.scope, + entity_metadata_field=resolved.entity_metadata, + include_entity_breakdown=resolver.include_entity_breakdown, + api_key_limit=api_key_limit, + ) + except HTTPException: + raise + except Exception as exc: + verbose_proxy_logger.exception("Daily activity aggregation failed: %s", exc) + raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"}) + + router.add_api_route( + f"{prefix}/daily/activity/aggregated", + aggregated, + methods=["GET"], + name=resolver.operation_names["aggregated"], + tags=list(resolver.tags), + dependencies=(Depends(user_api_key_auth),), + response_model=SpendAnalyticsPaginatedResponse, + include_in_schema=prefix != "/end_user", + ) + + if resolver.entity == "user": + aggregated.__doc__ = ( + "Aggregated analytics for a user's daily activity without pagination.\n" + "Returns the same response shape as the paginated endpoint with page metadata set to single-page.\n\n" + "Reads daily spend records that only ever accumulate and are never affected by budget\n" + "resets. Their total can legitimately exceed the `spend` field returned by\n" + "`/v2/user/info`, which is a running budget counter that every budget reset sets back\n" + "to zero (or to the overage above `max_budget` when `budget_rollover` is enabled)." + ) + elif resolver.entity == "team": + aggregated.__doc__ = ( + "Aggregated daily activity for teams without pagination, including per-team breakdown.\n\n" + "One SQL GROUPING SETS pass returns every day in the range regardless of row\n" + "volume, so callers never reassemble pages. Same response shape as the\n" + "paginated endpoint with page metadata pinned to a single page.\n\n" + "Args:\n" + " team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, " + "returns data for all teams.\n" + " start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD).\n" + " end_date (Optional[str]): End date for the activity period (YYYY-MM-DD).\n" + " model (Optional[str]): Filter by model name.\n" + " api_key (Optional[str]): Filter by API key.\n" + " exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude.\n" + " timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's " + "Date.getTimezoneOffset() convention.\n" + "Returns:\n" + " SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range." + ) + + +def _register_key_page_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None: + @management_endpoint_wrapper + async def key_page( + entity_query: Annotated[EntityQuery, Depends(resolver.query)], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)], + prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)], + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=constants.USAGE_KEY_PAGE_MAX)] = constants.USAGE_KEY_PAGE_DEFAULT, + ) -> DailyActivityKeyPageResponse: + try: + resolved: Final = await _resolved_scope( + resolver, + entity_query, + user_api_key_dict, + prisma_client, + user_aggregated=False, + ) + page: Final = await repository.key_page(resolved.scope, offset=offset, limit=limit) + return DailyActivityKeyPageResponse( + api_keys=await _key_activity_rows(repository, page.rows, resolved), + total_api_keys=page.total_api_keys, + offset=offset, + limit=limit, + ) + except HTTPException: + raise + except Exception as exc: + verbose_proxy_logger.exception("Daily activity key page failed: %s", exc) + raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"}) + + router.add_api_route( + f"{prefix}/daily/activity/aggregated/keys", + key_page, + methods=["GET"], + name=resolver.operation_names["key_page"], + tags=list(resolver.tags), + dependencies=(Depends(user_api_key_auth),), + response_model=DailyActivityKeyPageResponse, + include_in_schema=prefix != "/end_user", + ) + + +def _register_search_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None: + @management_endpoint_wrapper + async def search( + entity_query: Annotated[EntityQuery, Depends(resolver.query)], + search: Annotated[str, Query(min_length=1)], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)], + prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)], + limit: Annotated[int, Query(ge=1, le=constants.USAGE_KEY_SEARCH_MAX)] = constants.USAGE_KEY_SEARCH_DEFAULT, + ) -> DailyActivityKeySearchResponse: + try: + resolved: ResolvedScope = await _resolved_scope( + resolver, + entity_query, + user_api_key_dict, + prisma_client, + user_aggregated=False, + ) + keys: tuple[str, ...] = await repository.search_keys( + resolved.scope, + search=search, + limit=limit, + ) + if not keys: + return DailyActivityKeySearchResponse(api_keys=[]) + search_response: SpendAnalyticsPaginatedResponse = await get_daily_activity_aggregated( + repository, + replace(resolved.scope, api_keys=keys), + include_entity_breakdown=False, + ) + return DailyActivityKeySearchResponse(api_keys=_search_rows(keys, search_response)) + except HTTPException: + raise + except Exception as exc: + verbose_proxy_logger.exception("Daily activity key search failed: %s", exc) + raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"}) + + router.add_api_route( + f"{prefix}/daily/activity/aggregated/search", + search, + methods=["GET"], + name=resolver.operation_names["search"], + tags=list(resolver.tags), + dependencies=(Depends(user_api_key_auth),), + response_model=DailyActivityKeySearchResponse, + include_in_schema=prefix != "/end_user", + ) + + +def _register_model_top_keys_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None: + @management_endpoint_wrapper + async def model_top_keys( + entity_query: Annotated[EntityQuery, Depends(resolver.query)], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)], + prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)], + model_group: Annotated[str, Query(min_length=1)], + by_model_group: Annotated[bool, Query()] = True, + limit: Annotated[int, Query(ge=1, le=constants.USAGE_MODEL_TOP_KEYS_MAX)] = ( + constants.USAGE_MODEL_TOP_KEYS_DEFAULT + ), + ) -> ModelTopKeysResponse: + try: + resolved: ResolvedScope = await _resolved_scope( + resolver, + entity_query, + user_api_key_dict, + prisma_client, + user_aggregated=False, + ) + rows: tuple[KeySpendRow, ...] = await repository.model_top_keys( + resolved.scope, + model_group=model_group, + by_model_group=by_model_group, + limit=limit, + ) + return ModelTopKeysResponse( + model=model_group, + by_model_group=by_model_group, + api_keys=await _key_activity_rows(repository, rows, resolved), + ) + except HTTPException: + raise + except Exception as exc: + verbose_proxy_logger.exception("Daily activity model top keys failed: %s", exc) + raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"}) + + router.add_api_route( + f"{prefix}/daily/activity/aggregated/model_top_keys", + model_top_keys, + methods=["GET"], + name=resolver.operation_names["model_top_keys"], + tags=list(resolver.tags), + dependencies=(Depends(user_api_key_auth),), + response_model=ModelTopKeysResponse, + include_in_schema=prefix != "/end_user", + ) + + +def _register_export_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None: + @management_endpoint_wrapper + async def export( + entity_query: Annotated[EntityQuery, Depends(resolver.query)], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)], + prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)], + export_type: Annotated[ExportType, Query()] = ExportType.DAILY, + file_format: Annotated[Literal["csv", "json"], Query(alias="format")] = "csv", + ) -> StreamingResponse: + try: + resolved: ResolvedScope = await _resolved_scope( + resolver, + entity_query, + user_api_key_dict, + prisma_client, + user_aggregated=False, + ) + rows: Final = repository.export_rows(resolved.scope, export_type=export_type) + first_row: Final = await anext(rows, None) + return StreamingResponse( + _stream_export_rows(first_row, rows, file_format), + media_type="text/csv" if file_format == "csv" else "application/json", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": _content_disposition( + resolver.entity, + resolved.scope.start_date, + resolved.scope.end_date, + export_type, + file_format, + ), + }, + ) + except HTTPException: + raise + except Exception as exc: + verbose_proxy_logger.exception("Daily activity export failed: %s", exc) + raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"}) + + router.add_api_route( + f"{prefix}/daily/activity/export", + export, + methods=["GET"], + name=resolver.operation_names["export"], + tags=list(resolver.tags), + dependencies=(Depends(user_api_key_auth),), + response_class=StreamingResponse, + responses={ + 200: { + "description": "Streamed daily activity export", + "content": { + "text/csv": {"schema": {"type": "string"}}, + "application/json": {"schema": {"type": "array", "items": {"type": "object"}}}, + }, + } + }, + include_in_schema=prefix != "/end_user", + ) + + +def _register_cache_leakage_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None: + @management_endpoint_wrapper + async def cache_leakage_keys( + entity_query: Annotated[EntityQuery, Depends(resolver.query)], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)], + prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)], + limit: Annotated[ + int, Query(ge=1, le=constants.USAGE_CACHE_LEAKAGE_KEYS_MAX) + ] = constants.USAGE_CACHE_LEAKAGE_KEYS_DEFAULT, + ) -> CacheLeakageKeysResponse: + try: + resolved: ResolvedScope = await _resolved_scope( + resolver, + entity_query, + user_api_key_dict, + prisma_client, + user_aggregated=False, + ) + rows: tuple[KeySpendRow, ...] = await repository.cache_leakage_keys( + resolved.scope, + limit=limit, + ) + return CacheLeakageKeysResponse( + api_keys=await _key_activity_rows(repository, rows, resolved), + ) + except HTTPException: + raise + except Exception as exc: + verbose_proxy_logger.exception("Daily activity cache leakage keys failed: %s", exc) + raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"}) + + router.add_api_route( + f"{prefix}/daily/activity/aggregated/cache_leakage_keys", + cache_leakage_keys, + methods=["GET"], + name=resolver.operation_names["cache_leakage_keys"], + tags=list(resolver.tags), + dependencies=(Depends(user_api_key_auth),), + response_model=CacheLeakageKeysResponse, + include_in_schema=prefix != "/end_user", + ) + + +def register_daily_activity_routes(router: APIRouter, resolver: EntityScopeResolver) -> None: + for prefix in resolver.route_prefixes: + _register_aggregated_route(router, resolver, prefix) + _register_key_page_route(router, resolver, prefix) + _register_search_route(router, resolver, prefix) + _register_model_top_keys_route(router, resolver, prefix) + _register_export_route(router, resolver, prefix) + if resolver.entity == "user": + _register_cache_leakage_route(router, resolver, prefix) + + +for _resolver in ( + USER_RESOLVER, + TEAM_RESOLVER, + TAG_RESOLVER, + ORGANIZATION_RESOLVER, + CUSTOMER_RESOLVER, + AGENT_RESOLVER, +): + register_daily_activity_routes(router, _resolver) diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py new file mode 100644 index 00000000000..4ed3c680f15 --- /dev/null +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -0,0 +1,472 @@ +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Literal + +from fastapi import Query + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.agent_endpoints.endpoints import resolve_agent_daily_activity_scope +from litellm.proxy.management_endpoints.common_daily_activity import ScopeDenied +from litellm.proxy.management_endpoints.customer_endpoints import ( + customer_daily_activity_is_admin, + resolve_customer_daily_activity_scope, +) +from litellm.proxy.management_endpoints.internal_user_endpoints import resolve_user_daily_activity_entity_ids +from litellm.proxy.management_endpoints.organization_endpoints import resolve_organization_daily_activity_scope +from litellm.proxy.management_endpoints.tag_management_endpoints import get_tag_daily_activity_api_key_filter +from litellm.proxy.management_endpoints.team_endpoints import resolve_team_daily_activity_scope +from litellm.proxy.utils import PrismaClient +from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable + + +@dataclass(frozen=True, slots=True) +class EntityQuery: + entity_ids: tuple[str, ...] | None + exclude_entity_ids: tuple[str, ...] + api_key: str | None + start_date: str | None + end_date: str | None + model: str | None + timezone_offset_minutes: int | None + include_current_utc_day: bool + + +@dataclass(frozen=True, slots=True) +class ResolvedScope: + scope: DailyActivityScope + entity_metadata: Mapping[str, dict[str, object]] | None + + +Entity = Literal["user", "team", "tag", "organization", "customer", "agent"] +EntityScopeResolution = ResolvedScope | ScopeDenied +EntityScopeQuery = Callable[..., EntityQuery] +EntityScopeResolve = Callable[[UserAPIKeyAuth, EntityQuery, PrismaClient], Awaitable[EntityScopeResolution]] +OperationNames = Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class EntityScopeResolver: + entity: Entity + table: DailyActivityTable + entity_id_field: str + route_prefixes: tuple[str, ...] + tags: tuple[str, ...] + query: EntityScopeQuery + resolve: EntityScopeResolve + include_entity_breakdown: bool + operation_names: OperationNames + + +def _query_ids(value: str | None) -> tuple[str, ...] | None: + return tuple(value.split(",")) if value else None + + +def _query_excluded_ids(value: str | None) -> tuple[str, ...]: + return tuple(value.split(",")) if value else () + + +def _build_scope( + resolver: EntityScopeResolver, + query: EntityQuery, + entity_ids: Sequence[str] | None, + exclude_entity_ids: Sequence[str], + api_key_filter: str | Sequence[str] | None, + entity_metadata: Mapping[str, dict[str, object]] | None, +) -> ResolvedScope: + start_date: Final[str] = query.start_date or "" + end_date: Final[str] = query.end_date or "" + api_keys: Final[tuple[str, ...] | None] = ( + None + if api_key_filter is None or api_key_filter == "" + else (api_key_filter,) + if isinstance(api_key_filter, str) + else tuple(api_key_filter) + ) + return ResolvedScope( + scope=DailyActivityScope( + table=resolver.table, + entity_id_field=resolver.entity_id_field, + entity_ids=None if entity_ids is None else tuple(entity_ids), + exclude_entity_ids=tuple(exclude_entity_ids), + api_keys=api_keys, + start_date=start_date, + end_date=end_date, + model=query.model, + timezone_offset_minutes=query.timezone_offset_minutes, + include_current_utc_day=query.include_current_utc_day, + ), + entity_metadata=entity_metadata, + ) + + +async def _resolve_user( + user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient +) -> EntityScopeResolution: + entity_ids: Final = resolve_user_daily_activity_entity_ids( + user_id=query.entity_ids[0] if query.entity_ids is not None else None, + user_api_key_dict=user_api_key_dict, + ) + if isinstance(entity_ids, ScopeDenied): + return entity_ids + return _build_scope( + USER_RESOLVER, + query, + entity_ids, + query.exclude_entity_ids, + query.api_key, + None, + ) + + +async def _resolve_team( + user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient +) -> EntityScopeResolution: + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + team_scope: Final = await resolve_team_daily_activity_scope( + team_ids=",".join(query.entity_ids) if query.entity_ids is not None else None, + exclude_team_ids=",".join(query.exclude_entity_ids) if query.exclude_entity_ids else None, + api_key=query.api_key, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + return _build_scope( + TEAM_RESOLVER, + query, + team_scope.team_ids, + team_scope.exclude_team_ids or (), + team_scope.api_key_filter, + team_scope.team_alias_metadata, + ) + + +async def _resolve_tag( + user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient +) -> EntityScopeResolution: + api_key_filter: Final = await get_tag_daily_activity_api_key_filter( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + requested_api_key=query.api_key, + ) + return _build_scope( + TAG_RESOLVER, + query, + query.entity_ids, + query.exclude_entity_ids, + api_key_filter, + None, + ) + + +async def _resolve_organization( + user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient +) -> EntityScopeResolution: + org_scope: Final = await resolve_organization_daily_activity_scope( + organization_ids=query.entity_ids, + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + return _build_scope( + ORGANIZATION_RESOLVER, + query, + org_scope.organization_ids, + query.exclude_entity_ids, + query.api_key, + org_scope.organization_metadata, + ) + + +async def _resolve_customer( + user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient +) -> EntityScopeResolution: + if not customer_daily_activity_is_admin(user_api_key_dict): + return ScopeDenied(403, f"Admin-only endpoint. Your user role={user_api_key_dict.user_role}") + customer_scope: Final = await resolve_customer_daily_activity_scope( + end_user_ids=query.entity_ids, + prisma_client=prisma_client, + ) + return _build_scope( + CUSTOMER_RESOLVER, + query, + customer_scope.end_user_ids, + query.exclude_entity_ids, + query.api_key, + customer_scope.end_user_metadata, + ) + + +async def _resolve_agent( + user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient +) -> EntityScopeResolution: + agent_scope: Final = await resolve_agent_daily_activity_scope( + agent_ids=query.entity_ids, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + return _build_scope( + AGENT_RESOLVER, + query, + agent_scope.agent_ids, + query.exclude_entity_ids, + query.api_key, + agent_scope.agent_metadata, + ) + + +def _user_query( + start_date: str | None = Query(default=None, description="Start date in YYYY-MM-DD format"), + end_date: str | None = Query(default=None, description="End date in YYYY-MM-DD format"), + model: str | None = Query(default=None, description="Filter by specific model"), + api_key: str | None = Query(default=None, description="Filter by specific API key"), + user_id: str | None = Query( + default=None, + description="Filter by specific user ID. Admins can filter by any user or omit for global view. " + "Non-admins must provide their own user_id.", + ), + timezone: int | None = Query( + default=None, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = Query( + default=False, + description="When the range ends on the caller's current local day, extend it to " + "today's UTC bucket so spend written after the caller's local midnight (in UTC " + "terms) is included. Requires the timezone parameter. Historical ranges are " + "never extended.", + ), +) -> EntityQuery: + return EntityQuery( + entity_ids=(user_id,) if user_id is not None else None, + exclude_entity_ids=(), + api_key=api_key, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone, + include_current_utc_day=include_current_utc_day, + ) + + +def _team_query( + team_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + exclude_team_ids: str | None = None, + timezone: int | None = None, +) -> EntityQuery: + return EntityQuery( + entity_ids=_query_ids(team_ids), + exclude_entity_ids=_query_excluded_ids(exclude_team_ids), + api_key=api_key, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone, + include_current_utc_day=False, + ) + + +def _tag_query( + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + tags: str | None = None, + timezone: int | None = None, +) -> EntityQuery: + return EntityQuery( + entity_ids=_query_ids(tags), + exclude_entity_ids=(), + api_key=api_key, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone, + include_current_utc_day=False, + ) + + +def _organization_query( + organization_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + exclude_organization_ids: str | None = None, + timezone: int | None = None, +) -> EntityQuery: + return EntityQuery( + entity_ids=_query_ids(organization_ids), + exclude_entity_ids=_query_excluded_ids(exclude_organization_ids), + api_key=api_key, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone, + include_current_utc_day=False, + ) + + +def _customer_query( + end_user_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + exclude_end_user_ids: str | None = None, + timezone: int | None = None, +) -> EntityQuery: + return EntityQuery( + entity_ids=_query_ids(end_user_ids), + exclude_entity_ids=_query_excluded_ids(exclude_end_user_ids), + api_key=api_key, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone, + include_current_utc_day=False, + ) + + +def _agent_query( + agent_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + exclude_agent_ids: str | None = None, + timezone: int | None = None, +) -> EntityQuery: + return EntityQuery( + entity_ids=_query_ids(agent_ids), + exclude_entity_ids=_query_excluded_ids(exclude_agent_ids), + api_key=api_key, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone, + include_current_utc_day=False, + ) + + +USER_RESOLVER = EntityScopeResolver( + entity="user", + table=DailyActivityTable.USER, + entity_id_field="user_id", + route_prefixes=("/user",), + tags=("Budget & Spend Tracking", "Internal User management"), + query=_user_query, + resolve=_resolve_user, + include_entity_breakdown=False, + operation_names=MappingProxyType( + { + "aggregated": "get_user_daily_activity_aggregated", + "search": "get_user_daily_activity_aggregated_search", + "key_page": "get_user_daily_activity_aggregated_keys", + "model_top_keys": "get_user_daily_activity_model_top_keys", + "export": "get_user_daily_activity_export", + "cache_leakage_keys": "get_user_daily_activity_cache_leakage_keys", + } + ), +) +TEAM_RESOLVER = EntityScopeResolver( + entity="team", + table=DailyActivityTable.TEAM, + entity_id_field="team_id", + route_prefixes=("/team",), + tags=("team management",), + query=_team_query, + resolve=_resolve_team, + include_entity_breakdown=True, + operation_names=MappingProxyType( + { + "aggregated": "get_team_daily_activity_aggregated", + "search": "get_team_daily_activity_aggregated_search", + "key_page": "get_team_daily_activity_aggregated_keys", + "model_top_keys": "get_team_daily_activity_model_top_keys", + "export": "get_team_daily_activity_export", + } + ), +) +TAG_RESOLVER = EntityScopeResolver( + entity="tag", + table=DailyActivityTable.TAG, + entity_id_field="tag", + route_prefixes=("/tag",), + tags=("tag management",), + query=_tag_query, + resolve=_resolve_tag, + include_entity_breakdown=True, + operation_names=MappingProxyType( + { + "aggregated": "get_tag_daily_activity_aggregated", + "search": "get_tag_daily_activity_aggregated_search", + "key_page": "get_tag_daily_activity_aggregated_keys", + "model_top_keys": "get_tag_daily_activity_model_top_keys", + "export": "get_tag_daily_activity_export", + } + ), +) +ORGANIZATION_RESOLVER = EntityScopeResolver( + entity="organization", + table=DailyActivityTable.ORGANIZATION, + entity_id_field="organization_id", + route_prefixes=("/organization",), + tags=("organization management",), + query=_organization_query, + resolve=_resolve_organization, + include_entity_breakdown=True, + operation_names=MappingProxyType( + { + "aggregated": "get_organization_daily_activity_aggregated", + "search": "get_organization_daily_activity_aggregated_search", + "key_page": "get_organization_daily_activity_aggregated_keys", + "model_top_keys": "get_organization_daily_activity_model_top_keys", + "export": "get_organization_daily_activity_export", + } + ), +) +CUSTOMER_RESOLVER = EntityScopeResolver( + entity="customer", + table=DailyActivityTable.CUSTOMER, + entity_id_field="end_user_id", + route_prefixes=("/customer", "/end_user"), + tags=("Customer Management",), + query=_customer_query, + resolve=_resolve_customer, + include_entity_breakdown=True, + operation_names=MappingProxyType( + { + "aggregated": "get_customer_daily_activity_aggregated", + "search": "get_customer_daily_activity_aggregated_search", + "key_page": "get_customer_daily_activity_aggregated_keys", + "model_top_keys": "get_customer_daily_activity_model_top_keys", + "export": "get_customer_daily_activity_export", + } + ), +) +AGENT_RESOLVER = EntityScopeResolver( + entity="agent", + table=DailyActivityTable.AGENT, + entity_id_field="agent_id", + route_prefixes=("/agent",), + tags=("Agent Management",), + query=_agent_query, + resolve=_resolve_agent, + include_entity_breakdown=True, + operation_names=MappingProxyType( + { + "aggregated": "get_agent_daily_activity_aggregated", + "search": "get_agent_daily_activity_aggregated_search", + "key_page": "get_agent_daily_activity_aggregated_keys", + "model_top_keys": "get_agent_daily_activity_model_top_keys", + "export": "get_agent_daily_activity_export", + } + ), +) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1fd63c6d456..04b8ec56ae2 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -54,10 +54,9 @@ from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventH from litellm.proxy.management.teams.access import is_team_admin from litellm.proxy.management_endpoints.common_daily_activity import ( DailySpendRecord, - daily_activity_repository, - daily_activity_scope, + ScopeDenied, get_daily_activity, - get_daily_activity_aggregated, + raise_public, ) from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_view, @@ -2864,6 +2863,18 @@ async def ui_view_users( # Using shared metric helper implementations from common_daily_activity +def resolve_user_daily_activity_entity_ids( + *, user_id: str | None, user_api_key_dict: UserAPIKeyAuth +) -> tuple[str, ...] | None | ScopeDenied: + if _user_has_admin_view(user_api_key_dict): + return (user_id,) if user_id is not None else None + + caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict) + if user_id is not None and user_id != caller_user_id: + return ScopeDenied(403, "Non-admin users can only view their own spend data.") + return (caller_user_id,) + + async def _resolve_user_email_metadata( prisma_client: "PrismaClient", records: Sequence[DailySpendRecord] ) -> dict[str, dict]: @@ -2958,20 +2969,13 @@ async def get_user_daily_activity( ) try: - is_admin: Final = _user_has_admin_view(user_api_key_dict) - - if is_admin: - entity_id = user_id # None means global view, otherwise filter by user - else: - caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict) - if user_id is None: - user_id = caller_user_id - if user_id != caller_user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={"error": "Non-admin users can only view their own spend data."}, - ) - entity_id = user_id + resolved_entity_ids: Final = resolve_user_daily_activity_entity_ids( + user_id=user_id, + user_api_key_dict=user_api_key_dict, + ) + if isinstance(resolved_entity_ids, ScopeDenied): + raise_public(resolved_entity_ids) + entity_id: Final[str | None] = resolved_entity_ids[0] if resolved_entity_ids is not None else None return await get_daily_activity( prisma_client=prisma_client, @@ -2998,112 +3002,3 @@ async def get_user_daily_activity( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Failed to fetch analytics: {e}"}, ) - - -@router.get( - "/user/daily/activity/aggregated", - tags=["Budget & Spend Tracking", "Internal User management"], - dependencies=[Depends(user_api_key_auth)], - response_model=SpendAnalyticsPaginatedResponse, -) -@management_endpoint_wrapper -async def get_user_daily_activity_aggregated( - start_date: str | None = fastapi.Query( - default=None, - description="Start date in YYYY-MM-DD format", - ), - end_date: str | None = fastapi.Query( - default=None, - description="End date in YYYY-MM-DD format", - ), - model: str | None = fastapi.Query( - default=None, - description="Filter by specific model", - ), - api_key: str | None = fastapi.Query( - default=None, - description="Filter by specific API key", - ), - user_id: str | None = fastapi.Query( - default=None, - description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.", - ), - timezone: int | None = fastapi.Query( - default=None, - description="Timezone offset in minutes from UTC (e.g., 480 for PST). " - "Matches JavaScript's Date.getTimezoneOffset() convention.", - ), - include_current_utc_day: bool = fastapi.Query( - default=False, - description="When the range ends on the caller's current local day, extend it to " - "today's UTC bucket so spend written after the caller's local midnight (in UTC " - "terms) is included. Requires the timezone parameter. Historical ranges are " - "never extended.", - ), - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -) -> SpendAnalyticsPaginatedResponse: - """ - Aggregated analytics for a user's daily activity without pagination. - Returns the same response shape as the paginated endpoint with page metadata set to single-page. - - Reads daily spend records that only ever accumulate and are never affected by budget - resets. Their total can legitimately exceed the `spend` field returned by - `/v2/user/info`, which is a running budget counter that every budget reset sets back - to zero (or to the overage above `max_budget` when `budget_rollover` is enabled). - """ - from litellm.proxy.proxy_server import prisma_client - - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - - if start_date is None or end_date is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "Please provide start_date and end_date"}, - ) - - try: - is_admin: Final = _user_has_admin_view(user_api_key_dict) - - if is_admin: - entity_id = user_id # None means global view, otherwise filter by user - else: - caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict) - if user_id is None: - user_id = caller_user_id - if user_id != caller_user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={"error": "Non-admin users can only view their own spend data."}, - ) - entity_id = user_id - - repository: Final = daily_activity_repository(prisma_client) - scope: Final = daily_activity_scope( - "litellm_dailyuserspend", - "user_id", - entity_id, - None, - api_key, - start_date, - end_date, - model, - timezone, - include_current_utc_day, - ) - return await get_daily_activity_aggregated( - repository, - scope, - ) - - except HTTPException: - raise - except Exception as e: - verbose_proxy_logger.exception("/user/daily/activity/aggregated: Exception occured - %s", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Failed to fetch analytics: {e}"}, - ) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 24bbd2b4b1f..c8ae7af41db 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -14,10 +14,12 @@ Endpoints for /organization operations #### ORGANIZATION MANAGEMENT #### from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import ( TYPE_CHECKING, Annotated, Final, + NamedTuple, Protocol, cast, # noqa: TID251 # prisma types Json columns as fields.Json but reads back plain python values overload, @@ -62,6 +64,7 @@ from litellm.proxy.management_helpers.utils import ( ) from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.repositories.budget_repository import BudgetRepository +from litellm.repositories.chunked_in import find_many_in from litellm.repositories.object_permission_repository import ObjectPermissionRepository from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.table_repositories import OrganizationMembershipRepository @@ -583,43 +586,23 @@ async def get_organization_daily_activity( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - # Parse comma-separated ids - org_ids_list = organization_ids.split(",") if organization_ids else None + org_ids: Final = tuple(organization_ids.split(",")) if organization_ids else None exclude_org_ids_list: list[str] | None = None if exclude_organization_ids: exclude_org_ids_list = exclude_organization_ids.split(",") if exclude_organization_ids else None - # Restrict non-proxy-admins to only organizations where they are org_admin - if not _user_has_admin_view(user_api_key_dict): - memberships: Final = await _table(OrganizationMembershipRepository(prisma_client)).find_many( - where={"user_id": user_api_key_dict.user_id} - ) - admin_org_ids = [m.organization_id for m in memberships if m.user_role == LitellmUserRoles.ORG_ADMIN.value] - if org_ids_list is None: - # Default to orgs where user is org_admin - org_ids_list = admin_org_ids - else: - # Ensure user is org_admin for all requested orgs - for org_id in org_ids_list: - if org_id not in admin_org_ids: - raise HTTPException( - status_code=403, - detail={"error": f"User is not org_admin for Organization= {org_id}."}, - ) + org_scope: Final = await resolve_organization_daily_activity_scope( + organization_ids=org_ids, + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) - # Fetch organization aliases for metadata - where_condition: Final = _STR_OBJECT_DICT_ADAPTER.validate_python({}) - if org_ids_list is not None: - where_condition["organization_id"] = {"in": list(org_ids_list)} - org_aliases: Final = await _table(OrganizationRepository(prisma_client)).find_many(where=where_condition) - - # Query daily activity for organizations return await get_daily_activity( prisma_client=prisma_client, table_name="litellm_dailyorganizationspend", entity_id_field="organization_id", - entity_id=org_ids_list, - entity_metadata_field={o.organization_id: {"organization_alias": o.organization_alias} for o in org_aliases}, + entity_id=None if org_scope.organization_ids is None else list(org_scope.organization_ids), + entity_metadata_field=org_scope.organization_metadata, exclude_entity_ids=exclude_org_ids_list, start_date=start_date, end_date=end_date, @@ -630,6 +613,56 @@ async def get_organization_daily_activity( ) +class _OrganizationDailyActivityScope(NamedTuple): + organization_ids: tuple[str, ...] | None + organization_metadata: Mapping[str, dict[str, object]] + + +async def resolve_organization_daily_activity_scope( + *, + organization_ids: tuple[str, ...] | None, + prisma_client: PrismaClient, + user_api_key_dict: UserAPIKeyAuth, +) -> _OrganizationDailyActivityScope: + is_admin: Final = _user_has_admin_view(user_api_key_dict) + memberships: Final = ( + await _table(OrganizationMembershipRepository(prisma_client)).find_many( + where={"user_id": user_api_key_dict.user_id} + ) + if not is_admin + else () + ) + admin_organization_ids: Final = tuple( + membership.organization_id + for membership in memberships + if membership.user_role == LitellmUserRoles.ORG_ADMIN.value + ) + if not is_admin and organization_ids is not None: + for organization_id in organization_ids: + if organization_id not in admin_organization_ids: + raise HTTPException( + status_code=403, + detail={"error": f"User is not org_admin for Organization= {organization_id}."}, + ) + resolved_organization_ids: Final[tuple[str, ...] | None] = ( + organization_ids if is_admin or organization_ids is not None else admin_organization_ids + ) + + organization_table: Final = _table(OrganizationRepository(prisma_client)) + organization_rows: Final = ( + await find_many_in(organization_table, "organization_id", resolved_organization_ids) + if resolved_organization_ids is not None + else await organization_table.find_many(where={}) + ) + metadata: Final = MappingProxyType( + { + organization.organization_id: {"organization_alias": organization.organization_alias} + for organization in organization_rows + } + ) + return _OrganizationDailyActivityScope(resolved_organization_ids, metadata) + + async def _set_object_permission( data: NewOrganizationRequest, prisma_client: PrismaClient | None, diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index ab33d4bd766..5bf16379d05 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -190,7 +190,7 @@ async def _get_tag_list_scope( return {"api_key": {"in": scoped_api_keys}} -async def _get_tag_daily_activity_api_key_filter( +async def get_tag_daily_activity_api_key_filter( prisma_client: "PrismaClient", user_api_key_dict: UserAPIKeyAuth, requested_api_key: str | None, @@ -757,7 +757,7 @@ async def get_tag_daily_activity( # Convert comma-separated tags string to list if provided tag_list: Final = tags.split(",") if tags else None - scoped_api_key_filter: Final = await _get_tag_daily_activity_api_key_filter( + scoped_api_key_filter: Final = await get_tag_daily_activity_api_key_filter( prisma_client=prisma_client, user_api_key_dict=user_api_key_dict, requested_api_key=api_key, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 040b27b5802..88d51729c13 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -124,11 +124,6 @@ from litellm.proxy.hooks.model_max_budget_limiter import ( ) from litellm.proxy.management.teams.access import TEAM_OR_ORG_ADMIN, TeamRole, is_team_admin, team_access_denied from litellm.proxy.management.teams.dependencies import get_team_access -from litellm.proxy.management_endpoints.common_daily_activity import ( - daily_activity_repository, - daily_activity_scope, - get_daily_activity_aggregated, -) from litellm.proxy.management_endpoints.common_utils import ( _check_disable_global_guardrails_caller_permission, _check_passthrough_routes_caller_permission, @@ -6523,7 +6518,7 @@ class _TeamDailyActivityScope(NamedTuple): api_key_filter: str | list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions -async def _resolve_team_daily_activity_scope( +async def resolve_team_daily_activity_scope( *, team_ids: str | None, exclude_team_ids: str | None, @@ -6606,11 +6601,14 @@ async def _resolve_team_daily_activity_scope( user_api_keys = [key.token for key in user_keys if key.token] # If user has no API keys, return empty result if not user_api_keys: - user_api_keys = [""] # Use empty string to ensure no matches + user_api_keys = [] - # If api_key parameter is provided, use it; otherwise use user_api_keys if set - final_api_key_filter: str | list[str] | None = api_key - if final_api_key_filter is None and user_api_keys is not None: + final_api_key_filter: str | list[str] | None + if user_api_keys is None: + final_api_key_filter = api_key + elif api_key: + final_api_key_filter = api_key if api_key in user_api_keys else [] + else: final_api_key_filter = user_api_keys return _TeamDailyActivityScope( @@ -6661,7 +6659,7 @@ async def get_team_daily_activity( if prisma_client is None: raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) - scope: Final = await _resolve_team_daily_activity_scope( + scope: Final = await resolve_team_daily_activity_scope( team_ids=team_ids, exclude_team_ids=exclude_team_ids, api_key=api_key, @@ -6690,7 +6688,7 @@ async def get_team_daily_activity( _MAX_AGGREGATED_RANGE_DAYS: Final = 400 -def _aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None: +def aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None: """The aggregated endpoint has no pagination to bound its work, so malformed dates and ranges wider than the UI ever requests are rejected before querying.""" if start_date is None or end_date is None: @@ -6707,82 +6705,6 @@ def _aggregated_date_range_error(start_date: str | None, end_date: str | None) - return None -@router.get( - "/team/daily/activity/aggregated", - response_model=SpendAnalyticsPaginatedResponse, - tags=["team management"], -) -async def get_team_daily_activity_aggregated( - user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], - team_ids: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - model: str | None = None, - api_key: str | None = None, - exclude_team_ids: str | None = None, - timezone: int | None = None, -): - """ - Aggregated daily activity for teams without pagination, including per-team breakdown. - - One SQL GROUPING SETS pass returns every day in the range regardless of row - volume, so callers never reassemble pages. Same response shape as the - paginated endpoint with page metadata pinned to a single page. - - Args: - team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. - start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). - end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). - model (Optional[str]): Filter by model name. - api_key (Optional[str]): Filter by API key. - exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. - timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention. - Returns: - SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range. - """ - from litellm.proxy.proxy_server import ( - prisma_client, - proxy_logging_obj, - user_api_key_cache, - ) - - if prisma_client is None: - raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) - - range_error: Final = _aggregated_date_range_error(start_date, end_date) - if range_error is not None: - raise _daily_activity_error(status_code=400, message=range_error) - - scope: Final = await _resolve_team_daily_activity_scope( - team_ids=team_ids, - exclude_team_ids=exclude_team_ids, - api_key=api_key, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - - repository: Final = daily_activity_repository(prisma_client) - activity_scope: Final = daily_activity_scope( - "litellm_dailyteamspend", - "team_id", - scope.team_ids, - scope.exclude_team_ids, - scope.api_key_filter, - start_date, - end_date, - model, - timezone, - ) - return await get_daily_activity_aggregated( - repository, - activity_scope, - entity_metadata_field=scope.team_alias_metadata, - include_entity_breakdown=True, - ) - - def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str: team_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + team_count)) user_clause: Final = f' AND sl."user" = ${3 + team_count}' if restrict_to_user else "" @@ -6850,14 +6772,14 @@ async def get_team_spend_by_user( if prisma_client is None: raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) - range_error: Final = _aggregated_date_range_error(start_date, end_date) + range_error: Final = aggregated_date_range_error(start_date, end_date) if range_error is not None or start_date is None or end_date is None: raise _daily_activity_error(status_code=400, message=range_error or "Please provide start_date and end_date") if not team_ids: raise _daily_activity_error(status_code=400, message="Please provide team_ids") - scope: Final = await _resolve_team_daily_activity_scope( + scope: Final = await resolve_team_daily_activity_scope( team_ids=team_ids, exclude_team_ids=None, api_key=None, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9abf949ec1e..9282f216a52 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -604,6 +604,7 @@ from litellm.proxy.management_endpoints.cost_tracking_settings import ( from litellm.proxy.management_endpoints.customer_endpoints import ( router as customer_router, ) +from litellm.proxy.management_endpoints.daily_activity_routes import router as daily_activity_router from litellm.proxy.management_endpoints.fallback_management_endpoints import ( router as fallback_management_router, ) @@ -19951,6 +19952,7 @@ app.include_router(pass_through_router) app.include_router(health_router) app.include_router(key_management_router) app.include_router(internal_user_router) +app.include_router(daily_activity_router) app.include_router(password_management_router) app.include_router(session_management_router) app.include_router(team_router) diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 5f997945ec2..37804032569 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -124,6 +124,51 @@ class SpendAnalyticsPaginatedResponse(BaseModel): metadata: DailySpendMetadata = Field(default_factory=DailySpendMetadata) +class KeyActivityRow(BaseModel): + api_key: str + metrics: SpendMetrics + metadata: KeyMetadata + + +class KeySpendMetrics(BaseModel): + spend: float = 0.0 + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + api_requests: int = 0 + successful_requests: int = 0 + failed_requests: int = 0 + cache_read_input_tokens: int = 0 + cache_creation_input_tokens: int = 0 + + +class KeySpendActivityRow(BaseModel): + api_key: str + metrics: KeySpendMetrics + metadata: KeyMetadata + + +class DailyActivityKeySearchResponse(BaseModel): + api_keys: list[KeyActivityRow] + + +class DailyActivityKeyPageResponse(BaseModel): + api_keys: list[KeySpendActivityRow] + total_api_keys: int + offset: int + limit: int + + +class ModelTopKeysResponse(BaseModel): + model: str + by_model_group: bool + api_keys: list[KeySpendActivityRow] + + +class CacheLeakageKeysResponse(BaseModel): + api_keys: list[KeySpendActivityRow] + + class LiteLLM_DailyUserSpend(BaseModel): id: str user_id: str diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 99ed82dcad8..fe89257e83b 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -12,14 +12,34 @@ # Read-only analytics and spend reporting; observability, not Terraform-managed state GET /agent/daily/activity +GET /agent/daily/activity/aggregated +GET /agent/daily/activity/aggregated/keys +GET /agent/daily/activity/aggregated/model_top_keys +GET /agent/daily/activity/aggregated/search +GET /agent/daily/activity/export GET /customer/daily/activity +GET /customer/daily/activity/aggregated +GET /customer/daily/activity/aggregated/keys +GET /customer/daily/activity/aggregated/model_top_keys +GET /customer/daily/activity/aggregated/search +GET /customer/daily/activity/export GET /guardrails/usage/detail/{guardrail_id} GET /guardrails/usage/logs GET /guardrails/usage/overview GET /key/spend/report GET /organization/daily/activity +GET /organization/daily/activity/aggregated +GET /organization/daily/activity/aggregated/keys +GET /organization/daily/activity/aggregated/model_top_keys +GET /organization/daily/activity/aggregated/search +GET /organization/daily/activity/export GET /organization/spend/report GET /tag/daily/activity +GET /tag/daily/activity/aggregated +GET /tag/daily/activity/aggregated/keys +GET /tag/daily/activity/aggregated/model_top_keys +GET /tag/daily/activity/aggregated/search +GET /tag/daily/activity/export GET /tag/dau GET /tag/distinct GET /tag/mau @@ -28,10 +48,19 @@ GET /tag/user-agent/per-user-analytics GET /tag/wau GET /team/daily/activity GET /team/daily/activity/aggregated +GET /team/daily/activity/aggregated/keys +GET /team/daily/activity/aggregated/model_top_keys +GET /team/daily/activity/aggregated/search +GET /team/daily/activity/export GET /team/spend/by_user GET /team/spend/report GET /user/daily/activity GET /user/daily/activity/aggregated +GET /user/daily/activity/aggregated/keys +GET /user/daily/activity/aggregated/cache_leakage_keys +GET /user/daily/activity/aggregated/model_top_keys +GET /user/daily/activity/aggregated/search +GET /user/daily/activity/export GET /user/spend/report # Admin UI helper endpoints; serve UI forms and caller-scoped views, not desired state diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt index 9acc92e2afd..0a95ebfc01f 100644 --- a/tests/code_coverage_tests/unbounded_in_baseline.txt +++ b/tests/code_coverage_tests/unbounded_in_baseline.txt @@ -11,7 +11,6 @@ litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py backfill_null_oau litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py backfill_null_oauth2_flows prisma server_id.in `server_ids` 0 litellm/proxy/_experimental/mcp_server/toolset_db.py list_mcp_toolsets prisma toolset_id.in `toolset_ids` 0 litellm/proxy/agent_endpoints/endpoints.py _attach_keys_to_agents prisma agent_id.in `agent_ids` 0 -litellm/proxy/agent_endpoints/endpoints.py get_agent_daily_activity prisma agent_id.in `list(agent_ids_list)` 0 litellm/proxy/agent_endpoints/endpoints.py get_agents prisma agent_id.in `agent_ids` 0 litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py SkillVisibility.where prisma name.in `sorted(self.granted)` 0 litellm/proxy/auth/auth_checks.py _fetch_uncached_model_access_group_budgets prisma access_group_name.in `list(uncached_groups)` 0 @@ -43,7 +42,6 @@ litellm/proxy/management_endpoints/common_utils.py _team_admin_can_invite_user p litellm/proxy/management_endpoints/common_utils.py _user_has_admin_privileges prisma team_id.in `user_obj.teams` 0 litellm/proxy/management_endpoints/customer_endpoints.py delete_end_user prisma user_id.in `data.user_ids` 0 litellm/proxy/management_endpoints/customer_endpoints.py delete_end_user prisma user_id.in `data.user_ids` 1 -litellm/proxy/management_endpoints/customer_endpoints.py get_customer_daily_activity prisma user_id.in `list(end_user_ids_list)` 0 litellm/proxy/management_endpoints/internal_user_endpoints.py _check_user_info_v2_access prisma team_id.in `caller_user.teams` 0 litellm/proxy/management_endpoints/internal_user_endpoints.py _resolve_user_email_metadata prisma user_id.in `list(user_ids)` 0 litellm/proxy/management_endpoints/internal_user_endpoints.py delete_user prisma created_by.in `data.user_ids` 0 @@ -73,7 +71,6 @@ litellm/proxy/management_endpoints/mcp_management_endpoints.py fetch_all_mcp_ser litellm/proxy/management_endpoints/model_access_group_management_endpoints.py update_deployments_with_access_group prisma model_name.in `model_names` 0 litellm/proxy/management_endpoints/model_management_endpoints.py delete_team_models prisma model_id.in `model_ids` 0 litellm/proxy/management_endpoints/organization_endpoints.py deprecated_info_organization prisma organization_id.in `data.organizations` 0 -litellm/proxy/management_endpoints/organization_endpoints.py get_organization_daily_activity prisma organization_id.in `list(org_ids_list)` 0 litellm/proxy/management_endpoints/organization_endpoints.py list_organization prisma organization_id.in `membership_org_ids` 0 litellm/proxy/management_endpoints/router_weights.py validate_router_settings_weights prisma model_id.in `list(deployment_ids)` 0 litellm/proxy/management_endpoints/session_endpoints.py revoke_ui_session_keys prisma token.in `revoked_tokens` 0 @@ -91,7 +88,7 @@ litellm/proxy/management_endpoints/team_endpoints.py _build_team_list_where_cond litellm/proxy/management_endpoints/team_endpoints.py _get_keys_count_by_team prisma team_id.in `page_team_ids` 0 litellm/proxy/management_endpoints/team_endpoints.py _hydrate_member_user_details prisma user_id.in `sorted(user_ids)` 0 litellm/proxy/management_endpoints/team_endpoints.py _resolve_existing_member_user_ids prisma user_id.in `sorted(requested_user_ids)` 0 -litellm/proxy/management_endpoints/team_endpoints.py _resolve_team_daily_activity_scope prisma team_id.in `list(team_ids_list)` 0 +litellm/proxy/management_endpoints/team_endpoints.py resolve_team_daily_activity_scope prisma team_id.in `list(team_ids_list)` 0 litellm/proxy/management_endpoints/team_endpoints.py _sweep_deleted_team_references prisma team_id.in `tuple(team_ids)` 0 litellm/proxy/management_endpoints/team_endpoints.py _sweep_deleted_team_references_tx prisma team_id.in `tuple(team_ids)` 0 litellm/proxy/management_endpoints/team_endpoints.py _team_member_delete prisma user_id.in `sorted(addressed_user_ids)` 0 diff --git a/tests/integration/spend/golden/daily_activity_team_aggregated.json b/tests/integration/spend/golden/daily_activity_team_aggregated.json new file mode 100644 index 00000000000..f434e767700 --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_team_aggregated.json @@ -0,0 +1,1169 @@ +{ + "metadata": {"entity_total_api_keys":{"team-1":5}, + "api_key_limit": 100, + "has_more": false, + "page": 1, + "total_api_keys": 5, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": { + "team-1": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": { + "team_alias": "Usage Team" + }, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/golden/daily_activity_team_paginated.json b/tests/integration/spend/golden/daily_activity_team_paginated.json new file mode 100644 index 00000000000..e56ebbffe55 --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_team_paginated.json @@ -0,0 +1,1197 @@ +{ + "metadata": {"entity_total_api_keys":null, + "api_key_limit": null, + "has_more": false, + "page": 1, + "total_api_keys": null, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "__ptu_flat_cost__": { + "metadata": { + "key_alias": null, + "key_exists": false, + "team_id": null, + "user_email": null, + "user_id": null + }, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": { + "team-1": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": { + "team_alias": "Usage Team" + }, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/golden/daily_activity_user_aggregated.json b/tests/integration/spend/golden/daily_activity_user_aggregated.json new file mode 100644 index 00000000000..de1bbd63977 --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_user_aggregated.json @@ -0,0 +1,1002 @@ +{ + "metadata": {"entity_total_api_keys":null, + "api_key_limit": 100, + "has_more": false, + "page": 1, + "total_api_keys": 5, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": {}, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/golden/daily_activity_user_paginated.json b/tests/integration/spend/golden/daily_activity_user_paginated.json new file mode 100644 index 00000000000..b27bc405ebd --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_user_paginated.json @@ -0,0 +1,1198 @@ +{ + "metadata": {"entity_total_api_keys":null, + "api_key_limit": null, + "has_more": false, + "page": 1, + "total_api_keys": null, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "__ptu_flat_cost__": { + "metadata": { + "key_alias": null, + "key_exists": false, + "team_id": null, + "user_email": null, + "user_id": null + }, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": { + "user-1": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": { + "user_alias": null, + "user_email": "user@example.com" + }, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/test_daily_activity_routes.py b/tests/integration/spend/test_daily_activity_routes.py new file mode 100644 index 00000000000..7c8db96966e --- /dev/null +++ b/tests/integration/spend/test_daily_activity_routes.py @@ -0,0 +1,514 @@ +import csv +import hashlib +import io +import uuid +from datetime import datetime, timedelta, timezone +from itertools import chain +from pathlib import Path +from typing import Final + +import httpx +import pytest +from fastapi import FastAPI +from integration._support.client import JSON_OBJECT, Gateway, eventually, object_value, string_value +from integration._support.database import read_rows +from integration._support.process import owned_proxy +from integration._support.upstream import JsonResponse, delete_scenario, register_scenario +from integration.spend.test_daily_activity_repository import _daily_activity_database, _PrismaDatabase, _repository + +from litellm import constants +from litellm.proxy import proxy_server +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.daily_activity_routes import ( + get_daily_activity_prisma_client, + get_daily_activity_repository, +) +from litellm.proxy.management_endpoints.daily_activity_routes import ( + router as daily_activity_router, +) +from litellm.proxy.management_endpoints.internal_user_endpoints import router as internal_user_router +from litellm.proxy.management_endpoints.team_endpoints import router as team_router +from litellm.types.proxy.management_endpoints.common_daily_activity import DailyActivityKeyPageResponse + + +def _delete_organization(gateway: Gateway, organization_id: str) -> None: + response: Final = gateway.request("DELETE", "/organization/delete", {"organization_ids": [organization_id]}) + assert response.status_code == 200, response.text + + +def _delete_tag(gateway: Gateway, tag: str) -> None: + response: Final = gateway.request("POST", "/tag/delete", {"name": tag}) + assert response.status_code == 200, response.text + + +def _delete_end_user(gateway: Gateway, end_user_id: str) -> None: + response: Final = gateway.request("POST", "/end_user/delete", {"user_ids": [end_user_id]}) + assert response.status_code == 200, response.text + + +def _delete_agent(gateway: Gateway, agent_id: str) -> None: + response: Final = gateway.request("DELETE", f"/v1/agents/{agent_id}") + assert response.status_code == 200, response.text + + +def _daily_activity_request( + gateway: Gateway, + *, + model: str, + key: str, + end_user_id: str, + tag: str, + request_number: int, +) -> None: + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + JSON_OBJECT.validate_python( + { + "model": model, + "messages": [{"role": "user", "content": f"daily activity {request_number}"}], + "metadata": {"tags": [tag]}, + "user": end_user_id, + } + ), + key=key, + ) + assert response.status_code == 200, response.text + + +def _aggregate_result_api_keys(result: object) -> tuple[str, ...]: + result_body: Final = object_value(result) + breakdown: Final = object_value(result_body["breakdown"]) + api_keys: Final = object_value(breakdown["api_keys"]) + return tuple(api_keys) + + +def _aggregate_top_keys(results: object) -> frozenset[str]: + assert isinstance(results, list) + api_keys_by_result: Final = tuple(_aggregate_result_api_keys(result) for result in results) + return frozenset(chain.from_iterable(api_keys_by_result)) + + +def _assert_entity_activity_routes( + gateway: Gateway, + *, + prefix: str, + entity_param: str, + entity_id: str, + table: str, + entity_column: str, + date_params: dict[str, str], + target_digest: str, + model: str, +) -> None: + params: Final = {**date_params, entity_param: entity_id} + persisted_rows: Final = eventually( + lambda: read_rows( + f'SELECT api_key FROM "{table}" WHERE "{entity_column}"=%s AND date BETWEEN %s AND %s', + (entity_id, date_params["start_date"], date_params["end_date"]), + ), + lambda rows: len(rows) == 6, + seconds=70, + ) + aggregated: Final = gateway.request( + "GET", + f"{prefix}/daily/activity/aggregated", + params={**params, "api_key_limit": "3"}, + ) + assert aggregated.status_code == 200, aggregated.text + aggregate_body: Final = object_value(aggregated.json()) + metadata: Final = object_value(aggregate_body["metadata"]) + total_api_keys: Final = metadata["total_api_keys"] + api_key_limit: Final = metadata["api_key_limit"] + assert isinstance(total_api_keys, int) and total_api_keys == 6, aggregated.text + assert isinstance(api_key_limit, int) and api_key_limit == 3, aggregated.text + assert total_api_keys > api_key_limit, aggregated.text + assert metadata["total_api_requests"] == 8, aggregated.text + top_api_keys: Final = _aggregate_top_keys(aggregate_body["results"]) + assert target_digest not in top_api_keys, aggregated.text + ranked_rows: Final = read_rows( + f'SELECT api_key FROM "{table}" WHERE "{entity_column}"=%s AND date BETWEEN %s AND %s ' + "AND api_key <> %s GROUP BY api_key ORDER BY SUM(spend::numeric) DESC, api_key", + ( + entity_id, + date_params["start_date"], + date_params["end_date"], + constants.PTU_SENTINEL_API_KEY, + ), + ) + ranked_keys: Final = tuple(string_value(row["api_key"]) for row in ranked_rows) + page_responses: Final = tuple( + gateway.request( + "GET", + f"{prefix}/daily/activity/aggregated/keys", + params={**params, "offset": str(offset), "limit": "2"}, + ) + for offset in range(0, len(ranked_keys), 2) + ) + assert all(response.status_code == 200 for response in page_responses), tuple( + response.text for response in page_responses + ) + page_bodies: Final = tuple( + DailyActivityKeyPageResponse.model_validate_json(response.content) for response in page_responses + ) + page_api_keys: Final = tuple(tuple(row.api_key for row in body.api_keys) for body in page_bodies) + paged_keys: Final = tuple(chain.from_iterable(page_api_keys)) + assert tuple(body.total_api_keys for body in page_bodies) == (6,) * len(page_bodies) + assert paged_keys == ranked_keys + assert len(paged_keys) == len(frozenset(paged_keys)) + assert frozenset(paged_keys[:3]) == top_api_keys, aggregated.text + + key_details: Final = gateway.request( + "GET", + f"{prefix}/daily/activity/aggregated", + params={**params, "api_key": target_digest}, + ) + assert key_details.status_code == 200, key_details.text + key_details_body: Final = JSON_OBJECT.validate_json(key_details.content) + assert object_value(key_details_body["metadata"])["total_api_keys"] == 1, key_details.text + assert _aggregate_top_keys(key_details_body["results"]) == frozenset((target_digest,)), key_details.text + + searched: Final = gateway.request( + "GET", + f"{prefix}/daily/activity/aggregated/search", + params={**params, "search": target_digest}, + ) + assert searched.status_code == 200, searched.text + search_body: Final = object_value(searched.json()) + search_rows: Final = search_body["api_keys"] + assert isinstance(search_rows, list) and len(search_rows) == 1, searched.text + assert object_value(search_rows[0])["api_key"] == target_digest, searched.text + + top_keys: Final = gateway.request( + "GET", + f"{prefix}/daily/activity/aggregated/model_top_keys", + params={**params, "model_group": model}, + ) + assert top_keys.status_code == 200, top_keys.text + top_body: Final = object_value(top_keys.json()) + top_rows: Final = top_body["api_keys"] + assert isinstance(top_rows, list) and len(top_rows) == 5, top_keys.text + top_spends: Final = tuple(object_value(object_value(row)["metrics"])["spend"] for row in top_rows[:2]) + assert top_spends == ( + pytest.approx(0.12), + pytest.approx(0.12), + ), top_keys.text + + exported: Final = gateway.request( + "GET", + f"{prefix}/daily/activity/export", + params={**params, "export_type": "daily_with_keys"}, + ) + assert exported.status_code == 200, exported.text + export_rows: Final = tuple(csv.reader(io.StringIO(exported.text))) + assert len(export_rows) == len(persisted_rows) + 1, exported.text + + +@pytest.mark.timeout(90) +def test_daily_activity_routes_cover_all_entities_and_bounded_key_search(gateway: Gateway, tmp_path: Path) -> None: + with owned_proxy(gateway, tmp_path, {}) as proxy: + _assert_daily_activity_routes(proxy) + + +def _assert_daily_activity_routes(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) + target_scenario_id: Final = f"usage-cache-{uuid.uuid4().hex}" + target_response: Final = JsonResponse( + content_type="application/json", + body=JSON_OBJECT.validate_python( + { + "id": "$UNIQUE_ID", + "object": "chat.completion", + "created": 1_700_000_000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "cached response"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 40, + "completion_tokens": 20, + "total_tokens": 60, + "prompt_tokens_details": {"cached_tokens": 20}, + }, + } + ), + ) + target_upstream: Final = register_scenario(target_scenario_id, target_response) + scenario.cleanups.callback(delete_scenario, target_upstream) + cache_model: Final = scenario.model( + api_base=target_upstream.api_base(), + api_key=target_scenario_id, + input_cost_per_token=0.0, + output_cost_per_token=0.0, + ) + organization: Final = gateway.post( + "/organization/new", + {"organization_alias": f"integration-{uuid.uuid4().hex}"}, + ) + organization_id: Final = string_value(organization["organization_id"]) + scenario.cleanups.callback(_delete_organization, gateway, organization_id) + team_id: Final = scenario.team(organization_id=organization_id) + user_id: Final = scenario.user() + tag: Final = f"integration-{uuid.uuid4().hex}" + gateway.post("/tag/new", {"name": tag}) + scenario.cleanups.callback(_delete_tag, gateway, tag) + end_user_id: Final = f"integration-{uuid.uuid4().hex}" + gateway.post("/end_user/new", {"user_id": end_user_id}) + scenario.cleanups.callback(_delete_end_user, gateway, end_user_id) + agent_response: Final = gateway.request( + "POST", + "/v1/agents", + { + "agent_name": f"integration-{uuid.uuid4().hex}", + "agent_card_params": { + "protocolVersion": "0.3", + "name": "integration", + "description": "integration agent", + "url": "http://127.0.0.1:1/agent", + "version": "1", + "capabilities": {}, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [], + }, + }, + ) + assert agent_response.status_code == 200, agent_response.text + agent_id: Final = string_value(object_value(agent_response.json())["agent_id"]) + scenario.cleanups.callback(_delete_agent, gateway, agent_id) + keys: Final = tuple( + scenario.key( + models=[model, cache_model], + team_id=team_id, + user_id=user_id, + organization_id=organization_id, + agent_id=agent_id, + ) + for _ in range(5) + ) + target_key: Final = scenario.key( + models=[model, cache_model], + team_id=team_id, + user_id=user_id, + organization_id=organization_id, + agent_id=agent_id, + ) + for request_number, key in enumerate(keys): + _daily_activity_request( + gateway, + model=model, + key=key, + end_user_id=end_user_id, + tag=tag, + request_number=request_number, + ) + for request_number, key in enumerate(keys[:2]): + _daily_activity_request( + gateway, + model=model, + key=key, + end_user_id=end_user_id, + tag=tag, + request_number=100 + request_number, + ) + target_request: Final = gateway.request( + "POST", + "/v1/chat/completions", + JSON_OBJECT.validate_python( + { + "model": cache_model, + "messages": [{"role": "user", "content": "cached activity response"}], + "metadata": {"tags": [tag]}, + "user": end_user_id, + } + ), + key=target_key, + ) + assert target_request.status_code == 200, target_request.text + + today: Final = datetime.now(timezone.utc).date() + start_date: Final = (today - timedelta(days=1)).isoformat() + end_date: Final = (today + timedelta(days=1)).isoformat() + date_params: Final = {"start_date": start_date, "end_date": end_date, "timezone": "0"} + route_cases: Final = ( + ("/user", "user_id", user_id, "LiteLLM_DailyUserSpend", "user_id"), + ("/team", "team_ids", team_id, "LiteLLM_DailyTeamSpend", "team_id"), + ("/tag", "tags", tag, "LiteLLM_DailyTagSpend", "tag"), + ( + "/organization", + "organization_ids", + organization_id, + "LiteLLM_DailyOrganizationSpend", + "organization_id", + ), + ("/customer", "end_user_ids", end_user_id, "LiteLLM_DailyEndUserSpend", "end_user_id"), + ("/agent", "agent_ids", agent_id, "LiteLLM_DailyAgentSpend", "agent_id"), + ) + target_digest: Final = hashlib.sha256(target_key.encode()).hexdigest() + for prefix, entity_param, entity_id, table, entity_column in route_cases: + _assert_entity_activity_routes( + gateway, + prefix=prefix, + entity_param=entity_param, + entity_id=entity_id, + table=table, + entity_column=entity_column, + date_params=date_params, + target_digest=target_digest, + model=model, + ) + + user_cache_keys: Final = gateway.request( + "GET", + "/user/daily/activity/aggregated/cache_leakage_keys", + params={**date_params, "user_id": user_id}, + ) + assert user_cache_keys.status_code == 200, user_cache_keys.text + cache_rows: Final = object_value(user_cache_keys.json())["api_keys"] + assert isinstance(cache_rows, list) and cache_rows, user_cache_keys.text + cache_api_keys: Final = tuple(string_value(object_value(row)["api_key"]) for row in cache_rows) + assert target_digest in cache_api_keys, user_cache_keys.text + + +async def _assert_route_matches_golden(client: httpx.AsyncClient, route: str, golden_name: str) -> None: + response: Final = await client.get( + route, + params={"start_date": "2026-06-01", "end_date": "2026-06-01"}, + ) + assert response.status_code == 200, response.text + golden: Final = (Path(__file__).parent / "golden" / golden_name).read_text() + expected: Final = JSON_OBJECT.validate_json(golden) + actual: Final = object_value(response.json()) + assert actual == expected, route + + +@pytest.mark.asyncio +async def test_existing_activity_routes_match_base_branch_goldens(monkeypatch: pytest.MonkeyPatch) -> None: + async with _daily_activity_database() as database: + repository: Final = _repository(database) + app: Final = FastAPI() + app.include_router(internal_user_router) + app.include_router(team_router) + app.include_router(daily_activity_router) + monkeypatch.setattr(proxy_server, "prisma_client", _PrismaDatabase(database)) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="integration-admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + app.dependency_overrides[get_daily_activity_prisma_client] = lambda: _PrismaDatabase(database) + app.dependency_overrides[get_daily_activity_repository] = lambda: repository + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + route_goldens: Final = ( + ("/user/daily/activity", "daily_activity_user_paginated.json"), + ("/user/daily/activity/aggregated", "daily_activity_user_aggregated.json"), + ("/team/daily/activity", "daily_activity_team_paginated.json"), + ("/team/daily/activity/aggregated", "daily_activity_team_aggregated.json"), + ) + for route, golden_name in route_goldens: + await _assert_route_matches_golden(client, route, golden_name) + + +@pytest.mark.asyncio +async def test_user_key_pages_and_details_respect_caller_scope() -> None: + async with _daily_activity_database() as database: + await database.query_raw( + 'INSERT INTO "LiteLLM_UserTable" (user_id, user_email, models) VALUES ($1, $2, $3)', + "user-2", + "other@example.test", + [], + ) + await database.query_raw( + """ + INSERT INTO "LiteLLM_VerificationToken" + (token, key_alias, team_id, user_id, metadata, models) + VALUES ($1, $2, $3, $4, $5::jsonb, $6) + """, + "key-other-user", + "Other user key", + None, + "user-2", + "{}", + [], + ) + await database.query_raw( + """ + INSERT INTO "LiteLLM_DailyUserSpend" + (id, user_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::timestamp) + """, + "other-user-row", + "user-2", + "2026-06-01", + "key-other-user", + "model", + "", + "provider-a", + None, + "/v1/chat/completions", + 1, + 1, + 0, + 0, + 50.0, + 1, + 1, + 0, + "2026-06-01 12:00:00", + ) + repository: Final = _repository(database) + app: Final = FastAPI() + app.include_router(daily_activity_router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + app.dependency_overrides[get_daily_activity_prisma_client] = lambda: _PrismaDatabase(database) + app.dependency_overrides[get_daily_activity_repository] = lambda: repository + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + params: Final = {"start_date": "2026-06-01", "end_date": "2026-06-01"} + page: Final = await client.get( + "/user/daily/activity/aggregated/keys", + params={**params, "user_id": "user-1", "limit": 100}, + ) + assert page.status_code == 200, page.text + page_body: Final = DailyActivityKeyPageResponse.model_validate_json(page.content) + page_keys: Final = frozenset(row.api_key for row in page_body.api_keys) + assert page_body.total_api_keys == len(page_keys) == 5 + assert "key-other-user" not in page_keys + + denied: Final = await client.get( + "/user/daily/activity/aggregated/keys", + params={**params, "user_id": "user-2"}, + ) + assert denied.status_code == 403, denied.text + + own_details: Final = await client.get( + "/user/daily/activity/aggregated", + params={**params, "user_id": "user-1", "api_key": "key-a"}, + ) + assert own_details.status_code == 200, own_details.text + own_body: Final = JSON_OBJECT.validate_json(own_details.content) + assert object_value(own_body["metadata"])["total_api_keys"] == 1 + assert _aggregate_top_keys(own_body["results"]) == frozenset(("key-a",)) + + other_details: Final = await client.get( + "/user/daily/activity/aggregated", + params={**params, "user_id": "user-1", "api_key": "key-other-user"}, + ) + assert other_details.status_code == 200, other_details.text + other_body: Final = JSON_OBJECT.validate_json(other_details.content) + assert object_value(other_body["metadata"])["total_api_keys"] == 0 + assert _aggregate_top_keys(other_body["results"]) == frozenset() diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index d8ee58a52ea..6503e5bf50e 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -16,6 +16,93 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin from litellm.proxy.auth.route_checks import RouteChecks +DAILY_ACTIVITY_ROUTE_PAIRS: Final[tuple[tuple[str, str], ...]] = ( + ("/user/daily/activity", "/user/daily/activity/aggregated"), + ("/user/daily/activity", "/user/daily/activity/aggregated/keys"), + ("/user/daily/activity", "/user/daily/activity/aggregated/search"), + ("/user/daily/activity", "/user/daily/activity/aggregated/model_top_keys"), + ("/user/daily/activity", "/user/daily/activity/export"), + ("/user/daily/activity", "/user/daily/activity/aggregated/cache_leakage_keys"), + ("/team/daily/activity", "/team/daily/activity/aggregated"), + ("/team/daily/activity", "/team/daily/activity/aggregated/keys"), + ("/team/daily/activity", "/team/daily/activity/aggregated/search"), + ("/team/daily/activity", "/team/daily/activity/aggregated/model_top_keys"), + ("/team/daily/activity", "/team/daily/activity/export"), + ("/tag/daily/activity", "/tag/daily/activity/aggregated"), + ("/tag/daily/activity", "/tag/daily/activity/aggregated/keys"), + ("/tag/daily/activity", "/tag/daily/activity/aggregated/search"), + ("/tag/daily/activity", "/tag/daily/activity/aggregated/model_top_keys"), + ("/tag/daily/activity", "/tag/daily/activity/export"), + ("/organization/daily/activity", "/organization/daily/activity/aggregated"), + ("/organization/daily/activity", "/organization/daily/activity/aggregated/keys"), + ("/organization/daily/activity", "/organization/daily/activity/aggregated/search"), + ("/organization/daily/activity", "/organization/daily/activity/aggregated/model_top_keys"), + ("/organization/daily/activity", "/organization/daily/activity/export"), + ("/customer/daily/activity", "/customer/daily/activity/aggregated"), + ("/customer/daily/activity", "/customer/daily/activity/aggregated/keys"), + ("/customer/daily/activity", "/customer/daily/activity/aggregated/search"), + ("/customer/daily/activity", "/customer/daily/activity/aggregated/model_top_keys"), + ("/customer/daily/activity", "/customer/daily/activity/export"), + ("/customer/daily/activity", "/end_user/daily/activity/aggregated"), + ("/customer/daily/activity", "/end_user/daily/activity/aggregated/keys"), + ("/customer/daily/activity", "/end_user/daily/activity/aggregated/search"), + ("/customer/daily/activity", "/end_user/daily/activity/aggregated/model_top_keys"), + ("/customer/daily/activity", "/end_user/daily/activity/export"), + ("/agent/daily/activity", "/agent/daily/activity/aggregated"), + ("/agent/daily/activity", "/agent/daily/activity/aggregated/keys"), + ("/agent/daily/activity", "/agent/daily/activity/aggregated/search"), + ("/agent/daily/activity", "/agent/daily/activity/aggregated/model_top_keys"), + ("/agent/daily/activity", "/agent/daily/activity/export"), +) + +DAILY_ACTIVITY_ROLES: Final[tuple[LitellmUserRoles, ...]] = ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.ORG_ADMIN, + LitellmUserRoles.TEAM, + LitellmUserRoles.CUSTOMER, +) + + +def _daily_activity_route_outcome(route: str, user_role: LitellmUserRoles) -> str: + if user_role == LitellmUserRoles.PROXY_ADMIN: + return "allowed" + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=user_role.value, + ) + valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role) + request = MagicMock(spec=Request) + request.method = "GET" + request.query_params = {} + try: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=user_role.value, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + except HTTPException as exc: + return f"denied:{exc.status_code}" + except Exception as exc: + return f"denied:{type(exc).__name__}" + return "allowed" + + +@pytest.mark.parametrize(("existing_path", "new_path"), DAILY_ACTIVITY_ROUTE_PAIRS) +@pytest.mark.parametrize("user_role", DAILY_ACTIVITY_ROLES) +def test_daily_activity_routes_preserve_route_access_outcomes( + existing_path: str, new_path: str, user_role: LitellmUserRoles +) -> None: + assert _daily_activity_route_outcome(new_path, user_role) == _daily_activity_route_outcome( + existing_path, user_role + ) + def test_non_admin_config_update_route_rejected(): """Test that non-admin users are rejected when trying to call /config/update""" @@ -2219,7 +2306,7 @@ def test_internal_user_can_access_logs_drawer_detail_route(user_role): request_data={}, ) except Exception as e: - pytest.fail(f"{user_role.value} should be able to access {route}. Got error: {str(e)}") + pytest.fail(f"{user_role.value} should be able to access {route}. Got error: {e!s}") @pytest.mark.parametrize( diff --git a/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py b/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py index 8c80429aa92..bd1436dcd10 100644 --- a/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py +++ b/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py @@ -387,3 +387,59 @@ async def test_agent_activity_non_admin_no_access_returns_empty_page(): assert result.results == [] fake_get_daily.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("owned_tokens", "requested_api_key"), + [ + ([], None), + (["alice-key-1"], "bob-key-1"), + ], +) +async def test_team_activity_member_without_matching_keys_queries_nothing( + owned_tokens: list[str], requested_api_key: str | None +) -> None: + """A member without full team view whose key list is empty, or who asks for + a key they do not own, must reach the repository with an empty key filter, + never with no filter at all.""" + from litellm.proxy.management_endpoints import common_daily_activity, team_endpoints + from litellm.repositories.daily_activity_sql import build_where_clause + from litellm.types.repositories.daily_activity import DailyRowsPage + + user = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value) + prisma = MagicMock() + prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[_make_team("team-B", admin_user_ids=["bob"])]) + prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[MagicMock(token=token) for token in owned_tokens] + ) + user_info = MagicMock() + user_info.teams = ["team-B"] + repository = MagicMock() + repository.daily_rows = AsyncMock(return_value=DailyRowsPage(total_count=0, rows=())) + + with ( + patch.object(team_endpoints, "prisma_client", prisma, create=True), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new=AsyncMock(return_value=user_info), + ), + patch.object(common_daily_activity, "daily_activity_repository", return_value=repository), + patch("litellm.proxy.proxy_server.prisma_client", prisma), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), + ): + response = await team_endpoints.get_team_daily_activity( + team_ids="team-B", + start_date="2026-01-01", + end_date="2026-01-02", + api_key=requested_api_key, + user_api_key_dict=user, + ) + + scope = repository.daily_rows.await_args.args[0] + assert scope.api_keys == () + sql, _params = build_where_clause(scope) + assert sql.endswith(" AND FALSE") + assert response.results == [] + assert response.metadata.total_spend == 0 diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index 75e4a865137..45820adb4ce 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -10,6 +10,7 @@ from fastapi import HTTPException import litellm.proxy.management_endpoints.common_daily_activity as common_daily_activity_module from litellm.constants import USAGE_TOP_API_KEYS_DEFAULT from litellm.proxy.management_endpoints.common_daily_activity import ( + InvalidDateRange, _is_user_agent_tag, _ProxyDailyActivityReads, _record_to_spend_metrics, @@ -18,6 +19,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( daily_activity_scope, get_api_key_metadata, get_daily_activity, + raise_public, update_metrics, ) from litellm.proxy.management_endpoints.common_daily_activity import ( @@ -2332,17 +2334,17 @@ async def test_get_api_key_metadata_resolves_session_key_via_spend_log_window(): def test_spend_logs_window_pads_min_minus_one_day_and_max_plus_two_days(): - from litellm.proxy.management_endpoints.common_daily_activity import _spend_logs_window + from litellm.proxy.management_endpoints.common_daily_activity import spend_logs_window - window = _spend_logs_window({"2026-09-08", "2026-09-05", "not-a-date"}) + window = spend_logs_window({"2026-09-08", "2026-09-05", "not-a-date"}) assert window == (datetime(2026, 9, 4), datetime(2026, 9, 10)) def test_spend_logs_window_is_none_when_no_date_parses(): - from litellm.proxy.management_endpoints.common_daily_activity import _spend_logs_window + from litellm.proxy.management_endpoints.common_daily_activity import spend_logs_window - assert _spend_logs_window({"garbage", ""}) is None + assert spend_logs_window({"garbage", ""}) is None @pytest.mark.asyncio @@ -2447,3 +2449,10 @@ async def test_get_api_key_metadata_does_not_recover_daily_spend_owner_for_activ assert active_metadata.get("user_email") == "active-owner@example.com" assert active_metadata.get("key_exists") is True recovery_query_raw.assert_not_awaited() + + +def test_raise_public_maps_invalid_date_range_to_400() -> None: + with pytest.raises(HTTPException) as excinfo: + raise_public(InvalidDateRange(reason="Date range must be at most 400 days")) + assert excinfo.value.status_code == 400 + assert excinfo.value.detail == {"error": "Date range must be at most 400 days"} diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py new file mode 100644 index 00000000000..076538e5cd0 --- /dev/null +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -0,0 +1,1261 @@ +import csv +import io +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from dataclasses import dataclass, fields +from itertools import chain +from types import SimpleNamespace +from typing import Final +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from litellm import constants +from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable, LitellmUserRoles, Member, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.daily_activity_routes import ( + _csv_cell, + get_daily_activity_prisma_client, + get_daily_activity_repository, + router, +) +from litellm.types.proxy.management_endpoints.common_daily_activity import KeySpendMetrics, SpendMetrics +from litellm.types.repositories.daily_activity import ( + AggregatedRows, + DailyActivityScope, + DailyActivityTable, + EntityRollupRow, + ExportRow, + ExportType, + GroupingSetsRow, + KeyMetadataRow, + KeyPage, + KeySpendRow, +) + + +@dataclass(frozen=True, slots=True) +class _Activity: + table: DailyActivityTable + entity_id: str + date: str + api_key: str + model: str + model_group: str + spend: float + flat_cost: float + prompt_tokens: int + completion_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + compression_saved_tokens: int + compression_savings_spend: float + prompt_caching_savings_spend: float + gateway_injected_caching_savings_spend: float + autorouter_savings_spend: float + api_requests: int + successful_requests: int + failed_requests: int + total_response_time_ms: int + timed_requests: int + + +_ENTITY_CASES: Final[tuple[tuple[str, str, str], ...]] = ( + ("/user", "user_id", "user-a"), + ("/team", "team_ids", "team-a"), + ("/tag", "tags", "blue"), + ("/organization", "organization_ids", "org-a"), + ("/customer", "end_user_ids", "customer-a"), + ("/agent", "agent_ids", "agent-a"), +) +_DATE_PARAMS: Final = {"start_date": "2025-01-01", "end_date": "2025-01-02"} + + +def _activity_for_entity( + table: DailyActivityTable, + entity_id: str, + key_rows: tuple[tuple[str, str, str, float, int], ...], +) -> tuple[_Activity, ...]: + return tuple( + _Activity( + table=table, + entity_id=entity_id, + date=date, + api_key=api_key, + model=model, + model_group="rare-group" if model == "rare-model" else "popular-group", + spend=spend, + flat_cost=0.05, + prompt_tokens=10, + completion_tokens=5, + cache_read_input_tokens=cache_read, + cache_creation_input_tokens=1, + compression_saved_tokens=2, + compression_savings_spend=0.1, + prompt_caching_savings_spend=0.2, + gateway_injected_caching_savings_spend=0.3, + autorouter_savings_spend=0.4, + api_requests=1, + successful_requests=1, + failed_requests=0, + total_response_time_ms=100, + timed_requests=1, + ) + for api_key, date, model, spend, cache_read in key_rows + ) + + +def _seeded_activity() -> tuple[_Activity, ...]: + entity_ids: Final = { + DailyActivityTable.USER: "user-a", + DailyActivityTable.TEAM: "team-a", + DailyActivityTable.TAG: "blue", + DailyActivityTable.ORGANIZATION: "org-a", + DailyActivityTable.CUSTOMER: "customer-a", + DailyActivityTable.AGENT: "agent-a", + } + other_entity_ids: Final = { + DailyActivityTable.USER: "user-b", + DailyActivityTable.TEAM: "team-b", + DailyActivityTable.TAG: "other-blue", + DailyActivityTable.ORGANIZATION: "other-org", + DailyActivityTable.CUSTOMER: "customer-b", + DailyActivityTable.AGENT: "agent-b", + } + key_rows: Final = ( + ("key-alpha", "2025-01-01", "popular", 1.0, 0), + ("key-alpha", "2025-01-02", "popular", 2.0, 0), + ("key-beta", "2025-01-01", "popular", 4.0, 0), + ("key-gamma", "2025-01-01", "popular", 5.0, 0), + ("key-cache", "2025-01-01", "popular", 2.0, 20), + ("key-target", "2025-01-01", "rare-model", 0.5, 0), + ) + return tuple( + chain.from_iterable(_activity_for_entity(table, entity_id, key_rows) for table, entity_id in entity_ids.items()) + ) + tuple( + _Activity( + table=table, + entity_id=other_entity_ids[table], + date="2025-01-01", + api_key=f"key-other-{table.value}", + model="popular", + model_group="popular-group", + spend=100.0, + flat_cost=0.0, + prompt_tokens=10, + completion_tokens=5, + cache_read_input_tokens=5 if table is DailyActivityTable.USER else 0, + cache_creation_input_tokens=0, + compression_saved_tokens=0, + compression_savings_spend=0.0, + prompt_caching_savings_spend=0.0, + gateway_injected_caching_savings_spend=0.0, + autorouter_savings_spend=0.0, + api_requests=1, + successful_requests=1, + failed_requests=0, + total_response_time_ms=100, + timed_requests=1, + ) + for table in entity_ids + ) + + +def _metrics(rows: Sequence[_Activity]) -> Mapping[str, int | float]: + return { + "spend": sum(row.spend for row in rows), + "ptu_flat_cost": sum(row.flat_cost for row in rows), + "prompt_tokens": sum(row.prompt_tokens for row in rows), + "completion_tokens": sum(row.completion_tokens for row in rows), + "cache_read_input_tokens": sum(row.cache_read_input_tokens for row in rows), + "cache_creation_input_tokens": sum(row.cache_creation_input_tokens for row in rows), + "compression_saved_tokens": sum(row.compression_saved_tokens for row in rows), + "compression_savings_spend": sum(row.compression_savings_spend for row in rows), + "prompt_caching_savings_spend": sum(row.prompt_caching_savings_spend for row in rows), + "gateway_injected_caching_savings_spend": sum(row.gateway_injected_caching_savings_spend for row in rows), + "autorouter_savings_spend": sum(row.autorouter_savings_spend for row in rows), + "api_requests": sum(row.api_requests for row in rows), + "successful_requests": sum(row.successful_requests for row in rows), + "failed_requests": sum(row.failed_requests for row in rows), + "total_response_time_ms": sum(row.total_response_time_ms for row in rows), + "timed_requests": sum(row.timed_requests for row in rows), + } + + +def _grouping_row( + rows: Sequence[_Activity], + *, + date: str | None, + api_key: str | None, + group_level: int, + distinct_api_keys: int | None, +) -> GroupingSetsRow: + metric_values: Final = _metrics(rows) + return GroupingSetsRow( + date=date, + api_key=api_key, + **metric_values, + model=None, + model_group=None, + custom_llm_provider=None, + mcp_namespaced_tool_name=None, + endpoint=None, + group_level=group_level, + distinct_api_keys=distinct_api_keys, + ) + + +def _grouping_rows_for_day( + date: str, rows: Sequence[_Activity], top_keys: tuple[str, ...], distinct_api_keys: int +) -> tuple[GroupingSetsRow, ...]: + date_rows: Final = tuple(row for row in rows if row.date == date) + return ( + _grouping_row(date_rows, date=date, api_key=None, group_level=63, distinct_api_keys=distinct_api_keys), + ) + tuple( + _grouping_row( + tuple(row for row in date_rows if row.api_key == api_key), + date=date, + api_key=api_key, + group_level=31, + distinct_api_keys=None, + ) + for api_key in top_keys + if any(row.api_key == api_key for row in date_rows) + ) + + +class _FakeRepository: + def __init__(self, rows: tuple[_Activity, ...]) -> None: + self._rows: Final = rows + self.aggregated = AsyncMock(side_effect=self._aggregated) + self.key_page = AsyncMock(side_effect=self._key_page) + self.key_page_call: tuple[DailyActivityScope, int, int] | None = None + self.search_keys = AsyncMock(side_effect=self._search_keys) + self.model_top_keys = AsyncMock(side_effect=self._model_top_keys) + self.cache_leakage_keys = AsyncMock(side_effect=self._cache_leakage_keys) + self.key_metadata = AsyncMock(side_effect=self._key_metadata) + self.export_rows_error: Exception | None = None + + def _matching_rows(self, scope: DailyActivityScope) -> tuple[_Activity, ...]: + return tuple( + row + for row in self._rows + if row.table == scope.table + and scope.start_date <= row.date <= scope.end_date + and (scope.entity_ids is None or row.entity_id in scope.entity_ids) + and row.entity_id not in scope.exclude_entity_ids + and (scope.api_keys is None or row.api_key in scope.api_keys) + and (scope.model is None or row.model == scope.model) + ) + + async def _aggregated( + self, + scope: DailyActivityScope, + *, + include_entity_breakdown: bool = False, + api_key_limit: int = constants.USAGE_TOP_API_KEYS_DEFAULT, + ) -> AggregatedRows: + rows: Final = self._matching_rows(scope) + key_spend: Final = tuple( + sorted( + ((key, sum(row.spend for row in rows if row.api_key == key)) for key in {row.api_key for row in rows}), + key=lambda item: (-item[1], item[0]), + ) + ) + distinct_keys: Final = len(key_spend) + top_keys: Final = tuple(key for key, _ in key_spend[:api_key_limit]) + dates: Final = tuple(sorted({row.date for row in rows})) + grouping_rows: Final = ( + _grouping_row(rows, date=None, api_key=None, group_level=127, distinct_api_keys=distinct_keys), + ) + tuple( + grouping_row + for date in dates + for grouping_row in _grouping_rows_for_day(date, rows, top_keys, distinct_keys) + ) + entity_rows: Final = ( + tuple(entity_row for date in dates for entity_row in _entity_rows_for_day(date, rows)) + if include_entity_breakdown + else () + ) + return AggregatedRows( + grouping_rows=grouping_rows, + entity_rows=entity_rows if include_entity_breakdown else None, + distinct_api_keys=distinct_keys, + ) + + async def _search_keys(self, scope: DailyActivityScope, *, search: str, limit: int) -> tuple[str, ...]: + rows: Final = self._matching_rows(scope) + return tuple(key for key in dict.fromkeys(row.api_key for row in rows) if search.casefold() in key.casefold())[ + :limit + ] + + async def _key_page(self, scope: DailyActivityScope, *, offset: int, limit: int) -> KeyPage: + self.key_page_call = (scope, offset, limit) + rows: Final = self._matching_rows(scope) + api_keys: Final = _ranked_keys(rows) + return KeyPage( + rows=tuple(_key_spend_row(api_key, rows) for api_key in api_keys[offset : offset + limit]), + total_api_keys=len(api_keys), + ) + + async def _model_top_keys( + self, scope: DailyActivityScope, *, model_group: str, by_model_group: bool, limit: int + ) -> tuple[KeySpendRow, ...]: + rows: Final = tuple( + row + for row in self._matching_rows(scope) + if (row.model_group if by_model_group else row.model) == model_group + ) + return tuple(_key_spend_row(key, rows) for key in _ranked_keys(rows)[:limit]) + + async def _cache_leakage_keys(self, scope: DailyActivityScope, *, limit: int) -> tuple[KeySpendRow, ...]: + rows: Final = tuple(row for row in self._matching_rows(scope) if row.cache_read_input_tokens > 0) + return tuple(_key_spend_row(key, rows) for key in _ranked_keys(rows)[:limit]) + + async def _key_metadata( + self, api_keys: frozenset[str], window: tuple[object, object] | None + ) -> Mapping[str, KeyMetadataRow]: + return { + key: KeyMetadataRow( + api_key=key, + key_alias=f"alias-{key}", + team_id="team-a", + user_id="user-a", + user_email="user@example.test", + key_exists=True, + tags=(), + ) + for key in api_keys + } + + async def export_rows(self, scope: DailyActivityScope, *, export_type: ExportType) -> AsyncIterator[ExportRow]: + if self.export_rows_error is not None: + raise self.export_rows_error + for row in self._matching_rows(scope): + yield ExportRow( + date=row.date, + entity_id=row.entity_id, + entity_alias="=entity", + api_key=row.api_key, + key_alias="+key", + user_id="user-a", + user_email="user@example.test", + model=row.model, + spend=row.spend, + flat_cost=row.flat_cost, + prompt_tokens=row.prompt_tokens, + completion_tokens=row.completion_tokens, + api_requests=row.api_requests, + successful_requests=row.successful_requests, + failed_requests=row.failed_requests, + cache_read_input_tokens=row.cache_read_input_tokens, + cache_creation_input_tokens=row.cache_creation_input_tokens, + ) + + +def _ranked_keys(rows: Sequence[_Activity]) -> tuple[str, ...]: + return tuple( + key + for key, _ in sorted( + ((key, sum(row.spend for row in rows if row.api_key == key)) for key in {row.api_key for row in rows}), + key=lambda item: (-item[1], item[0]), + ) + ) + + +def _key_spend_row(api_key: str, rows: Sequence[_Activity]) -> KeySpendRow: + matching: Final = tuple(row for row in rows if row.api_key == api_key) + return KeySpendRow( + api_key=api_key, + spend=sum(row.spend for row in matching), + prompt_tokens=sum(row.prompt_tokens for row in matching), + completion_tokens=sum(row.completion_tokens for row in matching), + total_tokens=sum(row.prompt_tokens + row.completion_tokens for row in matching), + api_requests=sum(row.api_requests for row in matching), + successful_requests=sum(row.successful_requests for row in matching), + failed_requests=sum(row.failed_requests for row in matching), + cache_read_input_tokens=sum(row.cache_read_input_tokens for row in matching), + cache_creation_input_tokens=sum(row.cache_creation_input_tokens for row in matching), + ) + + +def _entity_rows_for_day( + date: str, rows: Sequence[_Activity], distinct_api_keys: int | None = None +) -> tuple[EntityRollupRow, ...]: + date_rows: Final = tuple(row for row in rows if row.date == date) + entities: Final = tuple(dict.fromkeys(row.entity_id for row in date_rows)) + return tuple( + entity_row + for entity_id in entities + for entity_row in _entity_rows_for_entity(date, entity_id, date_rows, distinct_api_keys) + ) + + +def _entity_rows_for_entity( + date: str, entity_id: str, rows: Sequence[_Activity], distinct_api_keys: int | None +) -> tuple[EntityRollupRow, ...]: + entity_rows: Final = tuple(row for row in rows if row.entity_id == entity_id) + return ( + _entity_rollup( + entity_rows, + date=date, + entity_id=entity_id, + api_key=None, + api_key_rolled=1, + distinct_api_keys=distinct_api_keys, + ), + ) + tuple( + _entity_rollup( + tuple(row for row in entity_rows if row.api_key == api_key), + date=date, + entity_id=entity_id, + api_key=api_key, + api_key_rolled=0, + distinct_api_keys=None, + ) + for api_key in dict.fromkeys(row.api_key for row in entity_rows) + ) + + +def _entity_rollup( + rows: Sequence[_Activity], + *, + date: str, + entity_id: str, + api_key: str | None, + api_key_rolled: int, + distinct_api_keys: int | None, +) -> EntityRollupRow: + return EntityRollupRow( + date=date, + api_key=api_key, + **_metrics(rows), + entity_id=entity_id, + api_key_rolled=api_key_rolled, + distinct_api_keys=distinct_api_keys, + ) + + +class _PrismaTable: + def __init__(self, rows: tuple[object, ...] = ()) -> None: + self._rows: Final = rows + + async def find_many(self, *, where: Mapping[str, object] | None = None, **kwargs: object) -> tuple[object, ...]: + if where is None: + return self._rows + return tuple(row for row in self._rows if _matches(row, where)) + + async def find_unique(self, *, where: Mapping[str, object], **kwargs: object) -> object | None: + return next((row for row in self._rows if _matches(row, where)), None) + + +def _matches(row: object, where: Mapping[str, object]) -> bool: + return all( + getattr(row, field_name, None) in value["in"] + if isinstance(value, Mapping) and "in" in value + else getattr(row, field_name, None) == value + for field_name, value in where.items() + ) + + +def _prisma_client() -> object: + user: Final = LiteLLM_UserTable( + user_id="user-a", + user_email="user@example.test", + user_role=LitellmUserRoles.INTERNAL_USER.value, + teams=["team-a"], + ) + team: Final = LiteLLM_TeamTable( + team_id="team-a", + team_alias="Team A", + members_with_roles=[Member(user_id="user-a", role="user")], + ) + other_user: Final = LiteLLM_UserTable( + user_id="user-b", + user_email="other-user@example.test", + user_role=LitellmUserRoles.INTERNAL_USER.value, + teams=["team-b"], + ) + other_team: Final = LiteLLM_TeamTable( + team_id="team-b", + team_alias="Team B", + members_with_roles=[Member(user_id="user-b", role="user")], + ) + db: Final = SimpleNamespace( + litellm_usertable=_PrismaTable((user, other_user)), + litellm_teamtable=_PrismaTable((team, other_team)), + litellm_verificationtoken=_PrismaTable((SimpleNamespace(token="key-alpha", user_id="user-a"),)), + litellm_organizationmembership=_PrismaTable( + ( + SimpleNamespace(user_id="user-a", organization_id="org-a", user_role="org_admin"), + SimpleNamespace(user_id="user-b", organization_id="other-org", user_role="org_admin"), + ) + ), + litellm_organizationtable=_PrismaTable( + ( + SimpleNamespace(organization_id="org-a", organization_alias="Org A"), + SimpleNamespace(organization_id="other-org", organization_alias="Other Org"), + ) + ), + litellm_endusertable=_PrismaTable( + ( + SimpleNamespace(user_id="customer-a", alias="Customer A"), + SimpleNamespace(user_id="customer-b", alias="Customer B"), + ) + ), + litellm_agentstable=_PrismaTable( + ( + SimpleNamespace(agent_id="agent-a", agent_name="Agent A", created_by="user-a"), + SimpleNamespace(agent_id="agent-b", agent_name="Agent B", created_by="user-b"), + ) + ), + ) + return SimpleNamespace(db=db, writer_db=db) + + +@pytest.fixture +def daily_activity_client() -> Iterator[tuple[TestClient, _FakeRepository]]: + repository: Final = _FakeRepository(_seeded_activity()) + prisma_client: Final = _prisma_client() + app: Final = FastAPI() + app.include_router(router) + + def resolve_auth(request: Request) -> UserAPIKeyAuth: + role: Final = LitellmUserRoles(request.headers.get("x-user-role", LitellmUserRoles.PROXY_ADMIN.value)) + user_id: Final[str | None] = request.headers.get("x-user-id") or ( + "admin" if role != LitellmUserRoles.INTERNAL_USER else None + ) + return UserAPIKeyAuth( + user_id=user_id, + user_role=role, + api_key=request.headers.get("x-api-key"), + ) + + app.dependency_overrides[get_daily_activity_repository] = lambda: repository + app.dependency_overrides[get_daily_activity_prisma_client] = lambda: prisma_client + app.dependency_overrides[user_api_key_auth] = resolve_auth + with TestClient(app) as client: + yield client, repository + + +def _entity_params(query_name: str, entity_id: str) -> dict[str, str]: + return {**_DATE_PARAMS, query_name: entity_id} + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_aggregated_routes_return_scoped_results( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated", + params=_entity_params(query_name, entity_id), + ) + assert response.status_code == 200, response.text + body: Final = response.json() + assert body["metadata"]["total_spend"] == pytest.approx(14.5), response.text + assert body["metadata"]["total_api_keys"] == 5, response.text + assert len(body["results"]) == 2, response.text + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_admin_aggregates_all_entities_when_filter_is_omitted( + daily_activity_client: tuple[TestClient, _FakeRepository], prefix: str, query_name: str, entity_id: str +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated", + params=_DATE_PARAMS, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(114.5), response.text + assert response.json()["metadata"]["total_api_keys"] == 6, response.text + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_search_folds_each_entity_key_across_days( + daily_activity_client: tuple[TestClient, _FakeRepository], prefix: str, query_name: str, entity_id: str +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated/search", + params={**_entity_params(query_name, entity_id), "search": "alpha"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "api_keys": [ + { + "api_key": "key-alpha", + "metrics": { + "spend": 3.0, + "flat_cost": 0.0, + "prompt_tokens": 20, + "completion_tokens": 10, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 2, + "compression_saved_tokens": 4, + "compression_savings_spend": 0.2, + "prompt_caching_savings_spend": 0.4, + "gateway_injected_caching_savings_spend": 0.6, + "autorouter_savings_spend": 0.8, + "total_tokens": 30, + "successful_requests": 2, + "failed_requests": 0, + "api_requests": 2, + "total_response_time_ms": 200, + "timed_requests": 2, + }, + "metadata": { + "key_alias": "alias-key-alpha", + "team_id": "team-a", + "user_id": "user-a", + "user_email": "user@example.test", + "key_exists": True, + }, + } + ] + } + + +def test_search_finds_keys_outside_the_top_keys_limit_and_skips_empty_aggregate( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + aggregate_response: Final = client.get( + "/user/daily/activity/aggregated", + params={**_entity_params("user_id", "user-a"), "api_key_limit": 3}, + ) + assert aggregate_response.status_code == 200, aggregate_response.text + assert repository.aggregated.call_args.kwargs["api_key_limit"] == 3 + top_keys: Final = frozenset( + chain.from_iterable(result["breakdown"]["api_keys"] for result in aggregate_response.json()["results"]) + ) + assert "key-target" not in top_keys + + search_response: Final = client.get( + "/user/daily/activity/aggregated/search", + params={**_entity_params("user_id", "user-a"), "search": "target", "limit": 7}, + ) + assert search_response.status_code == 200, search_response.text + assert repository.search_keys.call_args.kwargs["limit"] == 7 + assert search_response.json()["api_keys"][0]["api_key"] == "key-target" + assert search_response.json()["api_keys"][0]["metrics"]["spend"] == pytest.approx(0.5) + search_metrics: Final = search_response.json()["api_keys"][0]["metrics"] + assert set(search_metrics) == set(SpendMetrics.model_fields) + assert search_metrics["compression_savings_spend"] == pytest.approx(0.1) + assert search_metrics["total_response_time_ms"] == 100 + + repository.aggregated.reset_mock() + empty_response: Final = client.get( + "/user/daily/activity/aggregated/search", + params={**_entity_params("user_id", "user-a"), "search": "absent"}, + ) + assert empty_response.status_code == 200, empty_response.text + assert empty_response.json() == {"api_keys": []} + repository.aggregated.assert_not_awaited() + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_key_page_routes_map_ranked_rows_and_totals( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated/keys", + params={**_entity_params(query_name, entity_id), "offset": 1, "limit": 2}, + ) + + assert response.status_code == 200, response.text + assert response.json() == { + "api_keys": [ + { + "api_key": "key-beta", + "metrics": { + "spend": 4.0, + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 1, + }, + "metadata": { + "key_alias": "alias-key-beta", + "team_id": "team-a", + "user_id": "user-a", + "user_email": "user@example.test", + "key_exists": True, + }, + }, + { + "api_key": "key-alpha", + "metrics": { + "spend": 3.0, + "prompt_tokens": 20, + "completion_tokens": 10, + "total_tokens": 30, + "api_requests": 2, + "successful_requests": 2, + "failed_requests": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 2, + }, + "metadata": { + "key_alias": "alias-key-alpha", + "team_id": "team-a", + "user_id": "user-a", + "user_email": "user@example.test", + "key_exists": True, + }, + }, + ], + "total_api_keys": 5, + "offset": 1, + "limit": 2, + } + key_page_call: Final = repository.key_page_call + assert key_page_call is not None + scope: Final = key_page_call[0] + assert scope.entity_ids == (entity_id,) + assert key_page_call[1:] == (1, 2) + + +@pytest.mark.parametrize("params", ({"limit": 101}, {"offset": -1})) +def test_key_page_route_rejects_invalid_bounds( + daily_activity_client: tuple[TestClient, _FakeRepository], + params: Mapping[str, int], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/keys", + params={**_entity_params("user_id", "user-a"), **params}, + ) + + assert response.status_code == 422, response.text + repository.key_page.assert_not_awaited() + + +@pytest.mark.parametrize( + ("path", "route_params", "limit_name", "invalid_limit"), + ( + ("/user/daily/activity/aggregated", {}, "api_key_limit", 0), + ( + "/user/daily/activity/aggregated", + {}, + "api_key_limit", + constants.USAGE_TOP_API_KEYS_MAX + 1, + ), + ("/user/daily/activity/aggregated/search", {"search": "key"}, "limit", 0), + ( + "/user/daily/activity/aggregated/search", + {"search": "key"}, + "limit", + constants.USAGE_KEY_SEARCH_MAX + 1, + ), + ( + "/user/daily/activity/aggregated/model_top_keys", + {"model_group": "popular-group"}, + "limit", + 0, + ), + ( + "/user/daily/activity/aggregated/model_top_keys", + {"model_group": "popular-group"}, + "limit", + constants.USAGE_MODEL_TOP_KEYS_MAX + 1, + ), + ( + "/user/daily/activity/aggregated/cache_leakage_keys", + {}, + "limit", + 0, + ), + ( + "/user/daily/activity/aggregated/cache_leakage_keys", + {}, + "limit", + constants.USAGE_CACHE_LEAKAGE_KEYS_MAX + 1, + ), + ), +) +def test_usage_limit_routes_reject_values_outside_bounds( + daily_activity_client: tuple[TestClient, _FakeRepository], + path: str, + route_params: Mapping[str, str], + limit_name: str, + invalid_limit: int, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + path, + params={ + **_entity_params("user_id", "user-a"), + **route_params, + limit_name: invalid_limit, + }, + ) + + assert response.status_code == 422, response.text + repository.aggregated.assert_not_awaited() + repository.search_keys.assert_not_awaited() + repository.model_top_keys.assert_not_awaited() + repository.cache_leakage_keys.assert_not_awaited() + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_model_top_routes_rank_keys_and_include_metadata( + daily_activity_client: tuple[TestClient, _FakeRepository], prefix: str, query_name: str, entity_id: str +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated/model_top_keys", + params={ + **_entity_params(query_name, entity_id), + "model_group": "rare-group", + "limit": 3, + }, + ) + assert response.status_code == 200, response.text + assert repository.model_top_keys.call_args.kwargs["limit"] == 3 + assert response.json()["model"] == "rare-group" + assert response.json()["by_model_group"] is True + assert tuple(row["api_key"] for row in response.json()["api_keys"]) == ("key-target",) + metrics: Final = response.json()["api_keys"][0]["metrics"] + expected_row: Final = KeySpendRow( + api_key="key-target", + spend=0.5, + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + api_requests=1, + successful_requests=1, + failed_requests=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=1, + ) + assert set(metrics) == set(KeySpendMetrics.model_fields) + assert metrics == {field: getattr(expected_row, field) for field in KeySpendMetrics.model_fields} + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_export_routes_stream_csv_and_preserve_row_counts( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/export", + params={**_entity_params(query_name, entity_id), "export_type": ExportType.DAILY.value}, + ) + assert response.status_code == 200, response.text + assert response.headers["cache-control"] == "no-store" + assert "attachment;" in response.headers["content-disposition"] + records: Final = tuple(csv.reader(io.StringIO(response.text))) + assert tuple(records[0]) == tuple(field.name for field in fields(ExportRow)) + assert len(records) == 7 + assert records[1][2] == "'=entity" + assert records[1][4] == "'+key" + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_export_routes_stream_json_arrays( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/export", + params={**_entity_params(query_name, entity_id), "format": "json"}, + ) + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + records: Final = response.json() + assert isinstance(records, list) and len(records) == 6, response.text + assert records[0]["entity_alias"] == "=entity" + + +def test_export_first_row_error_returns_json_error_before_streaming( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + repository.export_rows_error = RuntimeError("database query failed") + response: Final = client.get( + "/team/daily/activity/export", + params={**_entity_params("team_ids", "team-a"), "format": "csv"}, + ) + assert response.status_code >= 400 + assert response.headers["content-type"].startswith("application/json") + assert response.text != ",".join(field.name for field in fields(ExportRow)) + "\r\n" + assert "database query failed" in response.text + + +def test_csv_export_with_no_rows_contains_only_header( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/team/daily/activity/export", + params={**_entity_params("team_ids", "team-a"), "api_key": "missing-key"}, + ) + assert response.status_code == 200, response.text + assert response.text == ",".join(field.name for field in fields(ExportRow)) + "\r\n" + + +def test_json_export_with_no_rows_is_an_empty_array( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/team/daily/activity/export", + params={**_entity_params("team_ids", "team-a"), "api_key": "missing-key", "format": "json"}, + ) + assert response.status_code == 200, response.text + assert response.json() == [] + + +def test_user_routes_preserve_scope_denials_and_service_account_guard( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + denied: Final = client.get( + "/user/daily/activity/aggregated", + params=_entity_params("user_id", "user-b"), + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert denied.status_code == 403, denied.text + repository.aggregated.assert_not_awaited() + + service_account: Final = client.get( + "/user/daily/activity/aggregated", + params=_DATE_PARAMS, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value}, + ) + assert service_account.status_code == 403, service_account.text + repository.aggregated.assert_not_awaited() + + own_scope: Final = client.get( + "/user/daily/activity/aggregated", + params=_DATE_PARAMS, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert own_scope.status_code == 200, own_scope.text + assert own_scope.json()["metadata"]["total_spend"] == pytest.approx(14.5) + assert own_scope.json()["metadata"]["total_api_keys"] == 5 + + +def test_team_scope_applies_membership_and_user_key_filter( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={**_entity_params("team_ids", "team-a"), "timezone": "480"}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(3.0) + assert response.json()["metadata"]["total_api_keys"] == 1 + scope: Final = repository.aggregated.call_args.args[0] + assert scope.api_keys == ("key-alpha",) + assert scope.entity_ids == ("team-a",) + assert scope.timezone_offset_minutes == 480 + assert repository.aggregated.call_args.kwargs["include_entity_breakdown"] is True + + +def test_team_scope_does_not_allow_an_unowned_api_key( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={**_entity_params("team_ids", "team-a"), "api_key": "key-beta"}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == 0 + assert response.json()["metadata"]["total_api_keys"] == 0 + assert "key-beta" not in response.text + + +def _assert_customer_route_denied(client: TestClient, prefix: str, suffix: str, extra_params: dict[str, str]) -> None: + response: Final = client.get( + f"{prefix}/daily/activity/{suffix}", + params={**_entity_params("end_user_ids", "customer-a"), **extra_params}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 403, response.text + + +def test_customer_service_routes_deny_non_admins(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, repository = daily_activity_client + route_params: Final = ( + ("aggregated", {}), + ("aggregated/search", {"search": "alpha"}), + ("aggregated/model_top_keys", {"model_group": "rare-group"}), + ("export", {"export_type": ExportType.DAILY.value}), + ) + for prefix in ("/customer", "/end_user"): + for suffix, extra_params in route_params: + _assert_customer_route_denied(client, prefix, suffix, extra_params) + repository.aggregated.assert_not_awaited() + + +def test_customer_end_user_aliases_are_hidden_from_openapi( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + paths: Final = client.get("/openapi.json").json()["paths"] + assert "/customer/daily/activity/aggregated" in paths + assert "/end_user/daily/activity/aggregated" not in paths + response: Final = client.get( + "/end_user/daily/activity/aggregated", + params=_entity_params("end_user_ids", "customer-a"), + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize( + ("prefix", "query_name", "entity_id"), + _ENTITY_CASES[:4] + (_ENTITY_CASES[-1],), +) +@pytest.mark.parametrize( + ("family", "extra_params"), + ( + ("aggregated", {}), + ("aggregated/search", {"search": "key"}), + ("aggregated/model_top_keys", {"model_group": "popular-group"}), + ("export", {"export_type": ExportType.DAILY.value}), + ), +) +def test_non_admin_routes_return_only_permitted_entities_and_keys( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, + family: str, + extra_params: Mapping[str, str], +) -> None: + client, _ = daily_activity_client + headers: Final = {"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"} + response: Final = client.get( + f"{prefix}/daily/activity/{family}", + params={**_entity_params(query_name, entity_id), **extra_params}, + headers=headers, + ) + assert response.status_code == 200, response.text + assert "key-other-" not in response.text, response.text + if prefix in ("/team", "/tag"): + assert "key-beta" not in response.text, response.text + assert "key-alpha" in response.text, response.text + + +def test_empty_scope_filters_fail_closed(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/tag/daily/activity/aggregated", + params=_entity_params("tags", "blue"), + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-empty"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == 0 + assert response.json()["results"] == [] + + +@pytest.mark.parametrize( + ("prefix", "query_name", "entity_id", "other_entity_id", "exclude_query_name"), + ( + ("/team", "team_ids", "team-a", "team-b", "exclude_team_ids"), + ("/organization", "organization_ids", "org-a", "other-org", "exclude_organization_ids"), + ("/customer", "end_user_ids", "customer-a", "customer-b", "exclude_end_user_ids"), + ("/agent", "agent_ids", "agent-a", "agent-b", "exclude_agent_ids"), + ), +) +def test_exclusion_filters_apply_after_entity_scope( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, + other_entity_id: str, + exclude_query_name: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated", + params={ + **_DATE_PARAMS, + query_name: f"{entity_id},{other_entity_id}", + exclude_query_name: entity_id, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(100) + assert response.json()["metadata"]["total_api_keys"] == 1 + + +def test_csv_formula_escaping_covers_all_supported_leading_characters() -> None: + dangerous_values: Final = ("=sum", "+sum", "-sum", "@sum", "\tsum", "\rsum") + assert tuple(_csv_cell(value) for value in dangerous_values) == tuple(f"'{value}" for value in dangerous_values) + + +def test_user_cache_leakage_route_returns_cache_keys_and_metadata( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/cache_leakage_keys", + params={**_entity_params("user_id", "user-a"), "limit": 4}, + ) + assert response.status_code == 200, response.text + assert repository.cache_leakage_keys.call_args.kwargs["limit"] == 4 + assert tuple(row["api_key"] for row in response.json()["api_keys"]) == ("key-cache",) + metrics: Final = response.json()["api_keys"][0]["metrics"] + expected_row: Final = KeySpendRow( + api_key="key-cache", + spend=2.0, + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + api_requests=1, + successful_requests=1, + failed_requests=0, + cache_read_input_tokens=20, + cache_creation_input_tokens=1, + ) + assert set(metrics) == set(KeySpendMetrics.model_fields) + assert metrics == {field: getattr(expected_row, field) for field in KeySpendMetrics.model_fields} + assert response.json()["api_keys"][0]["metadata"]["key_alias"] == "alias-key-cache" + repository.cache_leakage_keys.assert_awaited_once() + repository.key_metadata.assert_awaited_once() + assert repository.key_metadata.call_args.args[0] == frozenset(("key-cache",)) + assert repository.key_metadata.call_args.args[1] is not None + + +def test_user_cache_leakage_route_respects_requested_user_scope( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/cache_leakage_keys", + params=_entity_params("user_id", "user-missing"), + ) + assert response.status_code == 200, response.text + assert response.json() == {"api_keys": []} + scope: Final = repository.cache_leakage_keys.call_args.args[0] + assert scope.entity_ids == ("user-missing",) + + +def test_export_json_stream_has_all_seeded_rows(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/user/daily/activity/export", + params={ + **_entity_params("user_id", "user-a"), + "export_type": ExportType.DAILY.value, + "format": "json", + }, + ) + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("application/json") + assert len(response.json()) == 6 + + +def test_user_internal_role_is_scoped_to_api_key(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/tag/daily/activity/aggregated", + params=_entity_params("tags", "blue"), + headers={ + "x-user-role": LitellmUserRoles.INTERNAL_USER.value, + "x-user-id": "user-a", + }, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(3.0) + + +def test_user_aggregate_keeps_current_day_query_semantics( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated", + params={**_DATE_PARAMS, "user_id": "user-a", "timezone": 480, "include_current_utc_day": "true"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(14.5) + scope: Final = repository.aggregated.call_args.args[0] + assert scope.entity_ids == ("user-a",) + assert scope.timezone_offset_minutes == 480 + assert scope.include_current_utc_day is True + + +@pytest.mark.parametrize( + ("start_date", "end_date", "message"), + ( + ("2020-01-01", "2026-12-31", "at most 400 days"), + ("0000-01-01", "9999-12-31", "valid YYYY-MM-DD"), + ("2024-06-01", "2024-01-01", "on or after"), + ("not-a-date", "2024-01-31", "valid YYYY-MM-DD"), + (None, "2024-01-31", "start_date and end_date"), + ), +) +def test_team_aggregated_route_rejects_bad_date_ranges( + daily_activity_client: tuple[TestClient, _FakeRepository], + start_date: str | None, + end_date: str | None, + message: str, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={"start_date": start_date, "end_date": end_date, "team_ids": "team-a"}, + ) + assert response.status_code == 400, response.text + assert message in str(response.json()["detail"]), response.text + repository.aggregated.assert_not_awaited() + + +def test_user_aggregate_rejects_missing_dates(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, repository = daily_activity_client + missing_dates: Final = client.get("/user/daily/activity/aggregated", params={"user_id": "user-a"}) + assert missing_dates.status_code == 400, missing_dates.text + assert missing_dates.json()["detail"] == {"error": "Please provide start_date and end_date"} + repository.aggregated.assert_not_awaited() + + +@pytest.mark.parametrize( + ("start_date", "end_date", "message"), + ( + ("2020-01-01", "2026-12-31", "at most 400 days"), + ("not-a-date", "2024-01-31", "valid YYYY-MM-DD"), + ("2024-06-01", "2024-01-01", "on or after"), + ), +) +def test_user_key_page_rejects_bad_date_ranges( + daily_activity_client: tuple[TestClient, _FakeRepository], + start_date: str, + end_date: str, + message: str, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/keys", + params={"start_date": start_date, "end_date": end_date, "user_id": "user-a"}, + ) + assert response.status_code == 400, response.text + assert message in str(response.json()["detail"]), response.text + repository.key_page.assert_not_awaited() diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index be8628d7f46..799fe59147c 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -15,7 +15,6 @@ from fastapi.testclient import TestClient from pytest_mock import MockerFixture from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler - from litellm.proxy._types import ( LiteLLM_UserTableFiltered, LitellmUserRoles, @@ -26,12 +25,10 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.management_endpoints.internal_user_endpoints import ( - LiteLLM_UserTableWithKeyCount, _authorize_user_list_request, _resolve_org_filter_for_user_search, _resolve_user_email_metadata, _update_internal_user_params, - get_user_key_counts, get_users, new_user, ui_view_users, @@ -2480,185 +2477,6 @@ async def test_get_user_daily_activity_rejects_service_account_caller(monkeypatc mock_get_daily.assert_not_called() -@pytest.mark.asyncio -async def test_get_user_daily_activity_aggregated_rejects_service_account_caller( - monkeypatch, -): - """ - Same security regression as - test_get_user_daily_activity_rejects_service_account_caller, on the - aggregated route. Same shape, raw-SQL builder, same fix. - """ - from unittest.mock import AsyncMock, MagicMock - - from fastapi import HTTPException - - from litellm.proxy.management_endpoints.internal_user_endpoints import ( - get_user_daily_activity_aggregated, - ) - - mock_prisma_client = MagicMock() - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - - mock_get_daily_agg = AsyncMock() - monkeypatch.setattr( - "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", - mock_get_daily_agg, - ) - - service_account_key = UserAPIKeyAuth( - user_id=None, - user_role=LitellmUserRoles.INTERNAL_USER, - ) - - with pytest.raises(HTTPException) as exc_info: - await get_user_daily_activity_aggregated( - start_date="2025-01-01", - end_date="2025-01-31", - model=None, - api_key=None, - user_id=None, - timezone=None, - user_api_key_dict=service_account_key, - ) - - assert exc_info.value.status_code == 403 - assert "Service-account keys" in str(exc_info.value.detail) - mock_get_daily_agg.assert_not_called() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("include_current_utc_day", [False, True]) -async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch, include_current_utc_day): - """ - Test that admin users can call the aggregated endpoint without a user_id - to get a global view. Also verifies that the correct arguments are forwarded - to the underlying get_daily_activity_aggregated helper. - """ - from unittest.mock import AsyncMock, MagicMock - - from litellm.proxy.management_endpoints.internal_user_endpoints import ( - get_user_daily_activity_aggregated, - ) - - # Mock the prisma client - mock_prisma_client = MagicMock() - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - - # Mock the downstream helper so we don't need a real DB - mock_response = MagicMock() - mock_get_daily_agg = AsyncMock(return_value=mock_response) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", - mock_get_daily_agg, - ) - - # Admin caller - admin_key_dict = UserAPIKeyAuth( - user_id="admin-user-001", - user_role=LitellmUserRoles.PROXY_ADMIN, - ) - - # Admin calls without user_id → global view (entity_id=None) - result = await get_user_daily_activity_aggregated( - start_date="2025-02-01", - end_date="2025-02-28", - model="gpt-4", - api_key=None, - user_id=None, - timezone=480, - include_current_utc_day=include_current_utc_day, - user_api_key_dict=admin_key_dict, - ) - - assert result is mock_response - - # Verify the helper was called with the right parameters - mock_get_daily_agg.assert_called_once() - repository, scope = mock_get_daily_agg.call_args.args - assert repository is not None - assert scope.table.value == "litellm_dailyuserspend" - assert scope.entity_id_field == "user_id" - assert scope.entity_ids is None - assert scope.start_date == "2025-02-01" - assert scope.end_date == "2025-02-28" - assert scope.model == "gpt-4" - assert scope.api_keys is None - assert scope.timezone_offset_minutes == 480 - assert scope.include_current_utc_day is include_current_utc_day - - -@pytest.mark.asyncio -async def test_get_user_daily_activity_aggregated_non_admin_cannot_view_other_users( - monkeypatch, -): - """ - Same scoping contract as - test_get_user_daily_activity_non_admin_cannot_view_other_users, on the - aggregated route. Non-admins reach this handler now that the route is in - self_managed_routes, so the 403-on-mismatch and default-to-self behaviour - has to hold here too: opening the route must not widen access. - """ - from unittest.mock import AsyncMock, MagicMock, patch - - from fastapi import HTTPException - - from litellm.proxy.management_endpoints.internal_user_endpoints import ( - get_user_daily_activity_aggregated, - ) - - mock_prisma_client = MagicMock() - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - - non_admin_key_dict = UserAPIKeyAuth( - user_id="regular-user-123", - user_role=LitellmUserRoles.INTERNAL_USER, - ) - - # Case 1: Non-admin targets another user's data — 403, helper never reached - with patch( - "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", - new_callable=AsyncMock, - ) as mock_get_daily_agg: - with pytest.raises(HTTPException) as exc_info: - await get_user_daily_activity_aggregated( - start_date="2025-01-01", - end_date="2025-01-31", - model=None, - api_key=None, - user_id="other-user-456", - timezone=None, - user_api_key_dict=non_admin_key_dict, - ) - - assert exc_info.value.status_code == 403 - assert "Non-admin users can only view their own spend data" in str(exc_info.value.detail) - mock_get_daily_agg.assert_not_called() - - # Case 2: Non-admin omits user_id — scoped to their own user_id, not global - mock_response = MagicMock() - with patch( - "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", - new_callable=AsyncMock, - return_value=mock_response, - ) as mock_get_daily_agg: - result = await get_user_daily_activity_aggregated( - start_date="2025-01-01", - end_date="2025-01-31", - model=None, - api_key=None, - user_id=None, - timezone=None, - user_api_key_dict=non_admin_key_dict, - ) - - assert result is mock_response - mock_get_daily_agg.assert_called_once() - repository, scope = mock_get_daily_agg.call_args.args - assert repository is not None - assert scope.entity_ids == ("regular-user-123",) - - @pytest.mark.asyncio async def test_delete_user_cleans_up_created_by_invitation_links(mocker): """ diff --git a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py index 48586874788..440f93d1387 100644 --- a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py @@ -1292,7 +1292,7 @@ async def test_get_organization_daily_activity_non_admin_without_org_admin_role_ ) assert get_daily_activity_mock.call_args.kwargs["entity_id"] == [] - assert org_table_find_many.call_args.kwargs["where"] == {"organization_id": {"in": []}} + org_table_find_many.assert_not_awaited() @pytest.mark.asyncio diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index 764193c9650..7e8e588fec6 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -1,10 +1,10 @@ import asyncio import json +from collections.abc import Sequence from contextlib import AbstractContextManager, asynccontextmanager, contextmanager from dataclasses import dataclass from datetime import datetime, timezone from types import SimpleNamespace -from collections.abc import Sequence from typing import Final, Optional, cast from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch @@ -14602,129 +14602,6 @@ async def test_new_team_batch_enqueued_token_limit_rejected_for_non_admin(): assert "on a team" in str(exc.value.message) -@pytest.mark.asyncio -async def test_get_team_daily_activity_aggregated_scopes_and_flags(mock_db_client): - """The aggregated endpoint must apply the same non-admin key scoping as the - paginated one and request the per-team entity breakdown with the caller's - timezone, so the Team Usage UI gets every day in one response.""" - from litellm.proxy.management_endpoints.team_endpoints import ( - get_team_daily_activity_aggregated, - ) - - user_id = "test_user_123" - team_id = "test_team_456" - user_api_key_dict = UserAPIKeyAuth( - user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER - ) - - mock_user_info = LiteLLM_UserTable( - user_id=user_id, - teams=[team_id], - max_budget=1000.0, - spend=0.0, - user_email="test@example.com", - user_role="internal_user", - ) - - mock_team_member = Member(user_id=user_id, role="user") - mock_team = MagicMock(spec=LiteLLM_TeamTable) - mock_team.team_id = team_id - mock_team.team_alias = "Test Team" - mock_team.members_with_roles = [mock_team_member] - mock_team.model_dump.return_value = { - "team_id": team_id, - "team_alias": "Test Team", - "members_with_roles": [{"user_id": user_id, "role": "user"}], - } - - user_api_key_1 = MagicMock() - user_api_key_1.token = "user_key_1" - - mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) - mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( - return_value=[user_api_key_1] - ) - - with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - ) as mock_get_user_object: - mock_get_user_object.return_value = mock_user_info - - with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", - new_callable=AsyncMock, - ) as mock_aggregated: - mock_aggregated.return_value = MagicMock() - - await get_team_daily_activity_aggregated( - team_ids=team_id, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=None, - exclude_team_ids=None, - timezone=480, - user_api_key_dict=user_api_key_dict, - ) - - mock_aggregated.assert_called_once() - repository, scope = mock_aggregated.call_args.args - call_kwargs = mock_aggregated.call_args.kwargs - assert repository is not None - assert scope.api_keys == ("user_key_1",) - assert scope.entity_ids == (team_id,) - assert call_kwargs["entity_metadata_field"] == { - team_id: {"team_alias": "Test Team"} - } - assert call_kwargs["include_entity_breakdown"] is True - assert scope.timezone_offset_minutes == 480 - assert scope.table.value == "litellm_dailyteamspend" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "start_date,end_date,expected_error", - [ - ("2020-01-01", "2026-12-31", "at most 400 days"), - ("0000-01-01", "9999-12-31", "valid YYYY-MM-DD"), - ("2024-06-01", "2024-01-01", "on or after"), - ("not-a-date", "2024-01-31", "valid YYYY-MM-DD"), - (None, "2024-01-31", "start_date and end_date"), - ], -) -async def test_get_team_daily_activity_aggregated_rejects_bad_ranges( - mock_db_client, start_date, end_date, expected_error -): - """The aggregated endpoint has no pagination bounding its work, so an - unbounded or malformed range must 400 before any query runs.""" - from litellm.proxy.management_endpoints.team_endpoints import ( - get_team_daily_activity_aggregated, - ) - - with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", - new_callable=AsyncMock, - ) as mock_aggregated: - with pytest.raises(HTTPException) as exc_info: - await get_team_daily_activity_aggregated( - team_ids=None, - start_date=start_date, - end_date=end_date, - model=None, - api_key=None, - exclude_team_ids=None, - timezone=None, - user_api_key_dict=UserAPIKeyAuth( - user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN - ), - ) - - assert exc_info.value.status_code == 400 - assert expected_error in str(exc_info.value.detail) - mock_aggregated.assert_not_called() - - def _wire_new_team_prisma(mock_db_client): mock_db_client.jsonify_team_object = lambda db_data: db_data mock_db_client.get_data = AsyncMock(return_value=None) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7bce039cd3e..a240b4ed84a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -834,6 +834,91 @@ export interface paths { patch?: never; trace?: never; }; + "/agent/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Aggregated */ + get: operations["get_agent_daily_activity_aggregated_agent_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Aggregated Keys */ + get: operations["get_agent_daily_activity_aggregated_keys_agent_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Model Top Keys */ + get: operations["get_agent_daily_activity_model_top_keys_agent_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Aggregated Search */ + get: operations["get_agent_daily_activity_aggregated_search_agent_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Export */ + get: operations["get_agent_daily_activity_export_agent_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/alerting/settings": { parameters: { query?: never; @@ -3982,6 +4067,91 @@ export interface paths { patch?: never; trace?: never; }; + "/customer/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated */ + get: operations["get_customer_daily_activity_aggregated_customer_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Keys */ + get: operations["get_customer_daily_activity_aggregated_keys_customer_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Model Top Keys */ + get: operations["get_customer_daily_activity_model_top_keys_customer_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Search */ + get: operations["get_customer_daily_activity_aggregated_search_customer_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Export */ + get: operations["get_customer_daily_activity_export_customer_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/customer/delete": { parameters: { query?: never; @@ -4542,6 +4712,91 @@ export interface paths { patch?: never; trace?: never; }; + "/end_user/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated */ + get: operations["get_customer_daily_activity_aggregated_end_user_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Keys */ + get: operations["get_customer_daily_activity_aggregated_keys_end_user_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Model Top Keys */ + get: operations["get_customer_daily_activity_model_top_keys_end_user_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Search */ + get: operations["get_customer_daily_activity_aggregated_search_end_user_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Export */ + get: operations["get_customer_daily_activity_export_end_user_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/end_user/delete": { parameters: { query?: never; @@ -11093,6 +11348,91 @@ export interface paths { patch?: never; trace?: never; }; + "/organization/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Aggregated */ + get: operations["get_organization_daily_activity_aggregated_organization_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Aggregated Keys */ + get: operations["get_organization_daily_activity_aggregated_keys_organization_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Model Top Keys */ + get: operations["get_organization_daily_activity_model_top_keys_organization_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Aggregated Search */ + get: operations["get_organization_daily_activity_aggregated_search_organization_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Export */ + get: operations["get_organization_daily_activity_export_organization_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/organization/delete": { parameters: { query?: never; @@ -15647,6 +15987,91 @@ export interface paths { patch?: never; trace?: never; }; + "/tag/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Aggregated */ + get: operations["get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Aggregated Keys */ + get: operations["get_tag_daily_activity_aggregated_keys_tag_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Model Top Keys */ + get: operations["get_tag_daily_activity_model_top_keys_tag_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Aggregated Search */ + get: operations["get_tag_daily_activity_aggregated_search_tag_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Export */ + get: operations["get_tag_daily_activity_export_tag_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tag/dau": { parameters: { query?: never; @@ -16138,6 +16563,74 @@ export interface paths { patch?: never; trace?: never; }; + "/team/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Aggregated Keys */ + get: operations["get_team_daily_activity_aggregated_keys_team_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/team/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Model Top Keys */ + get: operations["get_team_daily_activity_model_top_keys_team_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/team/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Aggregated Search */ + get: operations["get_team_daily_activity_aggregated_search_team_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/team/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Export */ + get: operations["get_team_daily_activity_export_team_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/team/delete": { parameters: { query?: never; @@ -17819,6 +18312,91 @@ export interface paths { patch?: never; trace?: never; }; + "/user/daily/activity/aggregated/cache_leakage_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Cache Leakage Keys */ + get: operations["get_user_daily_activity_cache_leakage_keys_user_daily_activity_aggregated_cache_leakage_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Aggregated Keys */ + get: operations["get_user_daily_activity_aggregated_keys_user_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Model Top Keys */ + get: operations["get_user_daily_activity_model_top_keys_user_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Aggregated Search */ + get: operations["get_user_daily_activity_aggregated_search_user_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Export */ + get: operations["get_user_daily_activity_export_user_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/delete": { parameters: { query?: never; @@ -27151,6 +27729,11 @@ export interface components { */ source: "provider_usage"; }; + /** CacheLeakageKeysResponse */ + CacheLeakageKeysResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeySpendActivityRow"][]; + }; /** CachePingResponse */ CachePingResponse: { /** Cache Type */ @@ -30170,6 +30753,22 @@ export interface components { */ ssl_verify?: string | null; }; + /** DailyActivityKeyPageResponse */ + DailyActivityKeyPageResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeySpendActivityRow"][]; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + /** Total Api Keys */ + total_api_keys: number; + }; + /** DailyActivityKeySearchResponse */ + DailyActivityKeySearchResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeyActivityRow"][]; + }; /** DailySpendData */ DailySpendData: { breakdown?: components["schemas"]["BreakdownMetrics"]; @@ -30977,6 +31576,11 @@ export interface components { /** Parts */ parts: components["schemas"]["TracePart"][]; }; + /** + * ExportType + * @enum {string} + */ + ExportType: "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users"; /** * FacetListResponse * @description The distinct values one column takes over a filtered query. `data` holds bare values, not entity rows. @@ -32564,6 +33168,13 @@ export interface components { worker_id?: string | null; }; JsonValue: unknown; + /** KeyActivityRow */ + KeyActivityRow: { + /** Api Key */ + api_key: string; + metadata: components["schemas"]["KeyMetadata"]; + metrics: components["schemas"]["SpendMetrics"]; + }; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -32626,6 +33237,61 @@ export interface components { /** Keys */ keys?: string[] | null; }; + /** KeySpendActivityRow */ + KeySpendActivityRow: { + /** Api Key */ + api_key: string; + metadata: components["schemas"]["KeyMetadata"]; + metrics: components["schemas"]["KeySpendMetrics"]; + }; + /** KeySpendMetrics */ + KeySpendMetrics: { + /** + * Api Requests + * @default 0 + */ + api_requests: number; + /** + * Cache Creation Input Tokens + * @default 0 + */ + cache_creation_input_tokens: number; + /** + * Cache Read Input Tokens + * @default 0 + */ + cache_read_input_tokens: number; + /** + * Completion Tokens + * @default 0 + */ + completion_tokens: number; + /** + * Failed Requests + * @default 0 + */ + failed_requests: number; + /** + * Prompt Tokens + * @default 0 + */ + prompt_tokens: number; + /** + * Spend + * @default 0 + */ + spend: number; + /** + * Successful Requests + * @default 0 + */ + successful_requests: number; + /** + * Total Tokens + * @default 0 + */ + total_tokens: number; + }; /** * KeyUpdateFields * @description Allowlist of bulk-broadcastable fields for /team/key/bulk_update; `extra="forbid"` blocks RBAC/ownership/scope mutations even by team admins. @@ -37312,6 +37978,15 @@ export interface components { /** Cost */ cost: number; }; + /** ModelTopKeysResponse */ + ModelTopKeysResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeySpendActivityRow"][]; + /** By Model Group */ + by_model_group: boolean; + /** Model */ + model: string; + }; /** * Move * @description A mouse move action. @@ -49724,6 +50399,202 @@ export interface operations { }; }; }; + get_agent_daily_activity_aggregated_agent_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_aggregated_keys_agent_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_model_top_keys_agent_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_aggregated_search_agent_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_export_agent_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; alerting_settings_alerting_settings_get: { parameters: { query?: never; @@ -54767,6 +55638,202 @@ export interface operations { }; }; }; + get_customer_daily_activity_aggregated_customer_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_keys_customer_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_model_top_keys_customer_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_search_customer_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_export_customer_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_end_user_customer_delete_post: { parameters: { query?: never; @@ -55364,6 +56431,202 @@ export interface operations { }; }; }; + get_customer_daily_activity_aggregated_end_user_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_keys_end_user_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_model_top_keys_end_user_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_search_end_user_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_export_end_user_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_end_user_end_user_delete_post: { parameters: { query?: never; @@ -64202,6 +65465,202 @@ export interface operations { }; }; }; + get_organization_daily_activity_aggregated_organization_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_aggregated_keys_organization_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_model_top_keys_organization_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_aggregated_search_organization_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_export_organization_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_organization_organization_delete_delete: { parameters: { query?: never; @@ -69246,6 +70705,197 @@ export interface operations { }; }; }; + get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_aggregated_keys_tag_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_model_top_keys_tag_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_aggregated_search_tag_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_export_tag_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_daily_active_users_tag_dau_get: { parameters: { query?: { @@ -69748,6 +71398,7 @@ export interface operations { get_team_daily_activity_aggregated_team_daily_activity_aggregated_get: { parameters: { query?: { + api_key_limit?: number; team_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -69782,6 +71433,164 @@ export interface operations { }; }; }; + get_team_daily_activity_aggregated_keys_team_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_team_daily_activity_model_top_keys_team_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_team_daily_activity_aggregated_search_team_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_team_daily_activity_export_team_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_team_team_delete_post: { parameters: { query?: never; @@ -71871,6 +73680,7 @@ export interface operations { get_user_daily_activity_aggregated_user_daily_activity_aggregated_get: { parameters: { query?: { + api_key_limit?: number; /** @description Start date in YYYY-MM-DD format */ start_date?: string | null; /** @description End date in YYYY-MM-DD format */ @@ -71912,6 +73722,237 @@ export interface operations { }; }; }; + get_user_daily_activity_cache_leakage_keys_user_daily_activity_aggregated_cache_leakage_keys_get: { + parameters: { + query?: { + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @description Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CacheLeakageKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_aggregated_keys_user_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @description Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_model_top_keys_user_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @description Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_aggregated_search_user_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @description Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_export_user_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @description Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_user_user_delete_post: { parameters: { query?: never; From ee3eff26cb1b42acffa69508bb7ac978fffdab4d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:03:09 -0700 Subject: [PATCH 07/83] feat(ui): usage pages consume bounded daily activity routes (#43409) Resolves LIT-8899 Usage, cost optimization, user and team pages read the aggregate, paginated key, search, model top key, cache leakage and export routes added by the lower layers instead of downloading every key's daily rows into the browser. Key detail, model top key and search failures render explicit errors with retry controls, Overall Usage shows a loader while the aggregate is in flight, Retry on a failed first key page shows the loading state while it refetches, a short query keeps the loader or first page error visible instead of No keys match, key paging advances by the server offset so a page of already loaded keys moves on and only an empty page ends paging, and search results are stored as rows so a new teams array from the parent does not restart an in-flight search, and the global Cost tab shows a loader instead of zero totals while the aggregate reloads. Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/eslint-suppressions.json | 19 +- .../_components/CacheLeakageCard.test.tsx | 153 +- .../_components/CacheLeakageCard.tsx | 37 +- .../CostOptimizationView.activity.test.tsx | 30 +- .../_components/CostOptimizationView.test.tsx | 7 +- .../_components/CostOptimizationView.tsx | 17 +- .../_components/PromptCachingTab.test.tsx | 14 +- .../_components/UsageTab.test.tsx | 13 +- .../_components/UsageTab.tsx | 4 +- .../_components/costOptimizationUtils.ts | 90 +- .../_components/useCacheLeakageKeys.ts | 66 + .../useDailyActivityRange.test.tsx | 82 +- .../_components/useDailyActivityRange.ts | 88 +- .../EntityUsage/EntityUsage.test.tsx | 258 +- .../components/EntityUsage/EntityUsage.tsx | 248 +- .../components/EntityUsage/entityFetchFns.ts | 52 + .../components/UsagePageView.test.tsx | 195 +- .../_components/components/UsagePageView.tsx | 267 +- .../hooks/useAggregatedDailyActivity.test.ts | 71 + .../hooks/useAggregatedDailyActivity.ts | 65 + .../hooks/usePaginatedDailyActivity.test.ts | 321 -- .../hooks/usePaginatedDailyActivity.ts | 374 -- .../user_info_view.integration.test.tsx | 57 +- .../EntityUsageExportModal.test.tsx | 102 +- .../EntityUsageExportModal.tsx | 70 +- .../ExportTypeSelector.test.tsx | 2 +- .../EntityUsageExport/ExportTypeSelector.tsx | 10 +- .../UsageExportHeader.test.tsx | 33 +- .../EntityUsageExport/UsageExportHeader.tsx | 20 +- .../exportBlockedReason.test.ts | 34 - .../EntityUsageExport/exportBlockedReason.ts | 13 - .../src/components/EntityUsageExport/types.ts | 60 +- .../EntityUsageExport/utils.test.ts | 3037 +---------------- .../src/components/EntityUsageExport/utils.ts | 496 +-- .../KeyActivityPanel.integration.test.tsx | 674 ++++ .../components/KeyActivityPanel.test.tsx | 71 - .../UsagePage/components/KeyActivityPanel.tsx | 421 ++- .../UsagePage/components/keySearch.ts | 33 + .../components/UsagePage/dailyActivityApi.ts | 101 + .../UsagePage/keyActivityData.test.ts | 154 + .../components/UsagePage/keyActivityData.ts | 61 + .../UsagePage/keyActivityFilter.test.ts | 1 - .../src/components/UsagePage/types.ts | 11 - .../src/components/activity_metrics.test.tsx | 335 +- .../src/components/activity_metrics.tsx | 319 +- .../src/components/chat/UsagePanel.tsx | 12 +- .../networking.dailyActivity.test.ts | 224 ++ .../src/components/networking.test.ts | 136 - .../src/components/networking.tsx | 372 +- .../shared/PaginationStatusAlerts.test.tsx | 108 - .../shared/PaginationStatusAlerts.tsx | 63 - .../components/shared/ScopedSavingsTab.tsx | 10 +- .../KeySavingsTab.integration.test.tsx | 13 +- .../src/lib/http/client.test.ts | 19 + ui/litellm-dashboard/src/lib/http/client.ts | 14 +- 55 files changed, 3394 insertions(+), 6163 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts delete mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts delete mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts create mode 100644 ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts delete mode 100644 ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index daf12d11743..2465d07129c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1140,15 +1140,7 @@ "count": 1 }, "react-hooks/set-state-in-effect": { - "count": 3 - } - }, - "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { - "react-hooks/refs": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/users/_components/BulkEditUsers.tsx": { @@ -1279,11 +1271,6 @@ "count": 1 } }, - "src/components/EntityUsageExport/utils.ts": { - "max-params": { - "count": 3 - } - }, "src/components/GuardrailSettingsView.tsx": { "no-nested-ternary": { "count": 1 @@ -1786,13 +1773,13 @@ "count": 1 }, "max-params": { - "count": 21 + "count": 15 }, "no-nested-ternary": { "count": 5 }, "no-restricted-syntax": { - "count": 147 + "count": 146 }, "prefer-const": { "count": 31 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx index 8d328c4c329..95abc4e22cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx @@ -1,9 +1,18 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DailyData, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; +import type { components } from "@/lib/http/schema"; +import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; import type { DailyActivityRange } from "./useDailyActivityRange"; +const mockCacheLeakageKeysCall = vi.fn(); + +vi.mock("@/components/networking", () => ({ + cacheLeakageKeysCall: (...args: unknown[]) => mockCacheLeakageKeysCall(...args), +})); + vi.mock("@/components/shared/advanced_date_picker", () => ({ __esModule: true, default: () =>
, @@ -11,8 +20,9 @@ vi.mock("@/components/shared/advanced_date_picker", () => ({ import CacheLeakageCard from "./CacheLeakageCard"; -const baseMetrics = (overrides: Partial): SpendMetrics => ({ +const baseMetrics = (overrides: Partial): components["schemas"]["SpendMetrics"] => ({ spend: 0, + flat_cost: 0, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, @@ -21,27 +31,22 @@ const baseMetrics = (overrides: Partial): SpendMetrics => ({ failed_requests: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + compression_saved_tokens: 0, + compression_savings_spend: 0, + prompt_caching_savings_spend: 0, + gateway_injected_caching_savings_spend: 0, + autorouter_savings_spend: 0, + total_response_time_ms: 0, + timed_requests: 0, ...overrides, }); -const key = (alias: string, metrics: Partial): KeyMetricWithMetadata => ({ +const keyRow = (hash: string, alias: string, metrics: Partial): KeySpendActivityRow => ({ + api_key: hash, metrics: baseMetrics(metrics), metadata: { key_alias: alias, team_id: null }, }); -const dayWithKeys = (date: string, apiKeys: Record): DailyData => ({ - date, - metrics: baseMetrics({}), - breakdown: { - models: {}, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: apiKeys, - entities: {}, - }, -}); - const dayWithModels = (date: string, models: Record>): DailyData => ({ date, metrics: baseMetrics({}), @@ -67,27 +72,37 @@ const renderWith = (results: DailyData[], overrides: Partial dateValue: {}, onDateChange: vi.fn(), results, + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: new Date(2025, 0, 1), + endTime: new Date(2025, 0, 31), + userId: null, + apiKey: null, + }, ...overrides, }} />, ); describe("CacheLeakageCard", () => { - it("ranks leaking keys by uncached prompt tokens and shows cache hit ratio", () => { - renderWith([ - dayWithKeys("2026-07-12", { - "hash-caching": key("caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }), - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }), - ]); + beforeEach(() => { + mockCacheLeakageKeysCall.mockReset(); + mockCacheLeakageKeysCall.mockResolvedValue({ api_keys: [] }); + }); - expect(screen.getByText("leaky-key")).toBeInTheDocument(); + it("ranks leaking keys from the server-ranked key list and shows cache hit ratio", async () => { + mockCacheLeakageKeysCall.mockResolvedValue({ + api_keys: [ + keyRow("hash-caching", "caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }), + keyRow("hash-leaky", "leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), + ], + }); + renderWith([]); + + expect(await screen.findByText("leaky-key")).toBeInTheDocument(); expect(screen.getByText("0.0%")).toBeInTheDocument(); expect(screen.getByText("90.0%")).toBeInTheDocument(); [ @@ -97,23 +112,42 @@ describe("CacheLeakageCard", () => { ].forEach((info) => expect(screen.getByLabelText(info)).toBeInTheDocument()); }); - it("sorts by the clicked column, worst cache hit rate first", () => { - renderWith([ - dayWithKeys("2026-07-12", { - "hash-a": key("alpha", { + it("asks the server for the key ranking under the activity scope", async () => { + renderWith([], { + scope: { + accessToken: "test-token", + startTime: new Date(2025, 0, 1), + endTime: new Date(2025, 0, 31), + userId: "u1", + apiKey: "hash-1", + }, + }); + + await screen.findByText("No key usage in this range."); + expect(mockCacheLeakageKeysCall).toHaveBeenCalledWith( + expect.objectContaining({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true }), + ); + }); + + it("sorts by the clicked column, worst cache hit rate first", async () => { + mockCacheLeakageKeysCall.mockResolvedValue({ + api_keys: [ + keyRow("hash-a", "alpha", { prompt_tokens: 10000, cache_read_input_tokens: 9000, prompt_caching_savings_spend: 9.0, }), - "hash-b": key("bravo", { + keyRow("hash-b", "bravo", { prompt_tokens: 500, cache_read_input_tokens: 50, prompt_caching_savings_spend: 0.05, }), - }), - ]); + ], + }); + renderWith([]); const firstDataRow = () => screen.getAllByRole("row")[1]; + expect(await screen.findByText("alpha")).toBeInTheDocument(); expect(firstDataRow()).toHaveTextContent("alpha"); fireEvent.click(screen.getByText("Cache hit rate")); @@ -123,7 +157,7 @@ describe("CacheLeakageCard", () => { expect(firstDataRow()).toHaveTextContent("alpha"); }); - it("switches to the model view and lists models from every provider", () => { + it("switches to the model view and lists models from every provider", async () => { renderWith([ dayWithModels("2026-07-12", { "claude-sonnet-5": { prompt_tokens: 5000, cache_read_input_tokens: 0 }, @@ -131,51 +165,32 @@ describe("CacheLeakageCard", () => { }), ]); - fireEvent.click(screen.getByText("By model")); + fireEvent.click(await screen.findByText("By model")); expect(screen.getByText("Cache leakage by model")).toBeInTheDocument(); expect(screen.getByText("claude-sonnet-5")).toBeInTheDocument(); expect(screen.getByText("vertex_ai/gemini-2.5-pro")).toBeInTheDocument(); }); - it("shows an empty state when no key used tokens in the range", () => { - renderWith([dayWithKeys("2026-07-12", {})]); + it("shows an empty state when no key used tokens in the range", async () => { + renderWith([]); - expect(screen.getByText("No key usage in this range.")).toBeInTheDocument(); + expect(await screen.findByText("No key usage in this range.")).toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); - it("tells the user the table is still filling in while fallback pages stream", () => { - const day = dayWithKeys("2026-07-12", { - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }); - renderWith([day], { isFetchingMore: true }); + it("reports a load failure instead of claiming the range is empty", async () => { + mockCacheLeakageKeysCall.mockRejectedValue(new Error("route unavailable")); + renderWith([]); - expect(screen.getByRole("table")).toBeInTheDocument(); - expect( - screen.getByText("Data is still loading; rows and totals will update as the rest of the range arrives."), - ).toBeInTheDocument(); + expect(await screen.findByText("Could not load key usage for this range.")).toBeInTheDocument(); + expect(screen.queryByText("No key usage in this range.")).not.toBeInTheDocument(); }); - it("keeps the streaming note off while a fresh range loads over the previous range's rows", () => { - const day = dayWithKeys("2026-07-12", { - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }); - renderWith([day], { loading: true }); + it("shows a loading state while the key ranking is in flight", () => { + mockCacheLeakageKeysCall.mockReturnValue(new Promise(() => {})); + renderWith([]); - expect( - screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."), - ).not.toBeInTheDocument(); - }); - - it("drops the streaming note once the range has settled", () => { - const day = dayWithKeys("2026-07-12", { - "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), - }); - renderWith([day]); - - expect( - screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."), - ).not.toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx index cfac77788b7..7a63ccd1ee4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -8,8 +8,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { CacheLeakageDimension, CacheLeakageRow, computeCacheLeakage, pct, usd } from "./costOptimizationUtils"; +import { + CacheLeakageDimension, + CacheLeakageRow, + computeCacheLeakage, + leakageRowsFromKeyRows, + netSavingsPerCachedToken, + pct, + usd, +} from "./costOptimizationUtils"; import { DailyActivityRange } from "./useDailyActivityRange"; +import { useCacheLeakageKeys } from "./useCacheLeakageKeys"; interface CacheLeakageCardProps { activity: DailyActivityRange; @@ -80,11 +89,20 @@ const SortableHead = ({ }; const CacheLeakageCard: React.FC = ({ activity }) => { - const { results, loading, isFetchingMore } = activity; + const { results, loading } = activity; const [dimension, setDimension] = useState("key"); const [sort, setSort] = useState({ column: "potentialSavings", dir: "desc" }); - const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]); - const rows = useMemo(() => [...leakage.rows].sort((a, b) => compareRows(a, b, sort)), [leakage.rows, sort]); + const leakageRate = useMemo(() => netSavingsPerCachedToken(results), [results]); + const keyLeakage = useCacheLeakageKeys(activity, dimension === "key"); + const unsortedRows = useMemo( + () => + dimension === "key" + ? leakageRowsFromKeyRows(keyLeakage.rows, leakageRate) + : computeCacheLeakage(results, "model").rows, + [dimension, keyLeakage.rows, leakageRate, results], + ); + const rows = useMemo(() => [...unsortedRows].sort((a, b) => compareRows(a, b, sort)), [unsortedRows, sort]); + const rowsLoading = dimension === "key" ? keyLeakage.loading : loading; const onSort = (column: SortColumn) => setSort((prev) => @@ -96,6 +114,10 @@ const CacheLeakageCard: React.FC = ({ activity }) => { const subject = dimension === "model" ? "Models" : "Keys"; const firstColumn = dimension === "model" ? "Model" : "Key"; const emptyNoun = dimension === "model" ? "model" : "key"; + const emptyMessage = + dimension === "key" && keyLeakage.failed + ? "Could not load key usage for this range." + : `No ${emptyNoun} usage in this range.`; return ( @@ -119,14 +141,9 @@ const CacheLeakageCard: React.FC = ({ activity }) => { - {rows.length > 0 && isFetchingMore && ( -

- Data is still loading; rows and totals will update as the rest of the range arrives. -

- )} {rows.length === 0 ? (

- {loading || isFetchingMore ? "Loading..." : `No ${emptyNoun} usage in this range.`} + {rowsLoading ? "Loading..." : emptyMessage}

) : ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx index f8336f5ab56..204c4b1a409 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -3,8 +3,7 @@ import { fireEvent, render, waitFor, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const mockUserDailyActivityCall = vi.fn(); -const mockUserDailyActivityAggregatedCall = vi.fn(); +const mockDailyActivityAggregatedCall = vi.fn(); const { useAuthorizedMock, mockToolSpendResponse } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn(), mockToolSpendResponse: { by_tool: [], daily: [], start_date: null, end_date: null }, @@ -15,8 +14,8 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ })); vi.mock("@/components/networking", () => ({ - userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args), - userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), + dailyActivityAggregatedCall: (...args: unknown[]) => mockDailyActivityAggregatedCall(...args), + cacheLeakageKeysCall: vi.fn().mockResolvedValue({ api_keys: [] }), getToolSpend: vi.fn().mockResolvedValue(mockToolSpendResponse), getGeneralSettingsCall: vi.fn().mockResolvedValue([]), organizationListCall: vi.fn().mockResolvedValue([]), @@ -53,7 +52,7 @@ const singlePage = { describe("CostOptimizationView daily activity", () => { it("fetches daily activity once for the page and shares it with every tab that needs it", async () => { - mockUserDailyActivityAggregatedCall.mockResolvedValue(singlePage); + mockDailyActivityAggregatedCall.mockResolvedValue(singlePage); useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -63,25 +62,17 @@ describe("CostOptimizationView daily activity", () => { , ); - await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole("tab", { name: "Prompt Caching" })); await screen.findByTestId("caching-settings"); - expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); - expect(screen.queryByText(/Currently fetching spend data/)).not.toBeInTheDocument(); + expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); }); - it("shows the fetch-progress banner while the paginated fallback streams pages in", async () => { - mockUserDailyActivityAggregatedCall.mockReset(); - mockUserDailyActivityCall.mockReset(); - mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - mockUserDailyActivityCall.mockImplementation((...args: unknown[]) => - args[3] === 1 - ? Promise.resolve({ results: [], metadata: { total_pages: 3, has_more: true, page: 1 } }) - : new Promise(() => {}), - ); + it("surfaces a failure alert when the aggregated fetch fails", async () => { + mockDailyActivityAggregatedCall.mockReset(); + mockDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -91,7 +82,6 @@ describe("CostOptimizationView daily activity", () => { , ); - expect(await screen.findByText(/Currently fetching spend data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument(); + expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 028367555a1..a2f0ca5edc9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -11,12 +11,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ vi.mock("@/components/networking", () => ({ organizationListCall: vi.fn().mockResolvedValue([]), - userDailyActivityCall: vi - .fn() - .mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }), - userDailyActivityAggregatedCall: vi - .fn() - .mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }), + dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), })); vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
})); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 25bd3de0382..0aa4f88495a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Info, PiggyBank } from "lucide-react"; import useCan from "@/app/(dashboard)/hooks/useCan"; -import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; +import { Alert, AlertDescription } from "@/components/shared/Alert"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PageHeader } from "@/components/shared/PageHeader"; import UsageTab from "./UsageTab"; @@ -84,13 +84,14 @@ const CostOptimizationView: React.FC = ({ accessToken

- + {activity.failed && ( + + + Fetching spend data failed, so the savings below may be empty rather than final. Reload the page to try + again. + + + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx index 35464c5852e..a4a06cb6db0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, waitFor, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; + const mockGetGeneralSettingsCall = vi.fn(); vi.mock("@/components/networking", () => ({ @@ -46,12 +48,16 @@ describe("PromptCachingTab", () => { dateValue: {}, onDateChange: vi.fn(), results: [], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: null, + endTime: null, + userId: null, + apiKey: null, + }, }; render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index c62208aacc5..1d88e25396b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolSpendResponse } from "@/components/networking"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; const mockGetToolSpend = vi.fn(); @@ -119,12 +120,16 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => { dateValue: { from, to }, onDateChange: vi.fn(), results, + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: from, + endTime: to, + userId: null, + apiKey: null, + }, }} />, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 83b202590cb..2d673d96296 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -44,7 +44,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = { const isoDay = (d: Date): string => d.toISOString().slice(0, 10); const UsageTab: React.FC = ({ accessToken, activity }) => { - const { dateValue, onDateChange, results, loading, isFetchingMore } = activity; + const { dateValue, onDateChange, results, loading } = activity; const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; @@ -130,7 +130,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts index 5f16b1b04fd..b95e7e0d973 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -1,3 +1,4 @@ +import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi"; import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -101,6 +102,71 @@ const aggregateByModel = (results: readonly DailyData[]): Map { + const totals = [...aggregateByModel(results).values()].reduce( + (agg, a) => ({ + cachedTokens: agg.cachedTokens + a.cacheReadTokens + a.cacheCreationTokens, + realizedCachingSavings: agg.realizedCachingSavings + a.realizedCachingSavings, + }), + { cachedTokens: 0, realizedCachingSavings: 0 }, + ); + const rate = totals.cachedTokens > 0 ? totals.realizedCachingSavings / totals.cachedTokens : null; + return rate != null && rate > 0 ? rate : null; +}; + +const toLeakageRow = ( + id: string, + a: { + alias: string | null; + teamId: string | null; + promptTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + }, + rate: number | null, + dimension: CacheLeakageDimension, +): CacheLeakageRow => { + const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens); + return { + id, + label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`, + sublabel: dimension === "model" ? null : a.teamId, + uncachedPromptTokens, + cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0, + potentialSavings: rate != null ? uncachedPromptTokens * rate : null, + }; +}; + +const sortAndLimit = (rows: CacheLeakageRow[], rate: number | null, limit: number): CacheLeakageRow[] => + rows + .filter((row) => row.uncachedPromptTokens > 0) + .sort((x, y) => + rate != null + ? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0) + : y.uncachedPromptTokens - x.uncachedPromptTokens, + ) + .slice(0, limit); + +export const leakageRowsFromKeyRows = ( + rows: readonly KeySpendActivityRow[], + rate: number | null, + limit = 10, +): CacheLeakageRow[] => + sortAndLimit( + rows.map((row) => { + const metrics = { + alias: row.metadata.key_alias ?? null, + teamId: row.metadata.team_id ?? null, + promptTokens: row.metrics.prompt_tokens ?? 0, + cacheReadTokens: row.metrics.cache_read_input_tokens ?? 0, + cacheCreationTokens: row.metrics.cache_creation_input_tokens ?? 0, + }; + return toLeakageRow(row.api_key, metrics, rate, "key"); + }), + rate, + limit, + ); + export const computeCacheLeakage = ( results: readonly DailyData[], dimension: CacheLeakageDimension = "key", @@ -123,27 +189,13 @@ export const computeCacheLeakage = ( // A non-positive rate prices no leakage: there is no saving to extrapolate from const rate = netSavingsPerCachedToken != null && netSavingsPerCachedToken > 0 ? netSavingsPerCachedToken : null; - const rows: CacheLeakageRow[] = [...byEntity.entries()] - .map(([id, a]) => { - const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens); - return { - id, - label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`, - sublabel: dimension === "model" ? null : a.teamId, - uncachedPromptTokens, - cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0, - potentialSavings: rate != null ? uncachedPromptTokens * rate : null, - }; - }) - .filter((row) => row.uncachedPromptTokens > 0); - - const sorted = rows.sort((x, y) => - rate != null - ? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0) - : y.uncachedPromptTokens - x.uncachedPromptTokens, + const rows = sortAndLimit( + [...byEntity.entries()].map(([id, a]) => toLeakageRow(id, a, rate, dimension)), + rate, + limit, ); - return { rows: sorted.slice(0, limit), netSavingsPerCachedToken }; + return { rows, netSavingsPerCachedToken }; }; export interface DailyToolSpendPoint { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts new file mode 100644 index 00000000000..4febd774eb8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +import { cacheLeakageKeysCall } from "@/components/networking"; +import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyActivityRange } from "./useDailyActivityRange"; + +interface CacheLeakageKeysResult { + rows: KeySpendActivityRow[]; + loading: boolean; + failed: boolean; +} + +interface SettledKeys { + key: string; + rows: KeySpendActivityRow[]; + failed: boolean; +} + +export const useCacheLeakageKeys = (range: DailyActivityRange, enabled: boolean): CacheLeakageKeysResult => { + const { accessToken, startTime, endTime, userId, apiKey } = range.scope; + const [settled, setSettled] = useState(null); + const requestIdRef = useRef(0); + + const hasTimeRange = !!startTime && !!endTime; + const scopeReady = enabled && !!accessToken && hasTimeRange; + const scopeKey = scopeReady ? JSON.stringify([accessToken, startTime, endTime, userId, apiKey]) : null; + + useEffect(() => { + if (!scopeKey) return; + if (!accessToken || !startTime || !endTime) return; + + const requestId = ++requestIdRef.current; + const isStale = () => requestIdRef.current !== requestId; + + const request = { + accessToken, + startTime, + endTime, + entityIds: userId ? [userId] : null, + apiKey, + includeCurrentUtcDay: true, + }; + cacheLeakageKeysCall(request) + .then((response) => { + if (isStale()) return; + setSettled({ key: scopeKey, rows: response.api_keys, failed: false }); + }) + .catch((error) => { + if (isStale()) return; + console.error("Failed to fetch cache leakage keys:", error); + setSettled({ key: scopeKey, rows: [], failed: true }); + }); + + return () => { + requestIdRef.current++; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- scopeKey serializes the scope + }, [scopeKey]); + + const current = scopeKey !== null && settled?.key === scopeKey ? settled : null; + return { + rows: current?.rows ?? [], + loading: scopeKey !== null && current === null, + failed: current?.failed ?? false, + }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index b94fa45ecb7..90d6694ccba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -1,35 +1,35 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -const mockUsePaginatedDailyActivity = vi.fn(); +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; -const mockCancel = vi.fn(); +const mockUseAggregatedDailyActivity = vi.fn(); -vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({ - usePaginatedDailyActivity: (args: unknown) => { - mockUsePaginatedDailyActivity(args); +vi.mock("@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity", () => ({ + useAggregatedDailyActivity: (options: unknown) => { + mockUseAggregatedDailyActivity(options); return { - data: { results: [] }, + data: { results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA }, loading: false, - isFetchingMore: false, - progress: { currentPage: 4, totalPages: 9 }, - cancelled: false, failed: false, - coversRange: true, - cancel: mockCancel, }; }, })); vi.mock("@/components/networking", () => ({ - userDailyActivityCall: vi.fn(), - userDailyActivityAggregatedCall: vi.fn(), + dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), })); -import { userDailyActivityAggregatedCall } from "@/components/networking"; +import { dailyActivityAggregatedCall } from "@/components/networking"; import { useActivityDateRange, useDailyActivityRange } from "./useDailyActivityRange"; -const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[]; +interface CapturedOptions { + fetch: () => Promise; + enabled: boolean; + deps: unknown[]; +} + +const lastOptions = () => mockUseAggregatedDailyActivity.mock.calls.at(-1)?.[0] as CapturedOptions; describe("useDailyActivityRange", () => { it("offers date-range state without starting a daily-activity query", () => { @@ -37,49 +37,53 @@ describe("useDailyActivityRange", () => { expect(result.current.dateValue.from).toBeInstanceOf(Date); expect(result.current.dateValue.to).toBeInstanceOf(Date); - expect(mockUsePaginatedDailyActivity).not.toHaveBeenCalled(); + expect(mockUseAggregatedDailyActivity).not.toHaveBeenCalled(); }); - it("queries every user's activity for an admin", () => { + it("fetches every user's activity for an admin through the aggregated endpoint", async () => { renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true, null]); + await lastOptions().fetch(); + expect(dailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ + accessToken: "test-token", + entityIds: null, + includeCurrentUtcDay: true, + }), + ); }); - it("scopes the query to the caller for a non-admin", () => { + it("scopes the query to the caller for a non-admin", async () => { renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user")); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]); + await lastOptions().fetch(); + expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] })); }); it.each(["org_admin", "Org Admin"])( "scopes the query to the caller for %s, who has no admin view on this endpoint", - (role) => { + async (role) => { renderHook(() => useDailyActivityRange("test-token", "u1", role)); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]); + await lastOptions().fetch(); + expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] })); }, ); - it("fetches through the single-shot aggregated endpoint first so days never fragment across pages", () => { - renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - - expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith( - expect.objectContaining({ aggregatedFetchFn: userDailyActivityAggregatedCall }), - ); - }); - - it("forwards the pagination progress and cancel affordances instead of dropping them", () => { - const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - - expect(result.current.progress).toEqual({ currentPage: 4, totalPages: 9 }); - expect(result.current.cancelled).toBe(false); - expect(result.current.cancel).toBe(mockCancel); - }); - it("stays disabled until an access token is available", () => { renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin")); - expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false })); + expect(lastOptions().enabled).toBe(false); + }); + + it("exposes the request scope so sibling hooks fetch under the same filters", () => { + const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user")); + + expect(result.current.scope).toMatchObject({ + accessToken: "test-token", + userId: "u1", + apiKey: null, + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 605926132e9..1af009397b1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -1,9 +1,15 @@ import { useMemo, useState } from "react"; -import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking"; +import { dailyActivityAggregatedCall } from "@/components/networking"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + toDailyData, + type DailyActivityMetadata, + type DailyActivityRequest, +} from "@/components/UsagePage/dailyActivityApi"; import { DailyData } from "@/components/UsagePage/types"; import { spendScopeUserId } from "@/utils/roles"; -import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity"; +import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity"; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; @@ -12,30 +18,22 @@ export interface DateRange { to?: Date; } +export interface DailyActivityScope { + accessToken: string | null; + startTime: Date | null; + endTime: Date | null; + userId: string | null; + apiKey: string | null; +} + export interface DailyActivityRange { dateValue: DateRange; onDateChange: (value: DateRange) => void; results: DailyData[]; + metadata: DailyActivityMetadata; loading: boolean; - isFetchingMore: boolean; - progress: { currentPage: number; totalPages: number }; - cancelled: boolean; failed: boolean; - cancel: () => void; -} - -/** - * Which slice of daily activity to read. Both fields are passed straight through to the - * endpoint as filters, so the caller — not this hook — decides what the viewer may see. - * - * `userId: null` asks for the whole proxy, which the backend only honours for admins; - * a non-admin must send its own id or the request is rejected. That role decision lives in - * `useDailyActivityRange` below rather than in here, so a caller scoping to one key is not - * silently re-scoped to a user as well. - */ -export interface DailyActivityScope { - userId: string | null; - apiKey?: string | null; + scope: DailyActivityScope; } export type ActivityDateRange = Pick; @@ -47,39 +45,49 @@ export const useActivityDateRange = (): ActivityDateRange => { return { dateValue, onDateChange: setDateValue }; }; +export interface ScopedActivityInput { + userId: string | null; + apiKey?: string | null; +} + export const useScopedDailyActivityRange = ( accessToken: string | null, - scope: DailyActivityScope, + scope: ScopedActivityInput, { dateValue, onDateChange }: ActivityDateRange, ): DailyActivityRange => { const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; const { userId, apiKey = null } = scope; - const activityQueryOptions = { - fetchFn: userDailyActivityCall, - aggregatedFetchFn: userDailyActivityAggregatedCall, - // Positional, and read by two functions whose signatures diverge at index 3: the paginated - // call takes `page` there (injected by the hook) and the aggregated one does not. Anything - // appended here must therefore be appended to BOTH networking signatures, in this order. - args: [accessToken, startTime, endTime, userId, true, apiKey], - enabled: !!accessToken && !!startTime && !!endTime, - }; - const { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel } = - usePaginatedDailyActivity(activityQueryOptions); - const readUnavailable = failed || cancelled; - const waitingForRange = activityQueryOptions.enabled && !coversRange && !readUnavailable; + const request = useMemo( + () => + accessToken && startTime && endTime + ? { + accessToken, + startTime, + endTime, + entityIds: userId ? [userId] : null, + apiKey, + includeCurrentUtcDay: true, + } + : null, + [accessToken, startTime, endTime, userId, apiKey], + ); + + const { data, loading, failed } = useAggregatedDailyActivity({ + fetch: () => dailyActivityAggregatedCall("user", request as DailyActivityRequest), + enabled: request !== null, + deps: [accessToken, startTime, endTime, userId, apiKey], + }); return { dateValue, onDateChange, - results: data.results as DailyData[], - loading: loading || waitingForRange, - isFetchingMore, - progress, - cancelled, + results: toDailyData(data), + metadata: data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + loading, failed, - cancel, + scope: { accessToken, startTime, endTime, userId, apiKey }, }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 8f5bb9455d0..23bd772729b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -5,7 +5,13 @@ import type { ReactNode } from "react"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import * as networking from "@/components/networking"; -import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; +import type { + DailyData, + KeyMetadata, + KeyMetricWithMetadata, + ModelActivityData, + SpendMetrics, +} from "@/components/UsagePage/types"; import EntityUsage from "./EntityUsage"; import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations"; @@ -51,21 +57,31 @@ beforeAll(() => { // Mock the networking module vi.mock("@/components/networking", () => ({ - tagDailyActivityCall: vi.fn(), - teamDailyActivityCall: vi.fn(), - teamDailyActivityAggregatedCall: vi.fn(), - organizationDailyActivityCall: vi.fn(), - customerDailyActivityCall: vi.fn(), - agentDailyActivityCall: vi.fn(), - userDailyActivityCall: vi.fn(), + dailyActivityAggregatedCall: vi.fn(), + dailyActivityKeyPageCall: vi.fn(), + dailyActivityKeySearchCall: vi.fn(), + dailyActivityModelTopKeysCall: vi.fn(), + dailyActivityExportCall: vi.fn(), })); // Mock the child components to simplify testing vi.mock("@/components/activity_metrics", () => ({ - ActivityMetrics: ({ modelMetrics }: { modelMetrics?: { __source?: string } }) => ( + ActivityMetrics: ({ + modelMetrics, + summaryMetrics, + summaryTitle = "Overall Usage", + fetchTopApiKeys, + }: { + modelMetrics?: { __source?: string }; + summaryMetrics?: ModelActivityData; + summaryTitle?: string; + fetchTopApiKeys?: (model: string) => Promise; + }) => (
Activity Metrics {`metrics-source:${modelMetrics?.__source ?? "none"}`} + {summaryMetrics !== undefined && {summaryTitle}} + {fetchTopApiKeys !== undefined && {`top-keys-fetcher:${modelMetrics?.__source ?? "none"}`}}
), processActivityData: (_data: unknown, key: string) => ({ __source: key }), @@ -126,7 +142,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ })); vi.mock("@/components/common_components/team_multi_select", () => ({ - default: () =>
Team Multi Select
, + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + + ), })); // Mock useTeams hook @@ -138,13 +156,22 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ })); describe("EntityUsage", () => { - const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall); - const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall); - const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall); - const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall); - const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall); - const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall); - const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); + const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall); + const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall); + const mockTagDailyActivityCall = vi.fn(); + const mockTeamDailyActivityCall = vi.fn(); + const mockOrganizationDailyActivityCall = vi.fn(); + const mockCustomerDailyActivityCall = vi.fn(); + const mockAgentDailyActivityCall = vi.fn(); + const mockUserDailyActivityCall = vi.fn(); + const entityMocks: Record> = { + tag: mockTagDailyActivityCall, + team: mockTeamDailyActivityCall, + organization: mockOrganizationDailyActivityCall, + customer: mockCustomerDailyActivityCall, + agent: mockAgentDailyActivityCall, + user: mockUserDailyActivityCall, + }; const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); const infiniteUsersResult = (users: { user_id: string; user_alias: string | null; user_email: string | null }[]) => @@ -440,16 +467,25 @@ describe("EntityUsage", () => { }; beforeEach(() => { - mockTagDailyActivityCall.mockClear(); - mockTeamDailyActivityCall.mockClear(); - mockTeamDailyActivityAggregatedCall.mockClear(); - mockOrganizationDailyActivityCall.mockClear(); - mockCustomerDailyActivityCall.mockClear(); - mockAgentDailyActivityCall.mockClear(); - mockUserDailyActivityCall.mockClear(); + mockDailyActivityAggregatedCall.mockReset(); + mockDailyActivityKeyPageCall.mockReset(); + const emptyKeyPage = { + api_keys: [], + total_api_keys: 0, + offset: 0, + limit: 50, + }; + mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage); + mockDailyActivityAggregatedCall.mockImplementation((entity, request) => + ( + entityMocks[entity] as unknown as ( + req: typeof request, + ) => ReturnType + )(request), + ); + Object.values(entityMocks).forEach((mock) => mock.mockClear()); mockTagDailyActivityCall.mockResolvedValue(mockSpendData); mockTeamDailyActivityCall.mockResolvedValue(mockSpendData); - mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData); mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData); mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData); @@ -534,7 +570,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); // Check that it shows team-specific label @@ -650,6 +686,36 @@ describe("EntityUsage", () => { expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument(); }); + it("loads key pages separately from the aggregate using the current entity scope", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(1); + }); + fireEvent.click(screen.getByText("Team Multi Select")); + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(2); + }); + fireEvent.click(screen.getByText("Key Activity")); + await waitFor(() => { + expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith( + "team", + expect.objectContaining({ entityIds: ["team-1"] }), + 0, + 50, + ); + }); + expect(mockDailyActivityAggregatedCall.mock.lastCall?.[1]).not.toHaveProperty("apiKeyLimit"); + + const pageCallsBeforeFilterChange = mockDailyActivityKeyPageCall.mock.calls.length; + fireEvent.click(screen.getByText("Team Multi Select")); + + await waitFor(() => { + expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeFilterChange); + }); + expect(mockDailyActivityKeyPageCall.mock.lastCall?.[1]).toEqual(expect.objectContaining({ entityIds: ["team-1"] })); + }); + // An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the // other, so treat either as "not on screen" and the assertion holds whichever one is rendering. const isShowing = (element: HTMLElement): boolean => { @@ -671,7 +737,7 @@ describe("EntityUsage", () => { const NON_TEAM_PANELS: [string, string][] = [ ["Cost", "Tag Spend Overview"], ["Model Activity", "metrics-source:model_groups"], - ["Key Activity", "metrics-source:api_keys"], + ["Key Activity", "Overall Usage"], ["Endpoint Activity", "Endpoint Usage Panel"], ]; @@ -697,7 +763,7 @@ describe("EntityUsage", () => { ["Cost", "Team Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Agent Activity", "metrics-source:entities"], - ["Key Activity", "metrics-source:api_keys"], + ["Key Activity", "Overall Usage"], ["Endpoint Activity", "Endpoint Usage Panel"], ]; @@ -705,7 +771,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); act(() => { @@ -878,7 +944,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Agent Activity")).toBeInTheDocument(); @@ -898,7 +964,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument(); @@ -918,16 +984,56 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockAgentDailyActivityCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - 1, - null, - ); + expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "test-token" })); }); }); + it("offers per-model top keys in Model Activity but not in the agent breakdown", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + + act(() => { + fireEvent.click(screen.getByText("Model Activity")); + }); + expect(showingCount("top-keys-fetcher:model_groups")).toBeGreaterThan(0); + + act(() => { + fireEvent.click(screen.getByText("Agent Activity")); + }); + expect(showingCount("metrics-source:entities")).toBeGreaterThan(0); + expect(screen.queryByText("top-keys-fetcher:entities")).not.toBeInTheDocument(); + }); + + it("shows a loader instead of zero totals while the aggregate is in flight", async () => { + let resolveSpend: (value: typeof mockSpendData) => void = () => {}; + mockTagDailyActivityCall.mockReturnValue( + new Promise((resolve) => { + resolveSpend = resolve; + }), + ); + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.getAllByText("Loading chart data...")).toHaveLength(2); + expect(screen.queryByText("Total Spend")).not.toBeInTheDocument(); + expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + + await act(async () => { + resolveSpend(mockSpendData); + }); + + expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument(); + expect(screen.getByText("Overall Usage")).toBeInTheDocument(); + expect(screen.getByText("Total Spend")).toBeInTheDocument(); + expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + }); + it("should not fetch agent activity data for non-team entity types", async () => { render(); @@ -942,7 +1048,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); const agentActivityTab = screen.getByText("Agent Activity"); @@ -1098,7 +1204,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Team Spend Overview")).toBeInTheDocument(); @@ -1155,7 +1261,7 @@ describe("EntityUsage", () => { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; - mockTeamDailyActivityAggregatedCall.mockResolvedValue({ + mockTeamDailyActivityCall.mockResolvedValue({ ...mockSpendData, results: [ { @@ -1183,31 +1289,71 @@ describe("EntityUsage", () => { expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument(); }); - it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => { + it("uses the aggregated team endpoint and makes a single bounded request", async () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockDailyActivityAggregatedCall).toHaveBeenCalledWith("team", expect.anything()); }); - expect(mockTeamDailyActivityCall).not.toHaveBeenCalled(); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1); await waitFor(() => { expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); }); }); - it("falls back to the paginated team endpoint when the aggregated call fails", async () => { - mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - + it("does not scope the agent breakdown by the selected team ids", async () => { render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockAgentDailyActivityCall).toHaveBeenCalled(); }); + fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" })); + await waitFor(() => { - expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + const teamRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team"); + expect(teamRequests.some((call) => call[1].entityIds?.includes("team-1"))).toBe(true); }); + + const agentRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent"); + expect(agentRequests.length).toBeGreaterThan(0); + agentRequests.forEach((call) => { + expect(call[1].entityIds).toBeNull(); + }); + }); + + it("does not refetch agent activity when the team selection changes", async () => { + render(); + + await waitFor(() => { + expect(mockAgentDailyActivityCall).toHaveBeenCalled(); + }); + const agentCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent").length; + const teamCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length; + + fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" })); + + await waitFor(() => { + expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length).toBeGreaterThan( + teamCallsBefore, + ); + }); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent")).toHaveLength( + agentCallsBefore, + ); + }); + + it("surfaces a failure alert when the aggregated call fails instead of retrying other routes", async () => { + mockTeamDailyActivityCall.mockRejectedValue(new Error("aggregated unavailable")); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0); + }); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1); }); describe("user filter (LIT-5654)", () => { @@ -1244,18 +1390,16 @@ describe("EntityUsage", () => { const user = userEvent.setup(); await renderUserUsage(); - expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null); + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "test-token", entityIds: null }), + ); await user.click(userCombobox()); await user.click(await screen.findByText("Alice (user-001)")); await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - 1, - "user-001", + expect.objectContaining({ accessToken: "test-token", entityIds: ["user-001"] }), ); }); @@ -1264,11 +1408,7 @@ describe("EntityUsage", () => { await waitFor(() => { expect(mockUserDailyActivityCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - 1, - null, + expect.objectContaining({ accessToken: "test-token", entityIds: null }), ); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 6687bd4df03..3f239250967 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -6,7 +6,6 @@ import { getTopAgents, getTopAPIKeys, getTopModels, - type ExtendedDailyData, type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; @@ -17,27 +16,25 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { ChevronDown, ChevronRight, Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; +import { Alert, AlertDescription } from "@/components/shared/Alert"; +import { ChartLoader } from "@/components/shared/chart_loader"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import React, { type ReactNode, useMemo, useState } from "react"; +import React, { type ReactNode, useCallback, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import { UsageExportHeader } from "@/components/EntityUsageExport"; -import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import type { EntityType } from "@/components/EntityUsageExport/types"; -import { - agentDailyActivityCall, - customerDailyActivityCall, - organizationDailyActivityCall, - tagDailyActivityCall, - teamDailyActivityAggregatedCall, - teamDailyActivityCall, - userDailyActivityCall, -} from "@/components/networking"; import { Logo } from "@/components/molecules/logo/Logo"; -import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; +import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity"; +import { ENTITY_API } from "./entityFetchFns"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + toDailyData, + type DailyActivityRequest, +} from "@/components/UsagePage/dailyActivityApi"; +import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData"; import { EntityMetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import EndpointUsage from "../EndpointUsage/EndpointUsage"; @@ -62,18 +59,6 @@ interface EntityMetrics { metadata: Record; } -interface EntitySpendData { - results: ExtendedDailyData[]; - metadata: { - total_spend: number; - total_flat_cost?: number; - total_api_requests: number; - total_successful_requests: number; - total_failed_requests: number; - total_tokens: number; - }; -} - export interface EntityList { label: string; value: string; @@ -91,21 +76,6 @@ interface EntityUsageProps { isOrgAdmin?: boolean; } -const ENTITY_FETCH_FNS: Record Promise> = { - tag: tagDailyActivityCall, - team: teamDailyActivityCall, - organization: organizationDailyActivityCall, - customer: customerDailyActivityCall, - agent: agentDailyActivityCall, - user: userDailyActivityCall, -}; - -// Single-shot endpoints returning the whole range in one response; entity types -// without one fall back to page-draining the paginated endpoint. -const ENTITY_AGGREGATED_FETCH_FNS: Partial Promise>> = { - team: teamDailyActivityAggregatedCall, -}; - const ENTITY_CAPABILITIES: Partial> = { organization: "viewOrganizationUsage", agent: "viewAgentUsage", @@ -121,6 +91,7 @@ const EntityUsage: React.FC = ({ isOrgAdmin = false, }) => { const { teams } = useTeams(); + const teamList = useMemo(() => teams ?? [], [teams]); const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); const [topKeysLimit, setTopKeysLimit] = useState(5); @@ -130,56 +101,103 @@ const EntityUsage: React.FC = ({ const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); - - const entityFilterArg = useMemo(() => { - if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null; - return selectedTags.length > 0 ? selectedTags : null; - }, [entityType, selectedTags]); - - const fetchFn = ENTITY_FETCH_FNS[entityType]; - const aggregatedFetchFn = ENTITY_AGGREGATED_FETCH_FNS[entityType]; + const api = ENTITY_API[entityType]; const entityCapability = ENTITY_CAPABILITIES[entityType]; const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability, isOrgAdmin); const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage"); const hasRequestWindow = !!accessToken && !!startTime && !!endTime; const enabled = hasRequestWindow && canViewEntity; + const request = useMemo( + () => + hasRequestWindow + ? { + accessToken: accessToken as string, + startTime: startTime as Date, + endTime: endTime as Date, + entityIds: selectedTags.length > 0 ? selectedTags : null, + } + : null, + [hasRequestWindow, accessToken, startTime, endTime, selectedTags], + ); + const agentRequest = useMemo( + () => + hasRequestWindow + ? { + accessToken: accessToken as string, + startTime: startTime as Date, + endTime: endTime as Date, + entityIds: null, + } + : null, + [hasRequestWindow, accessToken, startTime, endTime], + ); + const { data: spendDataRaw, - isFetchingMore, - progress, - cancelled, + loading, failed, - coversRange, - cancel, - } = usePaginatedDailyActivity({ - fetchFn, - args: [accessToken, startTime, endTime, entityFilterArg], - enabled, - aggregatedFetchFn, + } = useAggregatedDailyActivity({ + fetch: () => api.aggregated(request as DailyActivityRequest), + enabled: enabled && request !== null, + deps: [entityType, accessToken, startTime, endTime, selectedTags], }); - const spendData = spendDataRaw as unknown as EntitySpendData; + const spendData = useMemo( + () => ({ + results: toDailyData(spendDataRaw), + metadata: spendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + }), + [spendDataRaw], + ); + const summaryMetrics = useMemo(() => overallUsageMetrics(spendData.results, spendData.metadata), [spendData]); const { data: agentSpendDataRaw, - isFetchingMore: agentIsFetchingMore, - progress: agentProgress, - cancelled: agentCancelled, + loading: agentLoading, failed: agentFailed, - cancel: agentCancel, - } = usePaginatedDailyActivity({ - fetchFn: agentDailyActivityCall, - args: [accessToken, startTime, endTime, null], - enabled: enabled && showAgentBreakdown, + } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.agent.aggregated(agentRequest as DailyActivityRequest), + enabled: enabled && showAgentBreakdown && agentRequest !== null, + deps: [accessToken, startTime, endTime, showAgentBreakdown], }); - const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData; + const agentSpendData = useMemo( + () => ({ + results: toDailyData(agentSpendDataRaw), + metadata: agentSpendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + }), + [agentSpendDataRaw], + ); + + const fetchTopApiKeys = useCallback( + (model: string) => api.modelTopKeys(request as DailyActivityRequest, model, modelViewType === "groups"), + [api, request, modelViewType], + ); + const searchKeys = useCallback( + (query: string) => (request === null ? Promise.resolve({ api_keys: [] }) : api.searchKeys(request, query)), + [api, request], + ); + const fetchKeyPage = useCallback( + (offset: number, limit: number) => + request === null + ? Promise.resolve({ api_keys: [], total_api_keys: 0, offset, limit }) + : api.keyPage(request, offset, limit), + [api, request], + ); + const fetchKeyDetail = useCallback( + (apiKey: string) => + request === null + ? Promise.resolve(undefined) + : api + .aggregated({ ...request, apiKey, apiKeyLimit: 1 }) + .then((response) => keyDetailFromResponse(response, apiKey, teamList)), + [api, request, teamList], + ); const modelBreakdownKey = modelViewType === "groups" ? "model_groups" : "models"; - const modelMetrics = processActivityData(spendData, modelBreakdownKey, teams || []); - const keyMetrics = processActivityData(spendData, "api_keys", teams || []); - const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teams || []) : {}; + const modelMetrics = processActivityData(spendData, modelBreakdownKey, teamList); + const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teamList) : {}; const getAllTags = () => { if (entityList) { @@ -393,7 +411,9 @@ const EntityUsage: React.FC = ({ const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"; - const costPanel = ( + const costPanel = loading ? ( + + ) : (
@@ -594,11 +614,15 @@ const EntityUsage: React.FC = ({

Top Agents Driving Spend

- + {agentLoading ? ( + + ) : ( + + )}
@@ -649,46 +673,67 @@ const EntityUsage: React.FC = ({
- + ), }, ...(showAgentBreakdown - ? [{ key: "agents", label: "Agent Activity", content: }] + ? [ + { + key: "agents", + label: "Agent Activity", + content: , + }, + ] : []), { key: "keys", label: "Key Activity", - content: , + content: ( + + ), }, { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const spendFetchState = { coversRange, cancelled, failed }; - return (
- - {showAgentBreakdown && ( - + {failed && ( + + + Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + + )} + {showAgentBreakdown && agentFailed && ( + + + Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + )} + request + ? api.exportRows(request, exportType, format) + : Promise.reject(new Error("Select a date range to export")) + } showFilters={filterSlot === undefined && entityList !== null} filterSlot={filterSlot} filterLabel={getFilterLabel(entityType)} @@ -696,8 +741,7 @@ const EntityUsage: React.FC = ({ selectedFilters={selectedTags} onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} - teams={teams || []} - exportBlockedReason={getExportBlockedReason(spendFetchState)} + teams={teamList} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts new file mode 100644 index 00000000000..6dc80ef4591 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts @@ -0,0 +1,52 @@ +import { + dailyActivityAggregatedCall, + dailyActivityExportCall, + dailyActivityKeyPageCall, + dailyActivityKeySearchCall, + dailyActivityModelTopKeysCall, +} from "@/components/networking"; +import type { EntityType } from "@/components/EntityUsageExport/types"; +import type { + DailyActivityAggregatedResponse, + DailyActivityEntity, + DailyActivityKeySearchResponse, + DailyActivityKeyPageResponse, + DailyActivityRequest, + ExportFormat, + ExportType, + ModelTopKeysResponse, +} from "@/components/UsagePage/dailyActivityApi"; + +export interface EntityApi { + aggregated(req: DailyActivityRequest): Promise; + keyPage(req: DailyActivityRequest, offset: number, limit: number): Promise; + searchKeys(req: DailyActivityRequest, search: string, limit?: number): Promise; + modelTopKeys( + req: DailyActivityRequest, + model: string, + byModelGroup: boolean, + limit?: number, + ): Promise; + exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise; +} + +const entityApi = ( + entity: DailyActivityEntity, + defaults?: Pick, +): EntityApi => ({ + aggregated: (req) => dailyActivityAggregatedCall(entity, { ...defaults, ...req }), + keyPage: (req, offset, limit) => dailyActivityKeyPageCall(entity, { ...defaults, ...req }, offset, limit), + searchKeys: (req, search, limit) => dailyActivityKeySearchCall(entity, { ...defaults, ...req }, search, limit), + modelTopKeys: (req, model, byModelGroup, limit) => + dailyActivityModelTopKeysCall(entity, { ...defaults, ...req }, model, byModelGroup, limit), + exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format), +}); + +export const ENTITY_API: Record = { + tag: entityApi("tag"), + team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }), + organization: entityApi("organization"), + customer: entityApi("customer"), + agent: entityApi("agent"), + user: entityApi("user"), +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 7702488f5bf..41873ca554b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -25,8 +25,11 @@ beforeAll(() => { // Mock the networking module vi.mock("@/components/networking", () => ({ - userDailyActivityCall: vi.fn(), - userDailyActivityAggregatedCall: vi.fn(), + dailyActivityAggregatedCall: vi.fn(), + dailyActivityKeyPageCall: vi.fn(), + dailyActivityKeySearchCall: vi.fn(), + dailyActivityModelTopKeysCall: vi.fn(), + dailyActivityExportCall: vi.fn(), gatewayDailyActivityCall: vi.fn(), tagListCall: vi.fn(), })); @@ -66,16 +69,25 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { const React = await import("react"); - const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => { + const UsageViewSelect = ({ + value, + onChange, + canViewTagUsage = false, + }: { + value: string; + onChange?: (value: string) => void; + canViewTagUsage?: boolean; + }) => { const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null; + const selectProps = { + value, + onChange: (e: React.ChangeEvent) => onChange?.(e.target.value), + role: "combobox", + "data-testid": "usage-view-select", + }; return React.createElement( "select", - { - value, - onChange: (e: any) => onChange?.(e.target.value), - role: "combobox", - "data-testid": "usage-view-select", - }, + selectProps, React.createElement("option", { value: "global" }, "Global Usage"), React.createElement("option", { value: "team" }, "Team Usage"), React.createElement("option", { value: "organization" }, "Organization Usage"), @@ -157,8 +169,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ })); describe("UsagePage", () => { - const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); - const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); + const mockUserDailyActivityAggregatedCall = vi.fn(); + const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall); + const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall); const mockTagListCall = vi.mocked(networking.tagListCall); const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall); const mockUseCustomers = vi.mocked(useCustomers); @@ -374,7 +387,17 @@ describe("UsagePage", () => { error: null, } as any); mockUserDailyActivityAggregatedCall.mockClear(); - mockUserDailyActivityCall.mockClear(); + mockDailyActivityAggregatedCall.mockReset(); + mockDailyActivityKeyPageCall.mockReset(); + mockDailyActivityKeyPageCall.mockResolvedValue({ + api_keys: [], + total_api_keys: 0, + offset: 0, + limit: 50, + }); + mockDailyActivityAggregatedCall.mockImplementation((entity: string, request: unknown) => + entity === "user" ? mockUserDailyActivityAggregatedCall(request) : Promise.resolve({ results: [], metadata: {} }), + ); mockTagListCall.mockClear(); mockGatewayDailyActivityCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); @@ -462,6 +485,11 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); }); expect(screen.queryByText("75,000")).not.toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(await screen.findByText("425,151")).toBeInTheDocument(); + expect(screen.getByText("Total Tokens").closest('[data-slot="card"]')).not.toHaveTextContent(/\d/); + expect(screen.queryByText("$0.0000")).not.toBeInTheDocument(); await act(async () => { releaseSecondFetch(); @@ -469,6 +497,28 @@ describe("UsagePage", () => { await waitFor(() => { expect(screen.getAllByText("75,000").length).toBeGreaterThan(0); }); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("$0.0838")).toBeInTheDocument(); + }); + + it("loads key pages separately from the aggregate and refreshes them when the range changes", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByText("Key Activity")); + await waitFor(() => { + expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith("user", expect.any(Object), 0, 50); + }); + expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit"); + const pageCallsBeforeRangeChange = mockDailyActivityKeyPageCall.mock.calls.length; + fireEvent.click(screen.getByTestId("pick-a-different-range")); + + await waitFor(() => { + expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeRangeChange); + }); + expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit"); }); it("should fall back to the spend-derived count when the gateway endpoint is unavailable", async () => { @@ -793,7 +843,7 @@ describe("UsagePage", () => { it.each(["organization", "agent"])("should not render the %s usage view for an internal user", async (usageView) => { mockUseAuthorized.mockReturnValue(nonAdminSession); - + mockDailyActivityKeyPageCall.mockImplementation(() => new Promise(() => {})); renderWithProviders(); await waitFor(() => { @@ -924,10 +974,7 @@ describe("UsagePage", () => { // Initially called with null (global view for admin) expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - null, + expect.objectContaining({ accessToken: "test-token", entityIds: null }), ); }); }); @@ -1016,127 +1063,23 @@ describe("UsagePage", () => { await waitFor(() => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( - "test-token", - expect.any(Date), - expect.any(Date), - "user-123", + expect.objectContaining({ accessToken: "test-token", entityIds: ["user-123"] }), ); }); }); }); - describe("aggregated endpoint fallback", () => { - it("should fall back to paginated calls when aggregated endpoint fails", async () => { + describe("aggregated endpoint failure", () => { + it("shows the failure alert instead of retrying other routes when the aggregated call fails", async () => { mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available")); - mockUserDailyActivityCall.mockResolvedValue({ - ...mockSpendData, - metadata: { - ...mockSpendData.metadata, - total_pages: 1, - page: 1, - }, - }); renderWithProviders(); await waitFor(() => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); - expect(mockUserDailyActivityCall).toHaveBeenCalled(); }); - - // Should still render the data from the paginated fallback, which lands a render after the call - expect(await screen.findByText("75,000")).toBeInTheDocument(); - }); - - it("should stop showing the previous range's paginated pages while a new range is in flight", async () => { - // Same rule as the aggregate, one fallback further down. The flag that - // decides whether these pages are read belongs to the range the failure - // happened on, or the previous range's pages reach the tile through it. - let releaseSecondAggregated: () => void = () => {}; - mockUserDailyActivityAggregatedCall.mockReset(); - mockUserDailyActivityAggregatedCall - .mockRejectedValueOnce(new Error("Aggregated endpoint not available")) - .mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - releaseSecondAggregated = () => reject(new Error("Aggregated endpoint not available")); - }), - ); - mockUserDailyActivityCall.mockResolvedValue({ - ...mockSpendData, - metadata: { ...mockSpendData.metadata, total_pages: 1, page: 1 }, - }); - - renderWithProviders(); - await waitFor(() => { - expect(screen.getAllByText("75,000").length).toBeGreaterThan(0); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("pick-a-different-range")); - }); - - await waitFor(() => { - expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); - }); - expect(screen.queryByText("75,000")).not.toBeInTheDocument(); - - await act(async () => { - releaseSecondAggregated(); - }); - await waitFor(() => { - expect(screen.getAllByText("75,000").length).toBeGreaterThan(0); - }); - }); - - it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => { - mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available")); - - const page1Data = { - results: [mockSpendData.results[0]], - metadata: { - total_spend: 60, - total_api_requests: 700, - total_successful_requests: 680, - total_failed_requests: 20, - total_tokens: 35000, - total_pages: 2, - page: 1, - }, - }; - - const page2Data = { - results: [ - { - ...mockSpendData.results[0], - date: "2025-01-02", - }, - ], - metadata: { - total_spend: 65.75, - total_api_requests: 800, - total_successful_requests: 770, - total_failed_requests: 30, - total_tokens: 40000, - total_pages: 2, - page: 2, - }, - }; - - mockUserDailyActivityCall.mockResolvedValueOnce(page1Data).mockResolvedValueOnce(page2Data); - - renderWithProviders(); - - await waitFor(() => { - // Both pages should have been fetched - expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2); - }); - - // Verify first page call - expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null); - - // Verify second page call - expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 2, null); + expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "user")).toHaveLength(1); + expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 018dfc84740..6f18b2fe1d2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -8,15 +8,15 @@ import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BarChart } from "@/components/shared/charts"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; -import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Button } from "@/components/ui/button"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Skeleton } from "@/components/ui/skeleton"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; @@ -30,23 +30,23 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; -import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; -import { - gatewayDailyActivityCall, - Organization, - tagListCall, - userDailyActivityAggregatedCall, - userDailyActivityCall, -} from "@/components/networking"; +import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { ChartLoader } from "@/components/shared/chart_loader"; import { Tag } from "@/components/tag_management/types"; import UserAgentActivity from "@/components/user_agent_activity"; import ViewUserSpend from "@/components/view_user_spend"; -import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity"; -import { DailyData, MetricWithMetadata } from "@/components/UsagePage/types"; +import { useAggregatedDailyActivity } from "../hooks/useAggregatedDailyActivity"; +import { ENTITY_API } from "./EntityUsage/entityFetchFns"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + toDailyData, + type DailyActivityRequest, +} from "@/components/UsagePage/dailyActivityApi"; +import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData"; +import { MetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import { fetchedRangeKey, @@ -72,18 +72,11 @@ interface UsagePageProps { organizations: Organization[]; } +const MetricValue = ({ pending, className, children }: { pending: boolean; className: string; children: ReactNode }) => + pending ? :

{children}

; + const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); - // Aggregated endpoint: try first, fall back to paginated if unavailable - const [aggregatedData, setAggregatedData] = useState | null>(null); - // Stamped like the data itself: the flag decides whether the paginated - // fallback is read, and a flag left over from the previous range would let - // that fallback's own leftover rows through. - const [aggregatedFailure, setAggregatedFailure] = useState | null>(null); - const [aggregatedLoading, setAggregatedLoading] = useState(false); const [gatewayActivityData, setGatewayActivityData] = useState(null); // Separate loading states for better UX @@ -142,7 +135,6 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); - // Stamped and selected during render like the request tiles below: the tag // filter reads "no tags" from an empty list, so a list left over from the // previous range would state that about a range nobody has measured yet. @@ -181,30 +173,29 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // can paint them. One source is not enough, since the tiles read the gateway // counts, fall through to the aggregate, and fall through again to the // paginated pages, so a stamp on any one of them is escaped by the next. - const currentAggregatedRangeKey = fetchedRangeKey(startTime, endTime, effectiveUserId); const currentGatewayRangeKey = fetchedRangeKey(startTime, endTime); - // Try aggregated endpoint first, fall back to paginated on failure - const aggregatedFetchIdRef = useRef(0); - useEffect(() => { - if (!accessToken || !startTime || !endTime) return; - const fetchId = ++aggregatedFetchIdRef.current; - const rangeKey = currentAggregatedRangeKey; - setAggregatedLoading(true); - - userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId) - .then((data) => { - if (aggregatedFetchIdRef.current !== fetchId) return; - setAggregatedData({ rangeKey, value: data }); - setAggregatedLoading(false); - setIsDateChanging(false); - }) - .catch(() => { - if (aggregatedFetchIdRef.current !== fetchId) return; - setAggregatedFailure({ rangeKey, value: true }); - setAggregatedLoading(false); - }); - }, [accessToken, startTime, endTime, effectiveUserId, currentAggregatedRangeKey]); + const dailyActivityRequest = useMemo( + () => + accessToken && startTime && endTime + ? { + accessToken, + startTime, + endTime, + entityIds: effectiveUserId ? [effectiveUserId] : null, + } + : null, + [accessToken, startTime, endTime, effectiveUserId], + ); + const { + data: aggregatedRaw, + loading: aggregatedLoading, + failed: aggregatedFailed, + } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.user.aggregated(dailyActivityRequest as DailyActivityRequest), + enabled: dailyActivityRequest !== null, + deps: [accessToken, startTime, endTime, effectiveUserId], + }); // Gateway request counts (SGR). Admin-only: the source table is // deployment-wide, so a non-admin must not see it. @@ -228,43 +219,28 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }, [isAdmin, gatewayRequest, currentGatewayRangeKey]); const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey); - const activeAggregated = selectForRange(aggregatedData, currentAggregatedRangeKey); - // A failure belongs to the range it happened on. Reading it through the same - // rule keeps the paginated hook disabled while a new range is in flight, and - // disabled is what empties it, so its previous rows never reach a tile. - const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true; - // Paginated fallback — only enabled when aggregated endpoint fails - const paginatedResult = usePaginatedDailyActivity({ - fetchFn: userDailyActivityCall, - args: [accessToken, startTime, endTime, effectiveUserId], - enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime, - }); + const userSpendData = useMemo( + () => ({ + results: toDailyData(aggregatedRaw), + metadata: aggregatedRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + }), + [aggregatedRaw], + ); - // Derive userSpendData from whichever source is active - const userSpendData = useMemo(() => { - if (activeAggregated) return activeAggregated; - if (aggregatedFailed) return paginatedResult.data; - return { results: [] as DailyData[], metadata: {} as any }; - }, [activeAggregated, aggregatedFailed, paginatedResult.data]); + const loading = aggregatedLoading; + const requestCountsPending = loading && gatewayActivity === null; - const loading = aggregatedLoading || paginatedResult.loading; + const summaryMetrics = useMemo( + () => overallUsageMetrics(userSpendData.results, userSpendData.metadata), + [userSpendData], + ); - // Read through the same range stamp as the tiles, so the export is blocked from the first - // render of a new range rather than from whenever the fetch effect gets around to running. - const spendFetchState = { - coversRange: activeAggregated !== null || paginatedResult.coversRange, - cancelled: paginatedResult.cancelled, - failed: paginatedResult.failed, - }; - const exportBlockedReason = getExportBlockedReason(spendFetchState); - - // Clear isDateChanging when paginated data starts arriving useEffect(() => { - if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) { + if (!loading) { setIsDateChanging(false); } - }, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]); + }, [loading]); // Super responsive date change handler const handleDateChange = useCallback((newValue: DateRangePickerValue) => { @@ -432,12 +408,43 @@ const UsagePage: React.FC = ({ teams, organizations }) => { () => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams), [userSpendData, modelViewType, teams], ); - const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]); const mcpServerMetrics = useMemo( () => processActivityData(userSpendData, "mcp_servers", teams), [userSpendData, teams], ); + const fetchTopApiKeys = useCallback( + (model: string) => + ENTITY_API.user.modelTopKeys(dailyActivityRequest as DailyActivityRequest, model, modelViewType === "groups"), + [dailyActivityRequest, modelViewType], + ); + const searchKeys = useCallback( + (query: string) => + dailyActivityRequest === null + ? Promise.resolve({ api_keys: [] }) + : ENTITY_API.user.searchKeys(dailyActivityRequest, query), + [dailyActivityRequest], + ); + const fetchKeyPage = useCallback( + (offset: number, limit: number) => { + if (dailyActivityRequest === null) { + const emptyPage = { api_keys: [], total_api_keys: 0, offset, limit }; + return Promise.resolve(emptyPage); + } + return ENTITY_API.user.keyPage(dailyActivityRequest, offset, limit); + }, + [dailyActivityRequest], + ); + const fetchKeyDetail = useCallback( + (apiKey: string) => + dailyActivityRequest === null + ? Promise.resolve(undefined) + : ENTITY_API.user + .aggregated({ ...dailyActivityRequest, apiKey, apiKeyLimit: 1 }) + .then((response) => keyDetailFromResponse(response, apiKey, teams)), + [dailyActivityRequest, teams], + ); + return (
{/* Global Date Picker and Tabs - Single Row */} @@ -453,13 +460,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { />
- + {aggregatedFailed && ( + + + Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + + )} {/* Your Usage / Global Usage Panel */} {(usageView === "global" || usageView === "my-usage") && ( <> @@ -493,16 +501,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { Ask AI - - - +
{/* Cost Panel */} @@ -532,11 +534,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

- + {!loading && ( + + )}
@@ -547,12 +551,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

Total Requests

-

+ {(gatewayActivity ? gatewayActivity.total_successful_requests + gatewayActivity.total_failed_requests : userSpendData.metadata?.total_api_requests )?.toLocaleString() || 0} -

+
@@ -577,12 +581,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { today: a non-admin (who may not read deployment-wide counts) and an admin on a proxy whose table is still backfilling. */} -

+ {( gatewayActivity?.total_successful_requests ?? userSpendData.metadata?.total_successful_requests )?.toLocaleString() || 0} -

+
@@ -602,24 +609,27 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
{/* Same source as Successful Requests: the two must agree, or the tile disagrees with the endpoint breakdown chart below it. */} -

+ {( gatewayActivity?.total_failed_requests ?? userSpendData.metadata?.total_failed_requests )?.toLocaleString() || 0} -

+

Average Cost per Request

-

+ $ {formatNumberWithCommas( (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), 4, )} -

+
= ({ teams, organizations }) => { )} -

+ {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} -

+
@@ -646,33 +656,33 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

Input Tokens

-

+ {(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()} -

+

Output Tokens

-

+ {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} -

+

Cache Read Tokens

-

+ {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} -

+

Cache Write Tokens

-

+ {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} -

+
@@ -858,10 +868,20 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
- + - + @@ -1004,11 +1024,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setIsGlobalExportModalOpen(false)} - entityType="team" - spendData={{ - results: userSpendData.results, - metadata: userSpendData.metadata, - }} + entityType="user" + onExport={(exportType, format) => + dailyActivityRequest + ? ENTITY_API.user.exportRows(dailyActivityRequest, exportType, format) + : Promise.reject(new Error("Missing access token or date range")) + } dateRange={dateValue} selectedFilters={[]} customTitle="Export Usage Data" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts new file mode 100644 index 00000000000..16a45d03db9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts @@ -0,0 +1,71 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + EMPTY_DAILY_ACTIVITY_METADATA, + type DailyActivityAggregatedResponse, +} from "@/components/UsagePage/dailyActivityApi"; +import { useAggregatedDailyActivity } from "./useAggregatedDailyActivity"; + +const response = (spend: number): DailyActivityAggregatedResponse => ({ + results: [], + metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: spend }, +}); + +describe("useAggregatedDailyActivity", () => { + it("calls fetch once per deps change and exposes the data", async () => { + const fetch = vi.fn().mockResolvedValue(response(12)); + const { result, rerender } = renderHook( + ({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }), + { initialProps: { dep: "a" } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.current.data.metadata?.total_spend).toBe(12); + + rerender({ dep: "b" }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + }); + + it("does not fetch while disabled and returns empty data", () => { + const fetch = vi.fn(); + const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: false, deps: ["a"] })); + + expect(fetch).not.toHaveBeenCalled(); + expect(result.current.data.results).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.failed).toBe(false); + }); + + it("exposes failed on rejection", async () => { + const fetch = vi.fn().mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: true, deps: ["a"] })); + + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.loading).toBe(false); + }); + + it("ignores an out-of-order resolution from the previous deps", async () => { + let resolveFirst: ((value: DailyActivityAggregatedResponse) => void) | undefined; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const fetch = vi + .fn() + .mockImplementationOnce(() => first) + .mockResolvedValueOnce(response(99)); + + const { result, rerender } = renderHook( + ({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }), + { initialProps: { dep: "a" } }, + ); + + rerender({ dep: "b" }); + await act(async () => { + resolveFirst?.(response(1)); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data.metadata?.total_spend).toBe(99); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts new file mode 100644 index 00000000000..08aec2e77f2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from "react"; +import { + EMPTY_DAILY_ACTIVITY_RESPONSE, + type DailyActivityAggregatedResponse, +} from "@/components/UsagePage/dailyActivityApi"; + +interface Options { + fetch: () => Promise; + enabled: boolean; + deps: readonly unknown[]; +} + +interface Result { + data: DailyActivityAggregatedResponse; + loading: boolean; + failed: boolean; +} + +interface SettledFetch { + key: string; + data: DailyActivityAggregatedResponse; + failed: boolean; +} + +export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): Result { + const [settled, setSettled] = useState(null); + const requestIdRef = useRef(0); + const fetchRef = useRef(fetch); + + useEffect(() => { + fetchRef.current = fetch; + }); + + const depsKey = JSON.stringify(deps); + + useEffect(() => { + if (!enabled) return; + + const requestId = ++requestIdRef.current; + const isStale = () => requestIdRef.current !== requestId; + + fetchRef + .current() + .then((response) => { + if (isStale()) return; + setSettled({ key: depsKey, data: response, failed: false }); + }) + .catch((error) => { + if (isStale()) return; + console.error("Error fetching daily activity:", error); + setSettled({ key: depsKey, data: EMPTY_DAILY_ACTIVITY_RESPONSE, failed: true }); + }); + + return () => { + requestIdRef.current++; + }; + }, [enabled, depsKey]); + + const current = enabled && settled?.key === depsKey ? settled : null; + return { + data: current?.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE, + loading: enabled && current === null, + failed: current?.failed ?? false, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts deleted file mode 100644 index 0b8cfecbfc6..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; -import { mergeDailyResults, sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity"; - -describe("sumMetadata", () => { - it("sums flat cost across pages instead of keeping the first page's value", () => { - // A team whose activity spans more than one page accrues flat cost on each of them. - // Keeping page 1's value under-reports the Flat Cost and Total Cost tiles. - const merged = sumMetadata({ total_spend: 1, total_flat_cost: 174.5 }, { total_spend: 2, total_flat_cost: 777 }); - - expect(merged.total_flat_cost).toBe(951.5); - expect(merged.total_spend).toBe(3); - }); - - it("treats a page missing the field as zero rather than dropping the running total", () => { - expect(sumMetadata({ total_flat_cost: 480 }, {}).total_flat_cost).toBe(480); - expect(sumMetadata({}, { total_flat_cost: 480 }).total_flat_cost).toBe(480); - }); - - it("carries non-summable keys through from the first page", () => { - const merged = sumMetadata( - { page: 1, total_pages: 3, total_spend: 1 }, - { page: 2, total_pages: 3, total_spend: 2 }, - ); - - expect(merged.page).toBe(1); - expect(merged.total_pages).toBe(3); - }); - - it("sums every total_* metric the daily activity metadata exposes", () => { - // Guards the class of bug rather than one field: a new backend total that nobody adds - // to SUMMABLE_METADATA_KEYS freezes at page 1, and spend still looks right so it reads - // as trustworthy. - const page = { - total_spend: 1, - total_prompt_tokens: 1, - total_completion_tokens: 1, - total_tokens: 1, - total_api_requests: 1, - total_successful_requests: 1, - total_failed_requests: 1, - total_cache_read_input_tokens: 1, - total_cache_creation_input_tokens: 1, - total_flat_cost: 1, - total_response_time_ms: 1, - total_timed_requests: 1, - }; - const merged = sumMetadata(page, page); - - for (const key of Object.keys(page)) { - expect(merged[key], `${key} must be summed across pages`).toBe(2); - } - }); -}); - -const metricsOf = (spend: number): SpendMetrics => ({ - spend, - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - api_requests: 1, - successful_requests: 1, - failed_requests: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - compression_savings_spend: spend, -}); - -const dayOf = (date: string, spend: number, apiKey: string = "sk-1"): DailyData => ({ - date, - metrics: metricsOf(spend), - breakdown: { - models: { - "gpt-4o": { - metrics: metricsOf(spend), - metadata: {}, - api_key_breakdown: { - [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } }, - }, - }, - }, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: { [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } } }, - entities: {}, - }, -}); - -describe("mergeDailyResults", () => { - it("collapses repeated dates into one entry with summed metrics (the LIT-5818 $2/$2/$1 case)", () => { - const merged = mergeDailyResults(mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-16", 2)]), [ - dayOf("2026-08-16", 1), - ]); - - expect(merged).toHaveLength(1); - expect(merged[0].metrics.spend).toBe(5); - expect(merged[0].metrics.compression_savings_spend).toBe(5); - }); - - it("appends unseen dates in arrival order", () => { - const merged = mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-15", 0.5)]); - - expect(merged.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]); - expect(merged[1].metrics.spend).toBe(0.5); - }); - - it("merges every breakdown level including the nested per-key breakdown", () => { - const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-1")]); - - expect(merged[0].breakdown.models["gpt-4o"].metrics.spend).toBe(5); - expect(merged[0].breakdown.models["gpt-4o"].api_key_breakdown["sk-1"].metrics.spend).toBe(5); - expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(5); - expect(merged[0].breakdown.api_keys["sk-1"].metadata.key_alias).toBe("alias-1"); - }); - - it("unions breakdown keys that appear on different pages", () => { - const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-2")]); - - expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(2); - expect(merged[0].breakdown.api_keys["sk-2"].metrics.spend).toBe(3); - }); - - it("sums metric keys it has never heard of so a future backend column cannot silently freeze", () => { - const withExtra = (spend: number): DailyData => ({ - ...dayOf("2026-08-16", spend), - metrics: { ...metricsOf(spend), future_savings_spend: spend } as SpendMetrics, - }); - const merged = mergeDailyResults([withExtra(2)], [withExtra(3)]); - - expect((merged[0].metrics as Record).future_savings_spend).toBe(5); - }); -}); - -describe("usePaginatedDailyActivity page accumulation", () => { - it("returns one entry per date when a date's rows span multiple pages", async () => { - const pages = [ - { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } }, - { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 2, total_spend: 2 } }, - { - results: [dayOf("2026-08-16", 1), dayOf("2026-08-15", 0.5)], - metadata: { total_pages: 3, page: 3, total_spend: 1.5 }, - }, - ]; - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1])); - const start = new Date("2026-08-10"); - const end = new Date("2026-08-17"); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.data.metadata.page).toBe(3), { timeout: 5000 }); - - expect(result.current.data.results.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]); - expect(result.current.data.results[0].metrics.spend).toBe(5); - expect(result.current.data.metadata.total_spend).toBe(5.5); - }); -}); - -describe("usePaginatedDailyActivity failure reporting", () => { - const firstPage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } }; - const start = new Date("2026-08-10"); - const end = new Date("2026-08-17"); - - it("reports a failed range so partial totals cannot pass as the whole range", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => - page === 1 ? Promise.resolve(firstPage) : Promise.reject(new Error("page 2 never came back")), - ); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); - - expect(result.current.isFetchingMore).toBe(false); - expect(result.current.loading).toBe(false); - expect(result.current.data.metadata.total_spend).toBe(2); - consoleError.mockRestore(); - }); - - it("reports no pages loaded when the very first request is what failed", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const fetchFn = vi.fn(() => Promise.reject(new Error("page 1 never came back"))); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); - - expect(result.current.progress).toEqual({ currentPage: 0, totalPages: 0 }); - consoleError.mockRestore(); - }); - - it("stays unfailed when every page arrives", async () => { - const pages = [ - firstPage, - { results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } }, - ]; - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => - Promise.resolve({ ...pages[page - 1], metadata: { ...pages[page - 1].metadata, total_pages: 2 } }), - ); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - await waitFor(() => expect(result.current.data.metadata.page).toBe(2), { timeout: 5000 }); - - expect(result.current.failed).toBe(false); - }); - - it("clears the failure when a new range is requested, so the banner cannot outlive it", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const fetchFn = vi.fn((...callArgs: unknown[]) => { - const [, , , page, filter] = callArgs as [string, Date, Date, number, string | null]; - if (filter !== "broken") - return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 1 } }); - if (page === 1) return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 2 } }); - return Promise.reject(new Error("page 2 never came back")); - }); - - const { result, rerender } = renderHook( - ({ filter }: { filter: string | null }) => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }), - { initialProps: { filter: "broken" as string | null } }, - ); - - await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); - - rerender({ filter: "healthy" }); - - await waitFor(() => expect(result.current.failed).toBe(false), { timeout: 5000 }); - consoleError.mockRestore(); - }); -}); - -describe("usePaginatedDailyActivity range coverage", () => { - const start = new Date("2026-08-10"); - const end = new Date("2026-08-17"); - const singlePage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 1, page: 1, total_spend: 2 } }; - - it("does not cover the range while the hook is disabled", () => { - const fetchFn = vi.fn(() => Promise.resolve(singlePage)); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: false }), - ); - - expect(result.current.coversRange).toBe(false); - expect(fetchFn).not.toHaveBeenCalled(); - }); - - it("covers the range only once every page of it has landed", async () => { - const pages = [ - { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 2, page: 1, total_spend: 2 } }, - { results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } }, - ]; - const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1])); - - const { result } = renderHook(() => - usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), - ); - - expect(result.current.coversRange).toBe(false); - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - }); - - it("never reports a range as covered while the data on screen is empty", async () => { - // Disabling the hook empties the data. Re-enabling it asks for the same args the last - // completed fetch used, so coverage that survives the disable would vouch for nothing. - const seen: Array<{ coversRange: boolean; rows: number }> = []; - const fetchFn = vi.fn(() => Promise.resolve(singlePage)); - - const { result, rerender } = renderHook( - ({ enabled }: { enabled: boolean }) => { - const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled }); - seen.push({ coversRange: activity.coversRange, rows: activity.data.results.length }); - return activity; - }, - { initialProps: { enabled: true } }, - ); - - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - - rerender({ enabled: false }); - rerender({ enabled: true }); - - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - expect(seen.filter((render) => render.coversRange && render.rows === 0)).toEqual([]); - }); - - it("stops covering the range on the very render the args change, not once an effect catches up", async () => { - // The render after a filter change still holds the previous filter's rows, so resetting - // coverage inside the fetch effect would leave a paint where the export reads them as the - // new range. That paint is the whole thing the gate exists to stop. - const seen: Array<{ filter: string; coversRange: boolean }> = []; - const fetchFn = vi.fn(() => Promise.resolve(singlePage)); - - const { result, rerender } = renderHook( - ({ filter }: { filter: string }) => { - const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }); - seen.push({ filter, coversRange: activity.coversRange }); - return activity; - }, - { initialProps: { filter: "team-a" } }, - ); - - await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); - - rerender({ filter: "team-b" }); - - const rendersForNewFilter = seen.filter((render) => render.filter === "team-b"); - expect(rendersForNewFilter.length).toBeGreaterThan(0); - expect(rendersForNewFilter.map((render) => render.coversRange)).not.toContain(true); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts deleted file mode 100644 index 1f03f6a4fcb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - BreakdownMetrics, - DailyData, - KeyMetricWithMetadata, - MetricWithMetadata, - SpendMetrics, -} from "@/components/UsagePage/types"; - -export interface PaginationProgress { - currentPage: number; - totalPages: number; -} - -/** Delay between sequential page fetches (ms) to avoid overloading the backend. */ -const PAGE_FETCH_DELAY_MS = 300; - -/** Number of pages to accumulate before flushing to React state (reduces re-renders). */ -const RENDER_BATCH_SIZE = 3; - -/** The metadata fields returned by the daily activity API that should be summed across pages. */ -const SUMMABLE_METADATA_KEYS = [ - "total_spend", - "total_prompt_tokens", - "total_completion_tokens", - "total_tokens", - "total_api_requests", - "total_successful_requests", - "total_failed_requests", - "total_cache_read_input_tokens", - "total_cache_creation_input_tokens", - "total_flat_cost", - "total_response_time_ms", - "total_timed_requests", -] as const; - -interface DailyActivityResponse { - results: DailyData[]; - metadata: Record; -} - -type FetchPageFn = (...args: any[]) => Promise; - -interface UsePaginatedDailyActivityParams { - /** The API call function (e.g., userDailyActivityCall). */ - fetchFn: FetchPageFn; - /** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */ - args: any[]; - /** Whether the hook should fetch. Set to false to disable. */ - enabled: boolean; - /** - * Optional single-shot endpoint returning the whole range at once (e.g. - * teamDailyActivityAggregatedCall). Called with `args` as-is (no page). - * On success pagination is skipped entirely; on failure the hook falls - * back to the paginated flow. - */ - aggregatedFetchFn?: (...args: any[]) => Promise; -} - -interface UsePaginatedDailyActivityReturn { - data: DailyActivityResponse; - loading: boolean; - isFetchingMore: boolean; - progress: PaginationProgress; - cancelled: boolean; - failed: boolean; - coversRange: boolean; - cancel: () => void; -} - -const EMPTY_DATA: DailyActivityResponse = { - results: [], - metadata: { - total_spend: 0, - total_prompt_tokens: 0, - total_completion_tokens: 0, - total_tokens: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_cache_read_input_tokens: 0, - total_cache_creation_input_tokens: 0, - total_response_time_ms: 0, - total_timed_requests: 0, - total_pages: 1, - has_more: false, - page: 1, - }, -}; - -/** - * Combine two pages of metadata. Only keys in SUMMABLE_METADATA_KEYS are added; anything - * else keeps the first page's value, so a total the backend adds later is silently frozen - * at page 1 until it is listed above. Exported so that contract can be tested directly. - */ -export function sumMetadata(a: Record, b: Record): Record { - const result = { ...a }; - for (const key of SUMMABLE_METADATA_KEYS) { - result[key] = (a[key] || 0) + (b[key] || 0); - } - return result; -} - -/** - * Sum the union of numeric metric keys so a metric column added to the backend - * later is summed automatically instead of silently frozen at one page's value - * (the drift hazard SUMMABLE_METADATA_KEYS documents above). - */ -const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics => - Object.fromEntries( - Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).map((key) => { - const left = a[key as keyof SpendMetrics]; - const right = b[key as keyof SpendMetrics]; - if (typeof left !== "number" && typeof right !== "number") return [key, left ?? right]; - return [key, (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0)]; - }), - ) as unknown as SpendMetrics; - -const mergeBucketMaps = ( - a: Record | undefined, - b: Record | undefined, - mergeEntry: (left: T, right: T) => T, -): Record => { - const left = a ?? {}; - const right = b ?? {}; - return Object.fromEntries( - Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => { - const leftEntry = left[key]; - const rightEntry = right[key]; - if (leftEntry === undefined) return [key, rightEntry]; - if (rightEntry === undefined) return [key, leftEntry]; - return [key, mergeEntry(leftEntry, rightEntry)]; - }), - ); -}; - -const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({ - ...a, - metrics: addMetrics(a.metrics, b.metrics), -}); - -const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({ - ...a, - metrics: addMetrics(a.metrics, b.metrics), - api_key_breakdown: mergeBucketMaps(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric), -}); - -const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({ - models: mergeBucketMaps(a.models, b.models, mergeMetricWithMetadata), - model_groups: mergeBucketMaps(a.model_groups, b.model_groups, mergeMetricWithMetadata), - mcp_servers: mergeBucketMaps(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata), - providers: mergeBucketMaps(a.providers, b.providers, mergeMetricWithMetadata), - api_keys: mergeBucketMaps(a.api_keys, b.api_keys, mergeKeyMetric), - entities: mergeBucketMaps(a.entities, b.entities, mergeMetricWithMetadata), - ...(a.endpoints || b.endpoints - ? { endpoints: mergeBucketMaps(a.endpoints, b.endpoints, mergeMetricWithMetadata) } - : {}), -}); - -/** - * The backend paginates over raw rows and re-groups per page, so a date whose - * rows span pages arrives as one partial DailyData per page. Merge by date so - * consumers never see the same date twice (LIT-5818: each day rendered as N - * partial bars). Exported so the contract can be tested directly. - */ -export function mergeDailyResults(existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] { - return incoming.reduce( - (acc, day) => { - const index = acc.findIndex((existingDay) => existingDay.date === day.date); - if (index === -1) return [...acc, day]; - return acc.map((existingDay, i) => - i === index - ? { - ...existingDay, - metrics: addMetrics(existingDay.metrics, day.metrics), - breakdown: mergeBreakdown(existingDay.breakdown, day.breakdown), - } - : existingDay, - ); - }, - [...existing], - ); -} - -/** - * Hook that auto-paginates daily activity endpoints, updating state in batches - * so charts render progressively. Cancels on unmount, param changes, or - * manual cancel(). - * - * The `args` array should contain every argument the fetchFn expects EXCEPT - * the `page` parameter. The hook injects `page` as the 4th argument (index 3), - * matching the signature of all daily activity calls: - * (accessToken, startTime, endTime, page, ...rest) - */ -export function usePaginatedDailyActivity({ - fetchFn, - args, - enabled, - aggregatedFetchFn, -}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn { - const [data, setData] = useState(EMPTY_DATA); - const [loading, setLoading] = useState(false); - const [isFetchingMore, setIsFetchingMore] = useState(false); - const [progress, setProgress] = useState({ - currentPage: 0, - totalPages: 0, - }); - const [cancelled, setCancelled] = useState(false); - const [failed, setFailed] = useState(false); - const [completedKey, setCompletedKey] = useState(null); - - const fetchIdRef = useRef(0); - const cancelledRef = useRef(false); - const delayTimerRef = useRef | null>(null); - - // Keep args in a ref so the effect can always read the latest values - // without needing them in the dependency array. - const argsRef = useRef(args); - argsRef.current = args; - - // Stable serialised key so the effect only re-runs when the arg *values* change. - const argsKey = JSON.stringify(args); - - // Stamped like the data itself and compared during render, so the render that follows an arg - // change already reports the new range as uncovered. Clearing it inside the fetch effect would - // be one render too late, leaving a paint where an export reads the previous range's rows. - const coversRange = enabled && completedKey === argsKey; - - const cancel = useCallback(() => { - cancelledRef.current = true; - setCancelled(true); - setIsFetchingMore(false); - if (delayTimerRef.current !== null) { - clearTimeout(delayTimerRef.current); - delayTimerRef.current = null; - } - }, []); - - useEffect(() => { - if (!enabled) { - setData(EMPTY_DATA); - setLoading(false); - setIsFetchingMore(false); - setProgress({ currentPage: 0, totalPages: 0 }); - setCancelled(false); - setFailed(false); - setCompletedKey(null); - return; - } - - const currentFetchId = ++fetchIdRef.current; - cancelledRef.current = false; - setCancelled(false); - setFailed(false); - - const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current; - - /** Cancellable delay that clears itself on cleanup. */ - const delay = (ms: number) => - new Promise((resolve) => { - delayTimerRef.current = setTimeout(() => { - delayTimerRef.current = null; - resolve(); - }, ms); - }); - - const run = async () => { - const currentArgs = argsRef.current; - setLoading(true); - setIsFetchingMore(false); - setProgress({ currentPage: 0, totalPages: 0 }); - - if (aggregatedFetchFn) { - try { - const aggregated = await aggregatedFetchFn(...currentArgs); - if (isStale()) return; - setData(aggregated); - setProgress({ currentPage: 1, totalPages: 1 }); - setLoading(false); - setCompletedKey(argsKey); - return; - } catch (error) { - if (isStale()) return; - console.error("Aggregated daily activity failed, falling back to pagination:", error); - } - } - - try { - // Inject page=1 as the 4th argument. - const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)]; - const firstPage = await fetchFn(...argsWithPage); - - if (isStale()) return; - - setData(firstPage); - - const totalPages = firstPage.metadata?.total_pages || 1; - - setProgress({ currentPage: 1, totalPages }); - - if (totalPages <= 1) { - setLoading(false); - setCompletedKey(argsKey); - return; - } - - // More pages — start fetching sequentially. - setLoading(false); - setIsFetchingMore(true); - - let accumulatedResults = mergeDailyResults([], firstPage.results); - let accumulatedMetadata = { ...firstPage.metadata }; - - for (let page = 2; page <= totalPages; page++) { - if (isStale()) return; - - // Small delay to avoid overwhelming the backend. - await delay(PAGE_FETCH_DELAY_MS); - - if (isStale()) return; - - const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)]; - const pageData = await fetchFn(...argsForPage); - - if (isStale()) return; - - accumulatedResults = mergeDailyResults(accumulatedResults, pageData.results); - accumulatedMetadata = sumMetadata(accumulatedMetadata, pageData.metadata); - accumulatedMetadata.total_pages = totalPages; - accumulatedMetadata.has_more = page < totalPages; - accumulatedMetadata.page = page; - - // Flush accumulated data and progress to React state every - // RENDER_BATCH_SIZE pages (or on the final page) to avoid - // expensive per-page re-renders. Progress and data are updated - // together so the counter never appears to decrement. - const isLastPage = page === totalPages; - const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0; - if (isLastPage || isBatchBoundary) { - setData({ - results: accumulatedResults, - metadata: accumulatedMetadata, - }); - setProgress({ currentPage: page, totalPages }); - } - } - - setIsFetchingMore(false); - setCompletedKey(argsKey); - } catch (error) { - if (!isStale()) { - console.error("Error fetching daily activity:", error); - setLoading(false); - setIsFetchingMore(false); - setFailed(true); - } - } - }; - - run(); - - return () => { - fetchIdRef.current++; - if (delayTimerRef.current !== null) { - clearTimeout(delayTimerRef.current); - delayTimerRef.current = null; - } - }; - // argsKey is a stable JSON string so the effect only re-fires when arg values change. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, fetchFn, aggregatedFetchFn, argsKey]); - - return { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx index 6a0e55a6dda..676bc7d29eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx @@ -21,7 +21,6 @@ const mockTeamInfoCall = vi.fn(); const mockUserUpdateUserCall = vi.fn(); const mockFetchMCPServers = vi.fn(); const mockListMCPTools = vi.fn(); -const mockUserDailyActivityCall = vi.fn(); const mockUserDailyActivityAggregatedCall = vi.fn(); const MCP_SERVER = { server_id: "srv-1", server_name: "GitHub MCP", alias: "GitHub MCP" }; @@ -65,8 +64,7 @@ vi.mock("@/components/networking", async (importOriginal) => { formatDate: original.formatDate, serverRootPath: "/", userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args), - userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args), - userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), + dailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), userDeleteCall: vi.fn(), userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args), modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), @@ -458,7 +456,6 @@ describe("UserInfoView savings", () => { Promise.resolve({ ...MOCK_USER_DATA_NO_TEAMS, user_id: userId }), ); mockUserDailyActivityAggregatedCall.mockReset().mockResolvedValue(savingsResponse([])); - mockUserDailyActivityCall.mockReset().mockResolvedValue(savingsResponse([])); }); afterEach(() => { @@ -472,17 +469,18 @@ describe("UserInfoView savings", () => { const { rerender } = render(); await user.click(await screen.findByRole("tab", { name: "Savings" })); expect(await screen.findByText("No usage recorded for this user in this range.")).toBeInTheDocument(); - expect(mockUserDailyActivityAggregatedCall.mock.calls[0][3]).toBe("user-1"); + expect(mockUserDailyActivityAggregatedCall.mock.calls[0]).toEqual([ + "user", + expect.objectContaining({ entityIds: ["user-1"] }), + ]); mockUserDailyActivityAggregatedCall.mockClear(); - mockUserDailyActivityCall.mockClear(); rerender(); await screen.findAllByText("another-user"); expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true"); expect(screen.queryByRole("tab", { name: "Savings" })).not.toBeInTheDocument(); expect(screen.queryByText("No usage recorded for this user in this range.")).not.toBeInTheDocument(); expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); }, ); @@ -506,7 +504,6 @@ describe("UserInfoView savings", () => { render(); const savingsTab = await screen.findByRole("tab", { name: "Savings" }); expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); await user.click(savingsTab); @@ -516,12 +513,12 @@ describe("UserInfoView savings", () => { expect(screen.getByTestId("summary-card-prompt-caching-savings")).toHaveTextContent("$1.00Total"); expect(screen.getByTestId("summary-card-auto-router-savings")).toHaveTextContent("-$3.00"); expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledExactlyOnceWith( - "admin-token", - expect.any(Date), - expect.any(Date), - "user-123", - true, - null, + "user", + expect.objectContaining({ + accessToken: "admin-token", + entityIds: ["user-123"], + includeCurrentUtcDay: true, + }), ); expect(screen.getByTestId("user-savings-scope-note")).toHaveTextContent("JWT-authenticated requests"); await user.click(screen.getByRole("tab", { name: "Per day" })); @@ -544,12 +541,12 @@ describe("UserInfoView savings", () => { expect(await screen.findByTestId("user-savings-empty")).toHaveTextContent("Loading savings"); expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument(); expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith( - "admin-token", - expect.any(Date), - expect.any(Date), - "user-456", - true, - null, + "user", + expect.objectContaining({ + accessToken: "admin-token", + entityIds: ["user-456"], + includeCurrentUtcDay: true, + }), ); await act(async () => { nextUser.resolve(savingsResponse([savingsDay("2026-09-19", { autorouter_savings_spend: -7 })])); @@ -596,27 +593,16 @@ describe("UserInfoView savings", () => { expect(await screen.findByTestId("summary-card-total-recorded-savings")).toHaveTextContent("-$7.00"); }); - it("reports an incomplete paginated read as unavailable instead of displaying a partial savings total", async () => { + it("reports a failed read as unavailable instead of displaying a partial savings total", async () => { mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); - mockUserDailyActivityCall - .mockResolvedValueOnce({ - results: [savingsDay("2026-09-19", { compression_savings_spend: 42 })], - metadata: { total_pages: 2, has_more: true, page: 1 }, - }) - .mockRejectedValueOnce(new Error("next page unavailable")); const user = userEvent.setup(); render(); await user.click(await screen.findByRole("tab", { name: "Savings" })); expect(await screen.findByRole("alert")).toHaveTextContent("Savings are unavailable for this range"); - expect(mockUserDailyActivityCall).toHaveBeenLastCalledWith( - "admin-token", - expect.any(Date), - expect.any(Date), - 2, - "user-123", - true, - null, + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "user", + expect.objectContaining({ entityIds: ["user-123"] }), ); expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument(); expect(screen.queryByText(/No usage recorded/)).not.toBeInTheDocument(); @@ -639,6 +625,5 @@ describe("UserInfoView savings", () => { expect(screen.getByRole("alert")).toHaveTextContent("this user has no ID"); expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled(); - expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index cb04323e4c9..375acd281af 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -1,61 +1,22 @@ -/** - * Tests for EntityUsageExportModal component - * - * Validates core export functionality: - * - Renders modal with correct default state (CSV format, daily scope) - * - User can select export type (daily vs daily_with_models) - * - User can switch format (CSV vs JSON) - * - Export button triggers data generation with correct parameters - * - Modal closes after successful export - */ - import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import { renderWithProviders } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import EntityUsageExportModal from "./EntityUsageExportModal"; -// Mock utilities that format/export data so tests stay fast and deterministic -vi.mock("./utils", () => { - return { - handleExportCSV: vi.fn(), - handleExportJSON: vi.fn(), - generateExportData: vi.fn(() => [{ Date: "2025-10-01" }]), - generateMetadata: vi.fn(() => ({ meta: true })), - }; -}); +const downloadBlob = vi.fn(); -// Mock useTeams hook -vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ - useTeams: vi.fn(() => ({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - })), +vi.mock("./utils", () => ({ + downloadBlob: (...args: unknown[]) => downloadBlob(...args), + exportFilename: vi.fn(() => "tag_usage_daily_2025-10-01_2025-10-14.csv"), })); -// JSDOM stubs for download flow used by the modal -// @ts-ignore -global.URL.createObjectURL = vi.fn(() => "blob:mock"); -// @ts-ignore -global.URL.revokeObjectURL = vi.fn(); - describe("EntityUsageExportModal", () => { const baseProps = { isOpen: true, onClose: vi.fn(), entityType: "tag" as const, - spendData: { - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }, + onExport: vi.fn().mockResolvedValue(new Blob(["data"])), dateRange: { from: new Date("2025-10-01"), to: new Date("2025-10-14") }, selectedFilters: [], customTitle: "Export Tag Usage", @@ -63,55 +24,44 @@ describe("EntityUsageExportModal", () => { beforeEach(() => { vi.clearAllMocks(); + baseProps.onExport.mockResolvedValue(new Blob(["data"])); }); - it("renders default state and exports CSV (daily) successfully", async () => { - /** - * Tests the happy path: user opens modal and exports with defaults. - * Verifies that handleExportCSV is called with correct parameters - * and modal closes after export completes. - */ + it("exports through the onExport callback with the selected type and format, then closes", async () => { const user = userEvent.setup(); - const { handleExportCSV } = await import("./utils"); renderWithProviders(); - // Default primary action reflects CSV export expect(screen.getByRole("button", { name: /Export CSV/i })).toBeInTheDocument(); - - // Click export await user.click(screen.getByRole("button", { name: /Export CSV/i })); - // Verifies export function was invoked with correct parameters - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag", {}); - - // Modal closes after export + expect(baseProps.onExport).toHaveBeenCalledWith("daily", "csv"); + expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), "tag_usage_daily_2025-10-01_2025-10-14.csv"); expect(baseProps.onClose).toHaveBeenCalled(); }); - it("exports with 'day-by-day by tag and model' scope when selected", async () => { - /** - * Tests that user can change export type (scope). - * Verifies handleExportCSV receives 'daily_with_models' scope - * when the second radio option is selected. - */ + it("forwards the export type the user picks", async () => { const user = userEvent.setup(); - const { handleExportCSV } = await import("./utils"); renderWithProviders(); - // Choose the alternate export type - click the label to trigger radio - const dailyModelLabel = screen.getByText(/Day-by-day by tag and model/i); - await user.click(dailyModelLabel); + await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by tag and key/i })); + await user.click(screen.getByRole("button", { name: /Export CSV/i })); - // Export with default CSV format - const exportBtn = screen.getByRole("button", { name: /Export CSV/i }); - await user.click(exportBtn); - - // Ensure the selected scope flowed through - expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag", {}); - - // Modal closes after export + expect(baseProps.onExport).toHaveBeenCalledWith("daily_with_keys", "csv"); expect(baseProps.onClose).toHaveBeenCalled(); }); + + it("keeps the modal open and reports the error when onExport rejects", async () => { + const user = userEvent.setup(); + baseProps.onExport.mockRejectedValueOnce(new Error("export failed")); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /Export CSV/i })); + + expect(baseProps.onExport).toHaveBeenCalled(); + expect(downloadBlob).not.toHaveBeenCalled(); + expect(baseProps.onClose).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index ec688e9fc3d..7044e3a7f31 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -1,47 +1,36 @@ -import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { createTeamAliasMap } from "@/utils/teamUtils"; import { Loader2 } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "@/lib/toast"; import ExportFormatSelector from "./ExportFormatSelector"; import ExportSummary from "./ExportSummary"; import ExportTypeSelector from "./ExportTypeSelector"; -import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types"; -import { handleExportCSV, handleExportJSON } from "./utils"; +import type { EntityUsageExportModalProps, ExportFormat, ExportType } from "./types"; +import { downloadBlob, exportFilename } from "./utils"; const EntityUsageExportModal: React.FC = ({ isOpen, onClose, entityType, - spendData, + onExport, dateRange, - selectedFilters, + selectedFilters = [], customTitle, }) => { const [exportFormat, setExportFormat] = useState("csv"); - const [exportScope, setExportScope] = useState("daily"); + const [exportType, setExportType] = useState("daily"); const [isExporting, setIsExporting] = useState(false); - const { data: teams, isLoading: isLoadingTeams } = useTeams(); const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); const modalTitle = customTitle || `Export ${entityLabel} Usage`; - // Cache team alias map using useMemo - const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); - const handleExport = async (format?: ExportFormat) => { - const formatToUse = format || exportFormat; + const handleExport = async () => { setIsExporting(true); try { - if (formatToUse === "csv") { - handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap); - toast.success(`${entityLabel} usage data exported successfully as CSV`); - } else { - handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap); - toast.success(`${entityLabel} usage data exported successfully as JSON`); - } + const blob = await onExport(exportType, exportFormat); + downloadBlob(blob, exportFilename(entityType, exportType, exportFormat, dateRange)); + toast.success(`${entityLabel} usage data exported successfully as ${exportFormat.toUpperCase()}`); onClose(); } catch (error) { console.error("Error exporting data:", error); @@ -63,36 +52,17 @@ const EntityUsageExportModal: React.FC = ({ {modalTitle}
- {isLoadingTeams ? ( -
- - - -
- ) : ( - <> - - - - - )} + + +
- {isLoadingTeams ? ( - <> - - - - ) : ( - <> - - - - )} + +
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx index 6743167bff6..997c048048f 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx @@ -1,6 +1,6 @@ import { renderWithProviders, screen } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; -import { vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import ExportTypeSelector from "./ExportTypeSelector"; describe("ExportTypeSelector", () => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx index f6fdece83a8..3162f00c056 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx @@ -1,15 +1,15 @@ import React from "react"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import type { ExportScope, EntityType } from "./types"; +import type { ExportType, EntityType } from "./types"; interface ExportTypeSelectorProps { - value: ExportScope; - onChange: (value: ExportScope) => void; + value: ExportType; + onChange: (value: ExportType) => void; entityType: EntityType; } const ExportTypeSelector: React.FC = ({ value, onChange, entityType }) => { - const allScopes: { value: ExportScope; title: string; description: string }[] = [ + const allScopes: { value: ExportType; title: string; description: string }[] = [ { value: "daily", title: `Day-by-day breakdown by ${entityType}`, @@ -36,7 +36,7 @@ const ExportTypeSelector: React.FC = ({ value, onChange return (
- onChange(next as ExportScope)} className="gap-2"> + onChange(next as ExportType)} className="gap-2"> {scopes.map((scope) => (
@@ -137,7 +133,7 @@ const UsageExportHeader: React.FC = ({ isOpen={isExportModalOpen} onClose={() => setIsExportModalOpen(false)} entityType={entityType} - spendData={spendData} + onExport={onExport} dateRange={dateValue} selectedFilters={selectedFilters} customTitle={customTitle} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts deleted file mode 100644 index e39b01a5dea..00000000000 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; - -const state = (overrides: Partial = {}): UsageFetchState => ({ - coversRange: true, - cancelled: false, - failed: false, - ...overrides, -}); - -describe("getExportBlockedReason", () => { - it("lets the export through once the data on screen covers the range", () => { - expect(getExportBlockedReason(state())).toBeUndefined(); - }); - - it("blocks whenever the data on screen does not cover the range, which is when a CSV silently under-reports", () => { - expect(getExportBlockedReason(state({ coversRange: false }))).toMatch(/still loading/i); - }); - - it("blocks after a stopped fetch and says a reload is what fixes it", () => { - const reason = getExportBlockedReason(state({ coversRange: false, cancelled: true })); - - expect(reason).toMatch(/stopped/i); - expect(reason).toMatch(/reload/i); - }); - - it("blocks after a failed page and names the failure rather than the stop", () => { - const reason = getExportBlockedReason(state({ coversRange: false, failed: true, cancelled: true })); - - expect(reason).toMatch(/failed to load/i); - expect(reason).not.toMatch(/stopped/i); - }); -}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts deleted file mode 100644 index 71408ba8f3f..00000000000 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface UsageFetchState { - coversRange: boolean; - cancelled: boolean; - failed: boolean; -} - -export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => { - if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again."; - if (cancelled) - return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all."; - if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish."; - return undefined; -}; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 15f193ecc3f..189a14392ec 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -1,67 +1,17 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import type { Team } from "@/components/key_team_helpers/key_list"; +import type { DailyActivityEntity, ExportFormat, ExportType } from "@/components/UsagePage/dailyActivityApi"; -export type ExportFormat = "csv" | "json"; -export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users"; -export type EntityType = "tag" | "team" | "organization" | "customer" | "agent" | "user"; - -export interface EntitySpendData { - results: any[]; - metadata: { - total_spend: number; - total_flat_cost?: number; - total_api_requests: number; - total_successful_requests: number; - total_failed_requests: number; - total_tokens: number; - }; -} +export type { ExportFormat, ExportType }; +export type EntityType = DailyActivityEntity; export interface EntityUsageExportModalProps { isOpen: boolean; onClose: () => void; entityType: EntityType; - spendData: EntitySpendData; + onExport: (exportType: ExportType, format: ExportFormat) => Promise; dateRange: DateRangePickerValue; - selectedFilters: string[]; + selectedFilters?: string[]; customTitle?: string; teams?: Team[]; } - -export interface ExportMetadata { - export_date: string; - entity_type: string; - date_range: { - from?: string; - to?: string; - }; - filters_applied: string[] | string; - export_scope: ExportScope; - summary: { - total_spend: number; - total_flat_cost?: number; - total_cost?: number; - total_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; - }; -} - -export interface EntityBreakdown { - metrics: { - spend: number; - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - api_requests: number; - successful_requests: number; - failed_requests: number; - cache_read_input_tokens: number; - cache_creation_input_tokens: number; - }; - metadata: { - alias: string; - id: string; - }; -} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 3f9cf58ec20..172d772e461 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1,3008 +1,51 @@ -// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import Papa from "papaparse"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EntitySpendData, ExportScope } from "./types"; -import { - generateDailyData, - generateDailyWithKeysData, - generateDailyWithModelsData, - generateDailyWithUsersData, - generateExportData, - generateMetadata, - getEntityBreakdown, - handleExportCSV, - handleExportJSON, - resolveEntities, -} from "./utils"; +import { downloadBlob, exportFilename } from "./utils"; -vi.mock("@/utils/dataUtils", () => ({ - formatNumberWithCommas: vi.fn((value: number, decimals: number = 0) => { - if (value === null || value === undefined || !Number.isFinite(value)) { - return "-"; - } - return value.toFixed(decimals); - }), -})); +describe("exportFilename", () => { + it("composes entity, export type, range and format", () => { + const name = exportFilename("team", "daily_with_keys", "csv", { + from: new Date(2025, 0, 5), + to: new Date(2025, 0, 31), + }); -vi.mock("papaparse", () => ({ - default: { - unparse: vi.fn((data: any[]) => "mocked-csv-data"), - }, -})); - -describe("EntityUsageExport utils", () => { - // Entity keys match team_ids because that's how the backend shapes team exports - // (breakdown.entities is keyed by team_id). The fix under test uses the entity key - // directly for display, so the key_alias/team_id in api_key_breakdown metadata is - // no longer consulted — it's retained here only to mirror real payload shape. - const mockSpendData: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - cache_read_input_tokens: 100, - cache_creation_input_tokens: 60, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - }, - metadata: { - team_id: "team-2", - key_alias: "alias-2", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - cache_read_input_tokens: 75, - cache_creation_input_tokens: 45, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 46.0, - total_api_requests: 450, - total_successful_requests: 430, - total_failed_requests: 20, - total_tokens: 4500, - }, - }; - - const mockTeamAliasMap: Record = { - "team-1": "Team One", - "team-2": "Team Two", - }; - - const usersFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 16.5, - api_requests: 165, - successful_requests: 156, - failed_requests: 9, - total_tokens: 1650, - prompt_tokens: 940, - completion_tokens: 710, - cache_read_input_tokens: 90, - cache_creation_input_tokens: 60, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - cache_read_input_tokens: 6, - cache_creation_input_tokens: 4, - }, - metadata: { - team_id: "team-1", - key_alias: "alice-key", - user_id: "u1", - user_email: "a@x", - }, - }, - kB: { - metrics: { - spend: 2.2, - api_requests: 22, - successful_requests: 20, - failed_requests: 2, - total_tokens: 220, - prompt_tokens: 130, - completion_tokens: 90, - cache_read_input_tokens: 12, - cache_creation_input_tokens: 8, - }, - metadata: { - team_id: "team-1", - user_id: "u1", - user_email: "a@x", - }, - }, - kC: { - metrics: { - spend: 3.3, - api_requests: 33, - successful_requests: 31, - failed_requests: 2, - total_tokens: 330, - prompt_tokens: 190, - completion_tokens: 140, - cache_read_input_tokens: 18, - cache_creation_input_tokens: 12, - }, - metadata: { - team_id: "team-1", - user_id: "u2", - user_email: null, - }, - }, - kD: { - metrics: { - spend: 4.4, - api_requests: 44, - successful_requests: 42, - failed_requests: 2, - total_tokens: 440, - prompt_tokens: 250, - completion_tokens: 190, - cache_read_input_tokens: 24, - cache_creation_input_tokens: 16, - }, - metadata: { - team_id: "team-1", - user_id: null, - }, - }, - kE: { - metrics: { - spend: 5.5, - api_requests: 55, - successful_requests: 53, - failed_requests: 2, - total_tokens: 550, - prompt_tokens: 310, - completion_tokens: 240, - cache_read_input_tokens: 30, - cache_creation_input_tokens: 20, - }, - metadata: { - team_id: "team-1", - user_id: "u3", - key_exists: false, - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - api_key_breakdown: { - kF: { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - metadata: { - team_id: "team-2", - user_id: "u1", - user_email: "a@x", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-03-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 7.7, - api_requests: 77, - successful_requests: 75, - failed_requests: 2, - total_tokens: 770, - prompt_tokens: 430, - completion_tokens: 340, - cache_read_input_tokens: 42, - cache_creation_input_tokens: 28, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 7.7, - api_requests: 77, - successful_requests: 75, - failed_requests: 2, - total_tokens: 770, - prompt_tokens: 430, - completion_tokens: 340, - cache_read_input_tokens: 42, - cache_creation_input_tokens: 28, - }, - metadata: { - team_id: "team-1", - key_alias: "alice-key", - user_id: "u1", - user_email: "a@x", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 30.8, - total_api_requests: 308, - total_successful_requests: 295, - total_failed_requests: 13, - total_tokens: 3080, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); + expect(name).toBe("team_usage_daily_with_keys_2025-01-05_2025-01-31.csv"); }); + it("falls back to 'all' when a bound is missing", () => { + expect(exportFilename("user", "daily_with_users", "json", { from: undefined, to: undefined })).toBe( + "user_usage_daily_with_users_all_all.json", + ); + }); +}); + +describe("downloadBlob", () => { afterEach(() => { + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); - describe("getEntityBreakdown", () => { - it("should aggregate entity spend data across multiple days", () => { - const result = getEntityBreakdown(mockSpendData); - - expect(result).toHaveLength(2); - expect(result[0].metadata.id).toBe("team-1"); - expect(result[0].metrics.spend).toBe(25.7); - expect(result[1].metadata.id).toBe("team-2"); - expect(result[1].metrics.spend).toBe(20.3); - }); - - it("should sort entities by spend descending", () => { - const result = getEntityBreakdown(mockSpendData); - - expect(result[0].metrics.spend).toBeGreaterThan(result[1].metrics.spend); - }); - - it("should aggregate all metrics correctly", () => { - const result = getEntityBreakdown(mockSpendData); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metrics.api_requests).toBe(250); - expect(entity1?.metrics.successful_requests).toBe(240); - expect(entity1?.metrics.failed_requests).toBe(10); - expect(entity1?.metrics.total_tokens).toBe(2500); - expect(entity1?.metrics.prompt_tokens).toBe(1500); - expect(entity1?.metrics.completion_tokens).toBe(1000); - expect(entity1?.metrics.cache_read_input_tokens).toBe(125); - expect(entity1?.metrics.cache_creation_input_tokens).toBe(75); - }); - - it("should use entity key as alias when no team alias map is provided", () => { - // Non-team exports (tags, orgs, customers, …) pass no teamAliasMap. - // For teams, this is also the fallback when a team is missing from the map. - const result = getEntityBreakdown(mockSpendData); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metadata.alias).toBe("team-1"); - }); - - it("should use team alias map to resolve alias from entity key", () => { - const spendDataWithoutAlias: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = getEntityBreakdown(spendDataWithoutAlias, mockTeamAliasMap); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metadata.alias).toBe("Team One"); - }); - - it("should use entity id when team alias is not available", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = getEntityBreakdown(spendDataWithoutTeamId); - const entity1 = result.find((e) => e.metadata.id === "entity1"); - - expect(entity1?.metadata.alias).toBe("entity1"); - }); - - it("should handle empty spend data", () => { - const emptySpendData: EntitySpendData = { - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }; - - const result = getEntityBreakdown(emptySpendData); - - expect(result).toHaveLength(0); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = getEntityBreakdown(spendDataWithMissingTokens); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metrics.prompt_tokens).toBe(0); - expect(entity1?.metrics.completion_tokens).toBe(0); - }); - }); - - describe("generateDailyData", () => { - it("should generate daily breakdown data with correct structure", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - - expect(result).toHaveLength(3); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - expect(result[0]).toHaveProperty("Team ID"); - expect(result[0]).toHaveProperty("Spend ($)"); - expect(result[0]).toHaveProperty("Requests"); - expect(result[0]).toHaveProperty("Successful Requests"); - expect(result[0]).toHaveProperty("Failed Requests"); - expect(result[0]).toHaveProperty("Total Tokens"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export exact cache token values per entity per day", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - - const day1Team1 = result.find((r) => r.Date === "2025-01-01" && r["Team ID"] === "team-1"); - const day1Team2 = result.find((r) => r.Date === "2025-01-01" && r["Team ID"] === "team-2"); - const day2Team1 = result.find((r) => r.Date === "2025-01-02" && r["Team ID"] === "team-1"); - - expect(day1Team1?.["Cache Read Input Tokens"]).toBe(50); - expect(day1Team1?.["Cache Creation Input Tokens"]).toBe(30); - expect(day1Team2?.["Cache Read Input Tokens"]).toBe(100); - expect(day1Team2?.["Cache Creation Input Tokens"]).toBe(60); - expect(day2Team1?.["Cache Read Input Tokens"]).toBe(75); - expect(day2Team1?.["Cache Creation Input Tokens"]).toBe(45); - }); - - it("should sort data by date ascending", () => { - const result = generateDailyData(mockSpendData, "Team"); - - const dates = result.map((r) => new Date(r.Date).getTime()); - for (let i = 0; i < dates.length - 1; i++) { - expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); - } - }); - - it("should use team alias when available", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - const team1Entry = result.find((r) => r["Team ID"] === "team-1"); - - expect(team1Entry?.["Team"]).toBe("Team One"); - }); - - it("should use dash when team alias is not available", () => { - const result = generateDailyData(mockSpendData, "Team"); - const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); - - if (entryWithoutTeamId) { - expect(entryWithoutTeamId["Team"]).toBe("-"); - } - }); - - it("should fall back to the entity key when there is no team alias mapping", () => { - // e.g. tag/org/customer exports where teamAliasMap has no entry for the entity, - // or a team that isn't in the alias map — the entity key itself is the label. - const spendDataWithoutAlias: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "my-tag": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyData(spendDataWithoutAlias, "Tag"); - const entry = result[0]; - - expect(entry["Tag ID"]).toBe("my-tag"); - expect(entry["Tag"]).toBe("my-tag"); - }); - - it("should format spend values correctly", () => { - const result = generateDailyData(mockSpendData, "Team"); - - expect(result[0]["Spend ($)"]).toBeDefined(); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyData(spendDataWithMissingTokens, "Team"); - - expect(result[0]["Prompt Tokens"]).toBe(0); - expect(result[0]["Completion Tokens"]).toBe(0); - expect(result[0]["Cache Read Input Tokens"]).toBe(0); - expect(result[0]["Cache Creation Input Tokens"]).toBe(0); - }); - }); - - describe("generateDailyWithKeysData", () => { - const mockSpendDataWithKeys: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-2", - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - }, - api_key_breakdown: { - key3: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - }, - metadata: { - team_id: "team-2", - key_alias: "alias-3", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 46.0, - total_api_requests: 450, - total_successful_requests: 430, - total_failed_requests: 20, - total_tokens: 4500, - }, - }; - - it("should generate daily breakdown with key data and correct structure", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - expect(result[0]).toHaveProperty("Team ID"); - expect(result[0]).toHaveProperty("Key Alias"); - expect(result[0]).toHaveProperty("Key ID"); - expect(result[0]).toHaveProperty("Spend ($)"); - expect(result[0]).toHaveProperty("Requests"); - expect(result[0]).toHaveProperty("Successful Requests"); - expect(result[0]).toHaveProperty("Failed Requests"); - expect(result[0]).toHaveProperty("Total Tokens"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export and aggregate cache token values per key", () => { - const makeDay = (cacheRead: number, cacheCreation: number) => ({ - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }); - - const spendDataWithCache: EntitySpendData = { - results: [makeDay(40, 25), makeDay(10, 5)], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithCache, "Team"); - - expect(result).toHaveLength(1); - expect(result[0]["Cache Read Input Tokens"]).toBe(50); - expect(result[0]["Cache Creation Input Tokens"]).toBe(30); - }); - - it("should sort data by date ascending", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - - const dates = result.map((r) => new Date(r.Date).getTime()); - for (let i = 0; i < dates.length - 1; i++) { - expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); - } - }); - - it("should aggregate metrics for duplicate date-team-key combinations", () => { - const spendDataWithDuplicates: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 21.0, - total_api_requests: 200, - total_successful_requests: 190, - total_failed_requests: 10, - total_tokens: 2000, - }, - }; - - const result = generateDailyWithKeysData(spendDataWithDuplicates, "Team"); - const key1Entries = result.filter((r) => r["Key ID"] === "key1"); - - expect(key1Entries).toHaveLength(1); - expect(key1Entries[0].Requests).toBe(100); - expect(key1Entries[0]["Successful Requests"]).toBe(95); - expect(key1Entries[0]["Failed Requests"]).toBe(5); - expect(key1Entries[0]["Total Tokens"]).toBe(1000); - }); - - it("should use team alias when available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team", mockTeamAliasMap); - const team1Entry = result.find((r) => r["Team ID"] === "team-1"); - - expect(team1Entry?.["Team"]).toBe("Team One"); - }); - - it("should use dash when team alias is not available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const entryWithoutTeamAlias = result.find((r) => r["Team ID"] === "team-1" && !mockTeamAliasMap[r["Team ID"]]); - - if (entryWithoutTeamAlias) { - expect(entryWithoutTeamAlias["Team"]).toBe("-"); - } - }); - - it("should use key alias when available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Key Alias"]).toBe("alias-1"); - }); - - it("should use dash when key alias is not available", () => { - const spendDataWithoutKeyAlias: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutKeyAlias, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Key Alias"]).toBe("-"); - }); - - it("should use entity id when team id is not available in metadata", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: {}, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutTeamId, "Team"); - const entry = result.find((r) => r["Key ID"] === "key1"); - - expect(entry?.["Team ID"]).toBe("entity1"); - }); - - it("should use dash when team id is not available", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: null, - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutTeamId, "Team"); - const entry = result.find((r) => r["Key ID"] === "key1"); - - expect(entry?.["Team ID"]).toBe("entity1"); - }); - - it("should format spend values correctly", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - - expect(result[0]["Spend ($)"]).toBeDefined(); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithMissingTokens, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Prompt Tokens"]).toBe(0); - expect(key1Entry?.["Completion Tokens"]).toBe(0); - expect(key1Entry?.["Cache Read Input Tokens"]).toBe(0); - expect(key1Entry?.["Cache Creation Input Tokens"]).toBe(0); - }); - - it("should handle empty api_key_breakdown", () => { - const spendDataWithEmptyBreakdown: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithEmptyBreakdown, "Team"); - - expect(result).toHaveLength(0); - }); - - it("should handle multiple keys for same team on same date", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const team1Entries = result.filter((r) => r["Team ID"] === "team-1" && r.Date === "2025-01-01"); - - expect(team1Entries.length).toBeGreaterThanOrEqual(2); - const keyIds = team1Entries.map((r) => r["Key ID"]); - expect(keyIds).toContain("key1"); - expect(keyIds).toContain("key2"); - }); - - it("should emit key owner columns right after Key ID", () => { - const result = generateDailyWithKeysData(usersFixture, "Team"); - - const columnNames = Object.keys(result[0]); - expect(columnNames[columnNames.indexOf("Key ID") + 1]).toBe("User ID"); - expect(columnNames[columnNames.indexOf("User ID") + 1]).toBe("User Email"); - - const kARow = result.find((r) => r["Key ID"] === "kA" && r.Date === "2025-03-01"); - expect(kARow?.["User ID"]).toBe("u1"); - expect(kARow?.["User Email"]).toBe("a@x"); - expect(kARow?.["Key Alias"]).toBe("alice-key"); - - const kCRow = result.find((r) => r["Key ID"] === "kC"); - expect(kCRow?.["User ID"]).toBe("u2"); - expect(kCRow?.["User Email"]).toBe("-"); - - const kDRow = result.find((r) => r["Key ID"] === "kD"); - expect(kDRow?.["User ID"]).toBe("-"); - expect(kDRow?.["User Email"]).toBe("-"); - }); - }); - - describe("generateDailyWithModelsData", () => { - const mockSpendDataWithModels: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { - team_id: "team-1", - }, - }, - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "gpt-3.5-turbo": { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 10.5, - total_api_requests: 100, - total_successful_requests: 95, - total_failed_requests: 5, - total_tokens: 1000, - }, - }; - - it("should generate daily breakdown with model data", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - expect(result[0]).toHaveProperty("Team ID"); - expect(result[0]).toHaveProperty("Model"); - expect(result[0]).toHaveProperty("Spend ($)"); - expect(result[0]).toHaveProperty("Requests"); - expect(result[0]).toHaveProperty("Successful"); - expect(result[0]).toHaveProperty("Failed"); - expect(result[0]).toHaveProperty("Total Tokens"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export prompt, completion, and cache token values summed across keys for the same model", () => { - const data: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 5.0, - api_requests: 25, - successful_requests: 25, - failed_requests: 0, - total_tokens: 1050, - prompt_tokens: 750, - completion_tokens: 300, - cache_read_input_tokens: 450, - cache_creation_input_tokens: 200, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 2.0, - api_requests: 10, - successful_requests: 10, - failed_requests: 0, - total_tokens: 700, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 3.0, - api_requests: 15, - successful_requests: 15, - failed_requests: 0, - total_tokens: 350, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "claude-sonnet-4-5": { - metrics: { - spend: 5.0, - api_requests: 25, - successful_requests: 25, - failed_requests: 0, - total_tokens: 1050, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 2.0, - api_requests: 10, - successful_requests: 10, - failed_requests: 0, - total_tokens: 700, - prompt_tokens: 500, - completion_tokens: 200, - cache_read_input_tokens: 300, - cache_creation_input_tokens: 120, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 3.0, - api_requests: 15, - successful_requests: 15, - failed_requests: 0, - total_tokens: 350, - prompt_tokens: 250, - completion_tokens: 100, - cache_read_input_tokens: 150, - cache_creation_input_tokens: 80, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 5.0, - total_api_requests: 25, - total_successful_requests: 25, - total_failed_requests: 0, - total_tokens: 1050, - }, - }; - - const result = generateDailyWithModelsData(data, "Team"); - - expect(result).toHaveLength(1); - expect(result[0].Model).toBe("claude-sonnet-4-5"); - expect(result[0]["Total Tokens"]).toBe(1050); - expect(result[0]["Prompt Tokens"]).toBe(750); - expect(result[0]["Completion Tokens"]).toBe(300); - expect(result[0]["Cache Read Input Tokens"]).toBe(450); - expect(result[0]["Cache Creation Input Tokens"]).toBe(200); - }); - - it("should sort data by date ascending", () => { - const multiDayData: EntitySpendData = { - results: [ - { - date: "2025-01-02", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ...mockSpendDataWithModels.results, - ], - metadata: mockSpendDataWithModels.metadata, - }; - - const result = generateDailyWithModelsData(multiDayData, "Team"); - - expect(new Date(result[0].Date).getTime()).toBeLessThanOrEqual( - new Date(result[result.length - 1].Date).getTime(), - ); - }); - - it("should attribute each model only its own per-key spend", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); - - const gpt4Entry = result.find((r) => r.Model === "gpt-4"); - const gpt35Entry = result.find((r) => r.Model === "gpt-3.5-turbo"); - - expect(gpt4Entry?.["Spend ($)"]).toBe("5.0000"); - expect(gpt4Entry?.Requests).toBe(50); - expect(gpt4Entry?.["Total Tokens"]).toBe(500); - - expect(gpt35Entry?.["Spend ($)"]).toBe("5.5000"); - expect(gpt35Entry?.Requests).toBe(50); - expect(gpt35Entry?.["Total Tokens"]).toBe(500); - }); - - it("should not duplicate a user's spend across every model (regression for LIT overcount)", () => { - // One user, one key, that key used two models. The entity-level api_key_breakdown - // carries the key's total (8.0) across both models; each model's api_key_breakdown - // carries only that model's share (3.0 + 5.0). The per-model rows must sum back to - // the user-day total, not repeat the total once per model. - const data: EntitySpendData = { - results: [ - { - date: "2025-02-14", - breakdown: { - entities: { - user1: { - metrics: { - spend: 8.0, - api_requests: 80, - successful_requests: 78, - failed_requests: 2, - total_tokens: 800, - prompt_tokens: 500, - completion_tokens: 300, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 8.0, - api_requests: 80, - successful_requests: 78, - failed_requests: 2, - total_tokens: 800, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "claude-3-haiku": { - metrics: { - spend: 3.0, - api_requests: 30, - successful_requests: 29, - failed_requests: 1, - total_tokens: 300, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 3.0, - api_requests: 30, - successful_requests: 29, - failed_requests: 1, - total_tokens: 300, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "claude-sonnet-4-5": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 49, - failed_requests: 1, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 49, - failed_requests: 1, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 8.0, - total_api_requests: 80, - total_successful_requests: 78, - total_failed_requests: 2, - total_tokens: 800, - }, - }; - - const result = generateDailyWithModelsData(data, "User"); - - expect(result).toHaveLength(2); - - const haiku = result.find((r) => r.Model === "claude-3-haiku"); - const sonnet = result.find((r) => r.Model === "claude-sonnet-4-5"); - - expect(haiku?.["Spend ($)"]).toBe("3.0000"); - expect(sonnet?.["Spend ($)"]).toBe("5.0000"); - - const totalSpend = result.reduce((sum, r) => sum + parseFloat(r["Spend ($)"].replace(/,/g, "")), 0); - const totalRequests = result.reduce((sum, r) => sum + r.Requests, 0); - const totalTokens = result.reduce((sum, r) => sum + r["Total Tokens"], 0); - - expect(totalSpend).toBeCloseTo(8.0, 4); - expect(totalRequests).toBe(80); - expect(totalTokens).toBe(800); - }); - - it("should omit models the user never called instead of fanning out", () => { - // A second key (key2) belongs to a different user and is the only caller of - // gpt-3.5-turbo. user1 only used key1 -> gpt-4. user1 must get exactly one row. - const data: EntitySpendData = { - results: [ - { - date: "2025-02-14", - breakdown: { - entities: { - user1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "gpt-3.5-turbo": { - metrics: { - spend: 9.0, - api_requests: 90, - successful_requests: 90, - failed_requests: 0, - total_tokens: 900, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 9.0, - api_requests: 90, - successful_requests: 90, - failed_requests: 0, - total_tokens: 900, - }, - metadata: { team_id: "team-2" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 14.0, - total_api_requests: 140, - total_successful_requests: 138, - total_failed_requests: 2, - total_tokens: 1400, - }, - }; - - const result = generateDailyWithModelsData(data, "User"); - - expect(result).toHaveLength(1); - expect(result[0].Model).toBe("gpt-4"); - expect(result[0]["Spend ($)"]).toBe("5.0000"); - }); - - it("should use team alias when available", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); - const team1Entry = result.find((r) => r["Team ID"] === "team-1"); - - expect(team1Entry?.["Team"]).toBe("Team One"); - }); - - it("should use dash when team alias is not available", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); - const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); - - if (entryWithoutTeamId) { - expect(entryWithoutTeamId["Team"]).toBe("-"); - } - }); - - it("should handle empty models breakdown", () => { - const spendDataWithoutModels: EntitySpendData = { - ...mockSpendDataWithModels, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: {}, - }, - }, - ], - metadata: mockSpendDataWithModels.metadata, - }; - - const result = generateDailyWithModelsData(spendDataWithoutModels, "Team"); - - expect(result).toHaveLength(0); - }); - }); - - describe("generateExportData", () => { - it("should return daily data when scope is daily", () => { - const result = generateExportData(mockSpendData, "daily", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).not.toHaveProperty("Model"); - }); - - it("should return daily with keys data when scope is daily_with_keys", () => { - const mockDataWithKeys: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateExportData(mockDataWithKeys, "daily_with_keys", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Key Alias"); - expect(result[0]).toHaveProperty("Key ID"); - expect(result[0]).not.toHaveProperty("Model"); - }); - - it("should return daily with models data when scope is daily_with_models", () => { - const mockDataWithModels: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateExportData(mockDataWithModels, "daily_with_models", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Model"); - }); - - it("should default to daily data for unknown scope", () => { - const result = generateExportData(mockSpendData, "unknown" as ExportScope, "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).not.toHaveProperty("Model"); - }); - }); - - describe("generateMetadata", () => { - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - it("should generate metadata with correct structure", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result).toHaveProperty("export_date"); - expect(result).toHaveProperty("entity_type"); - expect(result).toHaveProperty("date_range"); - expect(result).toHaveProperty("filters_applied"); - expect(result).toHaveProperty("export_scope"); - expect(result).toHaveProperty("summary"); - }); - - it("should include export date as ISO string", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.export_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - }); - - it("should include entity type", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.entity_type).toBe("team"); - }); - - it("should format date range correctly", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.date_range.from).toBe("2025-01-01T00:00:00.000Z"); - expect(result.date_range.to).toBe("2025-01-31T00:00:00.000Z"); - }); - - it("should handle missing date range values", () => { - const incompleteDateRange: DateRangePickerValue = { - from: undefined, - to: undefined, - }; - - const result = generateMetadata("team", incompleteDateRange, [], "daily", mockSpendData); - - expect(result.date_range.from).toBeUndefined(); - expect(result.date_range.to).toBeUndefined(); - }); - - it("should set filters_applied to None when empty", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.filters_applied).toBe("None"); - }); - - it("should include filters when provided", () => { - const result = generateMetadata("team", mockDateRange, ["filter1", "filter2"], "daily", mockSpendData); - - expect(result.filters_applied).toEqual(["filter1", "filter2"]); - }); - - it("should include export scope", () => { - const result = generateMetadata("team", mockDateRange, [], "daily_with_models", mockSpendData); - - expect(result.export_scope).toBe("daily_with_models"); - }); - - it("should include summary metrics from spend data", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.summary.total_spend).toBe(46.0); - expect(result.summary.total_requests).toBe(450); - expect(result.summary.successful_requests).toBe(430); - expect(result.summary.failed_requests).toBe(20); - expect(result.summary.total_tokens).toBe(4500); - }); - - it("should include total_flat_cost and total_cost in summary when total_flat_cost is present", () => { - const spendWithFlat: EntitySpendData = { - ...mockSpendData, - metadata: { ...mockSpendData.metadata, total_flat_cost: 6.45 }, - }; - const result = generateMetadata("team", mockDateRange, [], "daily", spendWithFlat); - expect(result.summary.total_flat_cost).toBeCloseTo(6.45, 4); - expect(result.summary.total_cost).toBeCloseTo(46.0 + 6.45, 4); - }); - - it("should omit total_flat_cost and total_cost when total_flat_cost is zero", () => { - const zeroFlat = { ...mockSpendData, metadata: { ...mockSpendData.metadata, total_flat_cost: 0 } }; - const result = generateMetadata("team", mockDateRange, [], "daily", zeroFlat); - expect(result.summary.total_flat_cost).toBeUndefined(); - expect(result.summary.total_cost).toBeUndefined(); - }); - }); - - describe("generateDailyData PTU flat cost", () => { - const dayWithFlat: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10, - flat_cost: 6.45, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: { - total_spend: 10, - total_flat_cost: 6.45, - total_api_requests: 50, - total_successful_requests: 50, - total_failed_requests: 0, - total_tokens: 500, - }, - }; - - it("includes Flat Cost ($) and Total Cost ($) columns when total_flat_cost is present", () => { - const rows = generateDailyData(dayWithFlat, "Team", {}); - expect(rows).toHaveLength(1); - expect(rows[0]).toHaveProperty("Flat Cost ($)"); - expect(rows[0]).toHaveProperty("Total Cost ($)"); - expect(rows[0]["Flat Cost ($)"]).toBe("6.4500"); - expect(rows[0]["Total Cost ($)"]).toBe("16.4500"); - }); - - it("does not include Flat Cost / Total Cost columns when total_flat_cost is zero", () => { - const spendWithoutFlat: EntitySpendData = { - ...dayWithFlat, - metadata: { - total_spend: 10, - total_api_requests: 50, - total_successful_requests: 50, - total_failed_requests: 0, - total_tokens: 500, - total_flat_cost: 0, - }, - }; - const rows = generateDailyData(spendWithoutFlat, "User", {}); - expect(rows).toHaveLength(1); - expect(rows[0]).not.toHaveProperty("Flat Cost ($)"); - expect(rows[0]).not.toHaveProperty("Total Cost ($)"); - }); - }); - - describe("handleExportCSV", () => { - beforeEach(() => { - document.body.innerHTML = ""; - window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); - window.URL.revokeObjectURL = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should create CSV file and trigger download", () => { - const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); - vi.spyOn(window.URL, "revokeObjectURL"); - const createElementSpy = vi.spyOn(document, "createElement"); - const appendChildSpy = vi.spyOn(document.body, "appendChild"); - const removeChildSpy = vi.spyOn(document.body, "removeChild"); - - handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); - - const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; - expect(unparsedRows).toHaveLength(3); - const day1Team1 = unparsedRows.find((r) => r["Date"] === "2025-01-01" && r["Team ID"] === "team-1"); - expect(day1Team1?.["Cache Read Input Tokens"]).toBe(50); - - const exportedBlob = createObjectURLSpy.mock.calls[0][0] as Blob; - expect(exportedBlob.type).toBe("text/csv;charset=utf-8;"); - - expect(createElementSpy).toHaveBeenCalledWith("a"); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - expect(attached.download).toMatch(/^team_usage_daily_.*\.csv$/); - expect(removeChildSpy).toHaveBeenCalledWith(attached); - }); - - it("should generate correct filename", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - - const today = new Date().toISOString().split("T")[0]; - - handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); - - expect(anchorElement.download).toBe(`team_usage_daily_${today}.csv`); - }); - - it("should create blob with correct type", () => { - let blobType = ""; - const originalBlob = window.Blob; - - window.Blob = class extends Blob { - constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { - super(parts, options); - if (options?.type) { - blobType = options.type; - } - } - } as any; - - handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); - - expect(blobType).toBe("text/csv;charset=utf-8;"); - - window.Blob = originalBlob; - }); - - it("should generate the daily_with_users filename and include User ID in the rows", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-03-01T12:00:00Z")); - - handleExportCSV(usersFixture, "daily_with_users", "Team", "team", mockTeamAliasMap); - vi.useRealTimers(); - - expect(anchorElement.download).toBe("team_usage_daily_with_users_2025-03-01.csv"); - - const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; - expect(unparsedRows[0]).toHaveProperty("User ID"); - }); - }); - - describe("handleExportJSON", () => { - beforeEach(() => { - document.body.innerHTML = ""; - window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); - window.URL.revokeObjectURL = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should create JSON file and trigger download", () => { - const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); - vi.spyOn(window.URL, "revokeObjectURL"); - const createElementSpy = vi.spyOn(document, "createElement"); - const appendChildSpy = vi.spyOn(document.body, "appendChild"); - const removeChildSpy = vi.spyOn(document.body, "removeChild"); - - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); - - const exportedBlob = createObjectURLSpy.mock.calls[0][0] as Blob; - expect(exportedBlob.type).toBe("application/json"); - - expect(createElementSpy).toHaveBeenCalledWith("a"); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - expect(attached.download).toMatch(/^team_usage_daily_.*\.json$/); - expect(removeChildSpy).toHaveBeenCalledWith(attached); - }); - - it("should generate correct filename", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - - const today = new Date().toISOString().split("T")[0]; - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); - - expect(anchorElement.download).toBe(`team_usage_daily_${today}.json`); - }); - - it("should create blob with correct type", () => { - let blobType = ""; - const originalBlob = window.Blob; - - window.Blob = class extends Blob { - constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { - super(parts, options); - if (options?.type) { - blobType = options.type; - } - } - } as any; - - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); - - expect(blobType).toBe("application/json"); - - window.Blob = originalBlob; - }); - - it("should include metadata and data in JSON export", () => { - let jsonString = ""; - const originalBlob = window.Blob; - - window.Blob = class extends Blob { - constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { - super(parts, options); - if (parts && parts[0]) { - jsonString = parts[0] as string; - } - } - } as any; - - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, ["filter1"], mockTeamAliasMap); - - const exportObject = JSON.parse(jsonString); - expect(exportObject).toHaveProperty("metadata"); - expect(exportObject).toHaveProperty("data"); - expect(exportObject.metadata.entity_type).toBe("team"); - expect(exportObject.metadata.filters_applied).toEqual(["filter1"]); - - window.Blob = originalBlob; - }); - }); - - describe("resolveEntities and aggregated endpoint fallback", () => { - // Simulates the response from /user/daily/activity/aggregated which has - // empty entities but populated api_keys at the breakdown level. - // Derived from mockSpendData: flatten all entities' api_key_breakdowns - // into top-level api_keys, clear entities, and add a second key for team-1 - // to test multi-key grouping. - const aggregatedSpendData: EntitySpendData = { - ...mockSpendData, - results: mockSpendData.results.slice(0, 1).map((day) => ({ - ...day, - breakdown: { - entities: {}, - api_keys: { - ...Object.fromEntries( - Object.values(day.breakdown.entities as Record).flatMap((e: any) => - Object.entries(e.api_key_breakdown || {}), - ), - ), - // Extra key on team-1 to test multi-key-per-team aggregation - key1b: { - metrics: { spend: 5, api_requests: 50, successful_requests: 48, failed_requests: 2, total_tokens: 500 }, - metadata: { team_id: "team-1", key_alias: "staging-key" }, - }, - }, - models: { - "gpt-4": { - metrics: { spend: 35.8, api_requests: 350, total_tokens: 3500 }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { team_id: "team-1" }, - }, - key1b: { - metrics: { - spend: 5, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 195, - failed_requests: 5, - total_tokens: 2000, - }, - metadata: { team_id: "team-2" }, - }, - }, - }, - }, - }, - })), - }; - - describe("resolveEntities", () => { - it("should return entities when populated", () => { - const breakdown = { - entities: { e1: { metrics: { spend: 1 } } }, - api_keys: { k1: { metrics: { spend: 2 }, metadata: { team_id: "t1" } } }, - }; - const result = resolveEntities(breakdown); - expect(result).toBe(breakdown.entities); - }); - - it("should aggregate api_keys into entities when entities is empty", () => { - const breakdown = aggregatedSpendData.results[0].breakdown; - const result = resolveEntities(breakdown); - - // Two teams: team-1 (key1+key2) and team-2 (key3) - expect(Object.keys(result)).toHaveLength(2); - expect(result["team-1"]).toBeDefined(); - expect(result["team-2"]).toBeDefined(); - - // team-1 spend = 10.5 (key1) + 5 (key1b) - expect(result["team-1"].metrics.spend).toBe(15.5); - expect(result["team-1"].metrics.api_requests).toBe(150); - expect(result["team-1"].metrics.total_tokens).toBe(1500); - - // team-2 spend = 20.3 (key2) - expect(result["team-2"].metrics.spend).toBe(20.3); - expect(result["team-2"].metrics.api_requests).toBe(200); - }); - - it("should use 'Unassigned' for keys without team_id", () => { - const breakdown = { - entities: {}, - api_keys: { - k1: { - metrics: { spend: 7, api_requests: 10, successful_requests: 10, failed_requests: 0, total_tokens: 100 }, - metadata: {}, - }, - }, - }; - const result = resolveEntities(breakdown); - expect(result["Unassigned"]).toBeDefined(); - expect(result["Unassigned"].metrics.spend).toBe(7); - }); - - it("should handle missing or empty api_keys gracefully", () => { - expect(Object.keys(resolveEntities({ entities: {}, api_keys: {} }))).toHaveLength(0); - expect(Object.keys(resolveEntities({ entities: {} }))).toHaveLength(0); - }); - - it("should preserve api_key_breakdown on aggregated entities", () => { - const breakdown = aggregatedSpendData.results[0].breakdown; - const result = resolveEntities(breakdown); - - // team-1 should have key1 and key1b in api_key_breakdown - expect(Object.keys(result["team-1"].api_key_breakdown)).toEqual(["key1", "key1b"]); - // team-2 should have key2 - expect(Object.keys(result["team-2"].api_key_breakdown)).toEqual(["key2"]); - }); - }); - - describe("getEntityBreakdown with aggregated data", () => { - it("should produce breakdown from api_keys when entities is empty", () => { - const result = getEntityBreakdown(aggregatedSpendData); - expect(result.length).toBeGreaterThan(0); - - // Sorted by spend desc: team-2 (20.3) then team-1 (15.5) - expect(result[0].metrics.spend).toBe(20.3); - expect(result[1].metrics.spend).toBe(15.5); - }); - }); - - describe("generateDailyData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - }); - }); - - describe("generateDailyWithKeysData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyWithKeysData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - - // Should have 3 key rows (key1, key1b, key2) - expect(result).toHaveLength(3); - const keyIds = result.map((r) => r["Key ID"]); - expect(keyIds).toContain("key1"); - expect(keyIds).toContain("key1b"); - expect(keyIds).toContain("key2"); - }); - }); - - describe("generateDailyWithModelsData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyWithModelsData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Model"); - - // team-1 = key1 (10.5) + key1b (5) on gpt-4; team-2 = key2 (20.3) on gpt-4. - // Spend must aggregate per team-key, not repeat the model total per team. - const team1 = result.find((r) => r["Team ID"] === "team-1"); - const team2 = result.find((r) => r["Team ID"] === "team-2"); - expect(team1?.["Spend ($)"]).toBe("15.5000"); - expect(team2?.["Spend ($)"]).toBe("20.3000"); - }); - }); - }); - - describe("display name resolution from entity metadata", () => { - const entityMetrics = { - spend: 12.25, - api_requests: 40, - successful_requests: 39, - failed_requests: 1, - total_tokens: 900, - prompt_tokens: 500, - completion_tokens: 400, - cache_read_input_tokens: 20, - cache_creation_input_tokens: 10, - }; - - const makeSpendData = (entity: string, metadata?: Record): EntitySpendData => ({ - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - [entity]: { - metrics: entityMetrics, - metadata, - api_key_breakdown: { - key1: { - metrics: entityMetrics, - metadata: { key_alias: "prod-key" }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }); - - it("should export the user email as the entity label and keep the raw user id in the id column", () => { - const result = generateDailyData( - makeSpendData("user-123", { user_email: "ada@example.com", user_alias: "Ada" }), - "User", - ); - - expect(result).toHaveLength(1); - expect(result[0]["User"]).toBe("ada@example.com"); - expect(result[0]["User ID"]).toBe("user-123"); - }); - - it("should fall back to the user alias when the user has no email", () => { - const nullEmail = generateDailyData( - makeSpendData("user-123", { user_email: null, user_alias: "Ada Lovelace" }), - "User", - ); - const missingEmail = generateDailyData(makeSpendData("user-123", { user_alias: "Ada Lovelace" }), "User"); - - expect(nullEmail[0]["User"]).toBe("Ada Lovelace"); - expect(missingEmail[0]["User"]).toBe("Ada Lovelace"); - }); - - it("should fall back to the raw entity key when the entity carries no metadata", () => { - const noMetadata = generateDailyData(makeSpendData("my-tag"), "Tag"); - const emptyMetadata = generateDailyData(makeSpendData("customer-9", {}), "Customer"); - const blankNames = generateDailyData(makeSpendData("user-123", { user_email: null, user_alias: null }), "User"); - - expect(noMetadata[0]["Tag"]).toBe("my-tag"); - expect(emptyMetadata[0]["Customer"]).toBe("customer-9"); - expect(blankNames[0]["User"]).toBe("user-123"); - }); - - it("should prefer the team alias map over any alias in entity metadata", () => { - const result = generateDailyData( - makeSpendData("team-1", { team_alias: "Stale Alias", user_email: "ada@example.com" }), - "Team", - mockTeamAliasMap, - ); - - expect(result[0]["Team"]).toBe("Team One"); - }); - - it("should use the team alias from entity metadata when the alias map has no entry for the team", () => { - const result = generateDailyData( - makeSpendData("team-9", { team_alias: "Team Nine", user_email: "ada@example.com" }), - "Team", - mockTeamAliasMap, - ); - - expect(result[0]["Team"]).toBe("Team Nine"); - }); - - it("should resolve metadata.alias to the user email in getEntityBreakdown", () => { - const withEmail = getEntityBreakdown( - makeSpendData("user-123", { user_email: "ada@example.com", user_alias: "Ada" }), - ); - const withoutEmail = getEntityBreakdown(makeSpendData("user-123", { user_alias: "Ada" })); - - expect(withEmail[0].metadata.alias).toBe("ada@example.com"); - expect(withEmail[0].metadata.id).toBe("user-123"); - expect(withoutEmail[0].metadata.alias).toBe("Ada"); - }); - - it("should resolve the user email on every key row of the keys scope", () => { - const spendData: EntitySpendData = { - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - "user-123": { - metrics: entityMetrics, - metadata: { user_email: "ada@example.com", user_alias: "Ada" }, - api_key_breakdown: { - key1: { metrics: entityMetrics, metadata: { key_alias: "prod-key" } }, - key2: { metrics: entityMetrics, metadata: { key_alias: "dev-key" } }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyWithKeysData(spendData, "User"); - - expect(result).toHaveLength(2); - expect(result.map((r) => r["User"])).toEqual(["ada@example.com", "ada@example.com"]); - expect(result.map((r) => r["User ID"])).toEqual(["user-123", "user-123"]); - expect(result.find((r) => r["Key ID"] === "key1")?.["Key Alias"]).toBe("prod-key"); - expect(result.find((r) => r["Key ID"] === "key2")?.["Key Alias"]).toBe("dev-key"); - }); - - it("should resolve each entity's own email in the models scope", () => { - const spendData: EntitySpendData = { - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - "user-a": { - metrics: entityMetrics, - metadata: { user_email: "ada@example.com", user_alias: "Ada" }, - api_key_breakdown: { key1: { metrics: entityMetrics, metadata: {} } }, - }, - "user-b": { - metrics: entityMetrics, - metadata: { user_email: null, user_alias: "Grace" }, - api_key_breakdown: { key2: { metrics: entityMetrics, metadata: {} } }, - }, - }, - models: { - "claude-sonnet-4-5": { - metrics: entityMetrics, - api_key_breakdown: { - key1: { metrics: entityMetrics, metadata: {} }, - key2: { metrics: entityMetrics, metadata: {} }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyWithModelsData(spendData, "User"); - - expect(result).toHaveLength(2); - expect(result.every((r) => r.Model === "claude-sonnet-4-5")).toBe(true); - expect(result.find((r) => r["User ID"] === "user-a")?.["User"]).toBe("ada@example.com"); - expect(result.find((r) => r["User ID"] === "user-b")?.["User"]).toBe("Grace"); - }); - }); - - describe("generateDailyWithUsersData", () => { - it("should reconcile spend with daily_with_keys and daily per date and team", () => { - const byUser = generateDailyWithUsersData(usersFixture, "Team"); - const byKey = generateDailyWithKeysData(usersFixture, "Team"); - const daily = generateDailyData(usersFixture, "Team"); - - expect(byUser.length).toBeGreaterThan(0); - expect(byKey.length).toBeGreaterThan(0); - expect(daily.length).toBeGreaterThan(0); - - const sumSpend = (rows: any[]): Record => { - const totals: Record = {}; - rows.forEach((r) => { - const bucket = `${r.Date}|${r["Team ID"]}`; - totals[bucket] = (totals[bucket] || 0) + Number(r["Spend ($)"]); - }); - return totals; - }; - - const userTotals = sumSpend(byUser); - const keyTotals = sumSpend(byKey); - - daily.forEach((row) => { - const bucket = `${row.Date}|${row["Team ID"]}`; - expect(userTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); - expect(keyTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); - }); - }); - - it("should roll multiple keys owned by one user in a team into a single row", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const matches = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u1"); - - expect(matches).toHaveLength(1); - const row = matches[0]; - expect(row.Keys).toBe(2); - expect(row["User Email"]).toBe("a@x"); - expect(row["Spend ($)"]).toBe("3.3000"); - expect(row.Requests).toBe(33); - expect(row["Successful Requests"]).toBe(30); - expect(row["Failed Requests"]).toBe(3); - expect(row["Total Tokens"]).toBe(330); - expect(row["Prompt Tokens"]).toBe(190); - expect(row["Completion Tokens"]).toBe(140); - expect(row["Cache Read Input Tokens"]).toBe(18); - expect(row["Cache Creation Input Tokens"]).toBe(12); - }); - - it("should bucket keys with no owner into an Unassigned row without dropping spend", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find( - (r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "Unassigned", - ); - - expect(row).toBeDefined(); - expect(row?.["User Email"]).toBe("-"); - expect(Number(row?.["Spend ($)"])).toBeCloseTo(4.4, 4); - }); - - it("should keep different users in the same team as separate rows", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const teamRows = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1"); - - const u1Rows = teamRows.filter((r) => r["User ID"] === "u1"); - const u2Rows = teamRows.filter((r) => r["User ID"] === "u2"); - expect(u1Rows).toHaveLength(1); - expect(u2Rows).toHaveLength(1); - expect(Number(u2Rows[0]["Spend ($)"])).toBeCloseTo(3.3, 4); - }); - - it("should keep the same user in different teams as separate rows", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const u1Rows = rows.filter((r) => r.Date === "2025-03-01" && r["User ID"] === "u1"); - - expect(u1Rows).toHaveLength(2); - const team1Row = u1Rows.find((r) => r["Team ID"] === "team-1"); - const team2Row = u1Rows.find((r) => r["Team ID"] === "team-2"); - expect(team1Row?.Keys).toBe(2); - expect(team2Row?.Keys).toBe(1); - expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); - }); - - it("should show a dash email when the user has none", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); - - expect(row).toBeDefined(); - expect(row?.["User Email"]).toBe("-"); - }); - - it("should still attribute a deleted key to its user", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); - - expect(row).toBeDefined(); - expect(Number(row?.["Spend ($)"])).toBeCloseTo(5.5, 4); - expect(row?.Requests).toBe(55); - }); - - it("should group rows under team_id on the aggregated endpoint shape", () => { - const aggregatedFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: {}, - api_keys: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - cache_read_input_tokens: 6, - cache_creation_input_tokens: 4, - }, - metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, - }, - kF: { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - metadata: { team_id: "team-2", user_id: "u2" }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const rows = generateDailyWithUsersData(aggregatedFixture, "Team"); - - expect(rows).toHaveLength(2); - const team1Row = rows.find((r) => r["Team ID"] === "team-1"); - const team2Row = rows.find((r) => r["Team ID"] === "team-2"); - expect(team1Row?.["User ID"]).toBe("u1"); - expect(team2Row?.["User ID"]).toBe("u2"); - expect(Number(team1Row?.["Spend ($)"])).toBeCloseTo(1.1, 4); - expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); - }); - - it("should emit the exact column order and sort by date ascending", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - - expect(Object.keys(rows[0])).toEqual([ - "Date", - "Team", - "Team ID", - "User ID", - "User Email", - "Keys", - "Spend ($)", - "Requests", - "Successful Requests", - "Failed Requests", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ]); - - const dates = rows.map((r) => new Date(r.Date).getTime()); - for (let i = 0; i < dates.length - 1; i++) { - expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); - } - }); - - it("should dispatch daily_with_users through generateExportData", () => { - expect(generateExportData(usersFixture, "daily_with_users", "Team")).toEqual( - generateDailyWithUsersData(usersFixture, "Team"), - ); - }); - - it("should keep owners separate when entity and user ids contain underscores", () => { - const collisionFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - team_1: { - metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, - api_key_breakdown: { - kX: { - metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, - metadata: { team_id: "team_1", user_id: "u1" }, - }, - }, - }, - team: { - metrics: { spend: 2, api_requests: 2, total_tokens: 20 }, - api_key_breakdown: { - kY: { - metrics: { spend: 2, api_requests: 2, total_tokens: 20 }, - metadata: { team_id: "team", user_id: "1_u1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const rows = generateDailyWithUsersData(collisionFixture, "Team"); - - expect(rows).toHaveLength(2); - const team1Row = rows.find((r) => r["Team ID"] === "team_1"); - expect(team1Row?.["User ID"]).toBe("u1"); - expect(team1Row?.Keys).toBe(1); - expect(team1Row?.["Spend ($)"]).toBe("1.0000"); - const teamRow = rows.find((r) => r["Team ID"] === "team"); - expect(teamRow?.["User ID"]).toBe("1_u1"); - expect(teamRow?.Keys).toBe(1); - expect(teamRow?.["Spend ($)"]).toBe("2.0000"); - }); - - it("should leave daily and daily_with_models output without user columns", () => { - const daily = generateDailyData(usersFixture, "Team"); - expect(daily[0]).not.toHaveProperty("User ID"); - - const modelsFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - }, - metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, - }, - }, - }, - }, - models: { - "gpt-4o": { - metrics: { spend: 1.1, api_requests: 11, total_tokens: 110 }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - }, - metadata: {}, - }, - }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const modelRows = generateDailyWithModelsData(modelsFixture, "Team"); - expect(modelRows).toHaveLength(1); - expect(Object.keys(modelRows[0])).toEqual([ - "Date", - "Team", - "Team ID", - "Model", - "Spend ($)", - "Requests", - "Successful", - "Failed", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ]); - }); + it("triggers an anchor click carrying the filename and revokes the object URL", () => { + const click = vi.fn(); + const revoke = vi.fn(); + const anchor = { href: "", download: "", click, remove: vi.fn() }; + const createElement = vi.fn(() => anchor); + const appendChild = vi.fn((node: unknown) => node); + const removeChild = vi.fn((node: unknown) => node); + const fakeURL = { ...URL, createObjectURL: vi.fn(() => "blob:mock"), revokeObjectURL: revoke }; + vi.stubGlobal("URL", fakeURL); + vi.stubGlobal("window", { URL: fakeURL }); + vi.stubGlobal("document", { + createElement, + body: { appendChild, removeChild }, + }); + + downloadBlob(new Blob(["data"]), "team_usage_daily_2025-01-01_2025-01-31.csv"); + + expect(createElement).toHaveBeenCalledWith("a"); + expect(anchor.download).toBe("team_usage_daily_2025-01-01_2025-01-31.csv"); + expect(anchor.href).toBe("blob:mock"); + expect(click).toHaveBeenCalledOnce(); + expect(revoke).toHaveBeenCalledWith("blob:mock"); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 95ce584cc89..0f2768c8417 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,497 +1,23 @@ -import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import Papa from "papaparse"; -import { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel"; -import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types"; +import type { EntityType, ExportFormat, ExportType } from "./types"; -const resolveEntityDisplay = ( - entity: string, - teamAliasMap: Record, - entityMetadata?: Record, -): { id: string; alias: string } => ({ - id: entity, - alias: - teamAliasMap[entity] || - entityMetadata?.team_alias || - entityMetadata?.user_email || - entityMetadata?.user_alias || - entity, -}); +const fileDay = (date: Date | undefined): string => + date + ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` + : "all"; -// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py). -// If the backend adds a field, add it here too. -const METRIC_KEYS = [ - "spend", - "api_requests", - "successful_requests", - "failed_requests", - "total_tokens", - "prompt_tokens", - "completion_tokens", - "cache_read_input_tokens", - "cache_creation_input_tokens", -] as const; - -// When breakdown.entities is empty (aggregated endpoint), reconstruct entities -// from breakdown.api_keys by grouping on metadata.team_id. -const aggregateApiKeysIntoEntities = (breakdown: Record): Record => { - const apiKeys = breakdown.api_keys; - if (!apiKeys || Object.keys(apiKeys).length === 0) return {}; - - const grouped: Record = {}; - - for (const [keyId, keyData] of Object.entries(apiKeys)) { - const teamId = keyData?.metadata?.team_id || "Unassigned"; - if (!grouped[teamId]) { - grouped[teamId] = { - metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])), - api_key_breakdown: {}, - }; - } - const m = grouped[teamId].metrics; - const km = keyData?.metrics || {}; - for (const k of METRIC_KEYS) { - m[k] += km[k] || 0; - } - grouped[teamId].api_key_breakdown[keyId] = keyData; - } - - return grouped; -}; - -// Returns breakdown.entities if populated, otherwise falls back to -// reconstructing entities from breakdown.api_keys. -export const resolveEntities = (breakdown: Record): Record => { - const entities = breakdown.entities; - if (entities && Object.keys(entities).length > 0) return entities; - return aggregateApiKeysIntoEntities(breakdown); -}; - -export const getEntityBreakdown = ( - spendData: EntitySpendData, - teamAliasMap: Record = {}, -): EntityBreakdown[] => { - const entitySpend: { [key: string]: EntityBreakdown } = {}; - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - - if (!entitySpend[entity]) { - entitySpend[entity] = { - metrics: { - spend: 0, - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - api_requests: 0, - successful_requests: 0, - failed_requests: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - metadata: { - alias, - id, - }, - }; - } - entitySpend[entity].metrics.spend += data.metrics.spend; - entitySpend[entity].metrics.api_requests += data.metrics.api_requests; - entitySpend[entity].metrics.successful_requests += data.metrics.successful_requests; - entitySpend[entity].metrics.failed_requests += data.metrics.failed_requests; - entitySpend[entity].metrics.total_tokens += data.metrics.total_tokens; - entitySpend[entity].metrics.prompt_tokens += data.metrics.prompt_tokens || 0; - entitySpend[entity].metrics.completion_tokens += data.metrics.completion_tokens || 0; - entitySpend[entity].metrics.cache_read_input_tokens += data.metrics.cache_read_input_tokens || 0; - entitySpend[entity].metrics.cache_creation_input_tokens += data.metrics.cache_creation_input_tokens || 0; - }); - }); - - return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); -}; - -// total_flat_cost defaults to 0 on every entity response, so only a non-zero value -// means a PTU-configured team actually accrued flat cost worth exporting. -const hasFlatCost = (spendData: EntitySpendData): boolean => (spendData.metadata.total_flat_cost ?? 0) > 0; - -export const generateDailyData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - const dailyBreakdown: any[] = []; - const includeFlatCost = hasFlatCost(spendData); - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - - const row: Record = { - Date: day.date, - [entityLabel]: alias, - [`${entityLabel} ID`]: id, - "Spend ($)": formatNumberWithCommas(data.metrics.spend, 4), - }; - if (includeFlatCost) { - const flatCost = data.metrics.flat_cost || 0; - row["Flat Cost ($)"] = formatNumberWithCommas(flatCost, 4); - row["Total Cost ($)"] = formatNumberWithCommas((data.metrics.spend || 0) + flatCost, 4); - } - row.Requests = data.metrics.api_requests; - row["Successful Requests"] = data.metrics.successful_requests; - row["Failed Requests"] = data.metrics.failed_requests; - row["Total Tokens"] = data.metrics.total_tokens; - row["Prompt Tokens"] = data.metrics.prompt_tokens || 0; - row["Completion Tokens"] = data.metrics.completion_tokens || 0; - row["Cache Read Input Tokens"] = data.metrics.cache_read_input_tokens || 0; - row["Cache Creation Input Tokens"] = data.metrics.cache_creation_input_tokens || 0; - dailyBreakdown.push(row); - }); - }); - - return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateDailyWithKeysData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - // Aggregate by unique (Date, Entity ID, Key ID) combination to prevent duplicates - const aggregatedData: { - [key: string]: { - Date: string; - entityId: string; - entityAlias: string; - keyId: string; - keyAlias: string | null; - userId: string | null; - userEmail: string | null; - metrics: { - spend: number; - api_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; - prompt_tokens: number; - completion_tokens: number; - cache_read_input_tokens: number; - cache_creation_input_tokens: number; - }; - }; - } = {}; - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - const apiKeyBreakdown = data.api_key_breakdown || {}; - - // Iterate through each API key in the breakdown - Object.entries(apiKeyBreakdown).forEach(([keyId, keyData]: [string, any]) => { - const keyAlias = keyActivityLabel(keyData?.metadata, "") || null; - - // Create unique key for aggregation: Date_EntityID_KeyID - const uniqueKey = `${day.date}_${entityId}_${keyId}`; - - if (!aggregatedData[uniqueKey]) { - // First time seeing this (Date, Entity ID, Key ID) combination - aggregatedData[uniqueKey] = { - Date: day.date, - entityId, - entityAlias, - keyId, - keyAlias, - userId: keyData?.metadata?.user_id || null, - userEmail: keyData?.metadata?.user_email || null, - metrics: { - spend: keyData.metrics?.spend || 0, - api_requests: keyData.metrics?.api_requests || 0, - successful_requests: keyData.metrics?.successful_requests || 0, - failed_requests: keyData.metrics?.failed_requests || 0, - total_tokens: keyData.metrics?.total_tokens || 0, - prompt_tokens: keyData.metrics?.prompt_tokens || 0, - completion_tokens: keyData.metrics?.completion_tokens || 0, - cache_read_input_tokens: keyData.metrics?.cache_read_input_tokens || 0, - cache_creation_input_tokens: keyData.metrics?.cache_creation_input_tokens || 0, - }, - }; - } else { - // Aggregate metrics for existing entry - aggregatedData[uniqueKey].metrics.spend += keyData.metrics?.spend || 0; - aggregatedData[uniqueKey].metrics.api_requests += keyData.metrics?.api_requests || 0; - aggregatedData[uniqueKey].metrics.successful_requests += keyData.metrics?.successful_requests || 0; - aggregatedData[uniqueKey].metrics.failed_requests += keyData.metrics?.failed_requests || 0; - aggregatedData[uniqueKey].metrics.total_tokens += keyData.metrics?.total_tokens || 0; - aggregatedData[uniqueKey].metrics.prompt_tokens += keyData.metrics?.prompt_tokens || 0; - aggregatedData[uniqueKey].metrics.completion_tokens += keyData.metrics?.completion_tokens || 0; - aggregatedData[uniqueKey].metrics.cache_read_input_tokens += keyData.metrics?.cache_read_input_tokens || 0; - aggregatedData[uniqueKey].metrics.cache_creation_input_tokens += - keyData.metrics?.cache_creation_input_tokens || 0; - } - }); - }); - }); - - // Convert aggregated data to array format - const dailyKeyBreakdown = Object.values(aggregatedData).map((item) => ({ - Date: item.Date, - [entityLabel]: item.entityAlias, - [`${entityLabel} ID`]: item.entityId, - "Key Alias": item.keyAlias || "-", - "Key ID": item.keyId, - ...(entityLabel === "User" ? {} : { "User ID": item.userId || "-", "User Email": item.userEmail || "-" }), - "Spend ($)": formatNumberWithCommas(item.metrics.spend, 4), - Requests: item.metrics.api_requests, - "Successful Requests": item.metrics.successful_requests, - "Failed Requests": item.metrics.failed_requests, - "Total Tokens": item.metrics.total_tokens, - "Prompt Tokens": item.metrics.prompt_tokens, - "Completion Tokens": item.metrics.completion_tokens, - "Cache Read Input Tokens": item.metrics.cache_read_input_tokens, - "Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens, - })); - - return dailyKeyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateDailyWithUsersData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - const aggregatedData: { - [key: string]: { - Date: string; - entityId: string; - entityAlias: string; - userId: string; - userEmail: string | null; - keyIds: Set; - metrics: Record<(typeof METRIC_KEYS)[number], number>; - }; - } = {}; - - spendData.results.forEach((day) => { - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { - const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata); - Object.entries(data.api_key_breakdown || {}).forEach(([keyId, keyData]: [string, any]) => { - const userId = keyData?.metadata?.user_id || "Unassigned"; - const uniqueKey = JSON.stringify([day.date, entityId, userId]); - if (!aggregatedData[uniqueKey]) { - aggregatedData[uniqueKey] = { - Date: day.date, - entityId, - entityAlias, - userId, - userEmail: null, - keyIds: new Set(), - metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])) as Record<(typeof METRIC_KEYS)[number], number>, - }; - } - const bucket = aggregatedData[uniqueKey]; - bucket.userEmail = bucket.userEmail || keyData?.metadata?.user_email || null; - bucket.keyIds.add(keyId); - for (const k of METRIC_KEYS) { - bucket.metrics[k] += keyData?.metrics?.[k] || 0; - } - }); - }); - }); - - return Object.values(aggregatedData) - .map((item) => ({ - Date: item.Date, - [entityLabel]: item.entityAlias, - [`${entityLabel} ID`]: item.entityId, - "User ID": item.userId, - "User Email": item.userEmail || "-", - Keys: item.keyIds.size, - "Spend ($)": formatNumberWithCommas(item.metrics.spend, 4), - Requests: item.metrics.api_requests, - "Successful Requests": item.metrics.successful_requests, - "Failed Requests": item.metrics.failed_requests, - "Total Tokens": item.metrics.total_tokens, - "Prompt Tokens": item.metrics.prompt_tokens, - "Completion Tokens": item.metrics.completion_tokens, - "Cache Read Input Tokens": item.metrics.cache_read_input_tokens, - "Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens, - })) - .sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateDailyWithModelsData = ( - spendData: EntitySpendData, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - const dailyModelBreakdown: any[] = []; - - spendData.results.forEach((day) => { - const dailyEntityModels: { [key: string]: { [key: string]: any } } = {}; - const dailyEntityMetadata: { [key: string]: Record | undefined } = {}; - - Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => { - if (!dailyEntityModels[entity]) { - dailyEntityModels[entity] = {}; - } - dailyEntityMetadata[entity] = entityData.metadata; - - Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => { - const entityApiKeys = entityData.api_key_breakdown || {}; - const modelApiKeys = modelData.api_key_breakdown || {}; - - Object.keys(entityApiKeys).forEach((apiKey) => { - const keyMetrics = modelApiKeys[apiKey]?.metrics; - if (!keyMetrics) return; - - if (!dailyEntityModels[entity][model]) { - dailyEntityModels[entity][model] = { - spend: 0, - requests: 0, - successful: 0, - failed: 0, - tokens: 0, - promptTokens: 0, - completionTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - }; - } - dailyEntityModels[entity][model].spend += keyMetrics.spend || 0; - dailyEntityModels[entity][model].requests += keyMetrics.api_requests || 0; - dailyEntityModels[entity][model].successful += keyMetrics.successful_requests || 0; - dailyEntityModels[entity][model].failed += keyMetrics.failed_requests || 0; - dailyEntityModels[entity][model].tokens += keyMetrics.total_tokens || 0; - dailyEntityModels[entity][model].promptTokens += keyMetrics.prompt_tokens || 0; - dailyEntityModels[entity][model].completionTokens += keyMetrics.completion_tokens || 0; - dailyEntityModels[entity][model].cacheReadInputTokens += keyMetrics.cache_read_input_tokens || 0; - dailyEntityModels[entity][model].cacheCreationInputTokens += keyMetrics.cache_creation_input_tokens || 0; - }); - }); - }); - - Object.entries(dailyEntityModels).forEach(([entity, models]) => { - const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, dailyEntityMetadata[entity]); - - Object.entries(models).forEach(([model, metrics]: [string, any]) => { - dailyModelBreakdown.push({ - Date: day.date, - [entityLabel]: alias, - [`${entityLabel} ID`]: id, - Model: model, - "Spend ($)": formatNumberWithCommas(metrics.spend, 4), - Requests: metrics.requests, - Successful: metrics.successful, - Failed: metrics.failed, - "Total Tokens": metrics.tokens, - "Prompt Tokens": metrics.promptTokens, - "Completion Tokens": metrics.completionTokens, - "Cache Read Input Tokens": metrics.cacheReadInputTokens, - "Cache Creation Input Tokens": metrics.cacheCreationInputTokens, - }); - }); - }); - }); - - return dailyModelBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime()); -}; - -export const generateExportData = ( - spendData: EntitySpendData, - exportScope: ExportScope, - entityLabel: string, - teamAliasMap: Record = {}, -): any[] => { - switch (exportScope) { - case "daily": - return generateDailyData(spendData, entityLabel, teamAliasMap); - case "daily_with_keys": - return generateDailyWithKeysData(spendData, entityLabel, teamAliasMap); - case "daily_with_models": - return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap); - case "daily_with_users": - return generateDailyWithUsersData(spendData, entityLabel, teamAliasMap); - default: - return generateDailyData(spendData, entityLabel, teamAliasMap); - } -}; - -export const generateMetadata = ( +export const exportFilename = ( entityType: EntityType, + exportType: ExportType, + format: ExportFormat, dateRange: DateRangePickerValue, - selectedFilters: string[], - exportScope: ExportScope, - spendData: EntitySpendData, -): ExportMetadata => { - const summary: ExportMetadata["summary"] = { - total_spend: spendData.metadata.total_spend, - total_requests: spendData.metadata.total_api_requests, - successful_requests: spendData.metadata.total_successful_requests, - failed_requests: spendData.metadata.total_failed_requests, - total_tokens: spendData.metadata.total_tokens, - }; - if (hasFlatCost(spendData)) { - const flatCost = spendData.metadata.total_flat_cost ?? 0; - summary.total_flat_cost = flatCost; - summary.total_cost = spendData.metadata.total_spend + flatCost; - } - return { - export_date: new Date().toISOString(), - entity_type: entityType, - date_range: { - from: dateRange.from?.toISOString(), - to: dateRange.to?.toISOString(), - }, - filters_applied: selectedFilters.length > 0 ? selectedFilters : "None", - export_scope: exportScope, - summary, - }; -}; +): string => `${entityType}_usage_${exportType}_${fileDay(dateRange.from)}_${fileDay(dateRange.to)}.${format}`; -export const handleExportCSV = ( - spendData: EntitySpendData, - exportScope: ExportScope, - entityLabel: string, - entityType: EntityType, - teamAliasMap: Record = {}, -): void => { - const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); - const csv = Papa.unparse(data); - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); +export const downloadBlob = (blob: Blob, filename: string): void => { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.csv`; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); -}; - -export const handleExportJSON = ( - spendData: EntitySpendData, - exportScope: ExportScope, - entityLabel: string, - entityType: EntityType, - dateRange: DateRangePickerValue, - selectedFilters: string[], - teamAliasMap: Record = {}, -): void => { - const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); - const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData); - const exportObject = { - metadata, - data, - }; - const jsonString = JSON.stringify(exportObject, null, 2); - const blob = new Blob([jsonString], { type: "application/json" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.json`; - a.download = fileName; + a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx new file mode 100644 index 00000000000..891690700e3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx @@ -0,0 +1,674 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { components } from "@/lib/http/schema"; +import { type DailyActivityKeyPageResponse, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi"; +import type { ModelActivityData } from "../types"; +import KeyActivityPanel from "./KeyActivityPanel"; + +let triggerIntersection: (() => void) | undefined; + +const intersectSentinel = async () => { + await waitFor(() => expect(triggerIntersection).toBeDefined()); + await act(async () => { + triggerIntersection?.(); + }); +}; + +class TestIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null = null; + readonly rootMargin = ""; + readonly thresholds: readonly number[] = []; + + constructor(private readonly callback: IntersectionObserverCallback) {} + + observe(target: Element): void { + triggerIntersection = () => + this.callback( + [ + { + boundingClientRect: target.getBoundingClientRect(), + intersectionRect: target.getBoundingClientRect(), + intersectionRatio: 1, + isIntersecting: true, + rootBounds: null, + target, + time: 0, + }, + ], + this, + ); + } + + unobserve(): void {} + + disconnect(): void { + triggerIntersection = undefined; + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } +} + +const metrics: components["schemas"]["SpendMetrics"] = { + api_requests: 2, + autorouter_savings_spend: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 3, + compression_saved_tokens: 0, + compression_savings_spend: 0, + failed_requests: 0, + flat_cost: 0, + gateway_injected_caching_savings_spend: 0, + prompt_caching_savings_spend: 0, + prompt_tokens: 4, + spend: 1.25, + successful_requests: 2, + timed_requests: 0, + total_response_time_ms: 0, + total_tokens: 7, +}; + +const pageRow = (apiKey: string): KeySpendActivityRow => ({ + api_key: apiKey, + metrics: { + api_requests: 2, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 3, + failed_requests: 0, + prompt_tokens: 4, + spend: 1.25, + successful_requests: 2, + total_tokens: 7, + }, + metadata: { key_alias: apiKey, team_id: null }, +}); + +const searchRow = (apiKey: string, alias: string): KeyActivityRow => ({ + api_key: apiKey, + metrics, + metadata: { key_alias: alias, team_id: null }, +}); + +const pageResponse = (apiKeys: KeySpendActivityRow[], total: number, offset = 0): DailyActivityKeyPageResponse => ({ + api_keys: apiKeys, + total_api_keys: total, + offset, + limit: 50, +}); + +const summary: ModelActivityData = { + label: "Overall Usage", + total_requests: 200, + total_successful_requests: 198, + total_failed_requests: 2, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 700, + prompt_tokens: 400, + completion_tokens: 300, + total_spend: 500, + total_response_time_ms: 0, + total_timed_requests: 0, + top_models: [], + daily_data: [ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + ], +}; + +const detail = (apiKey: string): ModelActivityData => ({ + label: apiKey, + total_requests: 2, + total_successful_requests: 2, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_tokens: 7, + prompt_tokens: 4, + completion_tokens: 3, + total_spend: 1.25, + total_response_time_ms: 0, + total_timed_requests: 0, + top_models: [ + { model: "gpt-4o-mini", spend: 1.25, requests: 2, successful_requests: 2, failed_requests: 0, tokens: 7 }, + ], + daily_data: [ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_response_time_ms: null, + }, + }, + ], +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + triggerIntersection = undefined; +}); + +beforeEach(() => { + vi.stubGlobal("IntersectionObserver", TestIntersectionObserver); +}); + +describe("KeyActivityPanel", () => { + it("loads the first page, appends the next page, and keeps limit copy out of the UI", async () => { + let resolveNextPage: (response: DailyActivityKeyPageResponse) => void = () => {}; + const nextPage = new Promise((resolve) => { + resolveNextPage = resolve; + }); + const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`)); + const fetchKeyPage = vi.fn((offset: number, _limit: number) => + offset === 0 ? Promise.resolve(pageResponse(firstPageRows, 52)) : nextPage, + ); + + render( + , + ); + + expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument(); + expect(screen.getByText("52 keys")).toBeInTheDocument(); + expect(screen.getByText("$500.00")).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledWith(0, 50); + expect(screen.queryByText(/limit|truncat|highest-spend|load top/i)).not.toBeInTheDocument(); + + await intersectSentinel(); + expect(fetchKeyPage).toHaveBeenCalledWith(50, 50); + expect(screen.getByText("Loading more keys...")).toBeInTheDocument(); + + await act(async () => { + resolveNextPage(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50)); + await nextPage; + }); + expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument(); + expect(screen.queryByText("Loading more keys...")).not.toBeInTheDocument(); + }); + + it("fetches full detail on first expansion and renders charts with daily data", async () => { + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-chart")], 1)); + let resolveDetail: (metrics: ModelActivityData) => void = () => {}; + const detailResponse = new Promise((resolve) => { + resolveDetail = resolve; + }); + const fetchKeyDetail = vi.fn().mockReturnValue(detailResponse); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: /key-chart/ })); + + expect(fetchKeyDetail).toHaveBeenCalledWith("key-chart"); + expect(await screen.findByText("Loading key details...")).toBeInTheDocument(); + await act(async () => { + resolveDetail(detail("key-chart")); + await detailResponse; + }); + expect(await screen.findByText("Spend per day")).toBeInTheDocument(); + expect(screen.getByText("Requests per day")).toBeInTheDocument(); + expect(screen.queryByText("No data")).not.toBeInTheDocument(); + }); + + it("shows a detail error with Retry when the detail request fails and refetches on retry", async () => { + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-broken")], 1)); + const fetchKeyDetail = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(detail("key-broken")); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: /key-broken/ })); + + expect(await screen.findByText(/Could not load key details\./)).toBeInTheDocument(); + expect(fetchKeyDetail).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Spend per day")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(fetchKeyDetail).toHaveBeenCalledTimes(2); + expect(fetchKeyDetail).toHaveBeenLastCalledWith("key-broken"); + expect(await screen.findByText("Spend per day")).toBeInTheDocument(); + expect(screen.queryByText(/Could not load key details\./)).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("shows a loader instead of zero totals while the summary is in flight", async () => { + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1)); + const zeroSummary: ModelActivityData = { ...summary, total_spend: 0, total_requests: 0, total_tokens: 0 }; + const props = { + fetchKeyPage, + fetchKeyDetail: vi.fn(), + searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }), + teams: [], + }; + const { rerender } = render(); + + expect(await screen.findByRole("button", { name: /key-local/ })).toBeInTheDocument(); + expect(screen.getByText("Loading chart data...")).toBeInTheDocument(); + expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument(); + expect(screen.getByText("Overall Usage")).toBeInTheDocument(); + expect(screen.getAllByText("$500.00").length).toBeGreaterThan(0); + }); + + it("loads details for remote search results and merges local matches", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local-remote")], 2)); + const fetchKeyDetail = vi.fn().mockResolvedValue(detail("key-remote")); + const searchKeys = vi.fn().mockResolvedValue({ + api_keys: [searchRow("key-remote", "remote server result")], + }); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + vi.useRealTimers(); + expect(searchKeys).toHaveBeenCalledWith("remote"); + expect(screen.getByText("2 matching keys")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /key-local-remote/ })).toBeInTheDocument(); + const remoteButton = screen.getByRole("button", { name: /remote server result/ }); + fireEvent.click(remoteButton); + + expect(fetchKeyDetail).toHaveBeenCalledWith("key-remote"); + expect(await screen.findByText("Spend per day")).toBeInTheDocument(); + expect(screen.queryByText("No data")).not.toBeInTheDocument(); + }); + + it("shows an error with Retry when the first page fails and recovers on retry", async () => { + let resolveRetry: (page: DailyActivityKeyPageResponse) => void = () => undefined; + const fetchKeyPage = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + render( + , + ); + + expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument(); + expect(screen.queryByText("0 keys")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 0, 50); + + await act(async () => { + resolveRetry(pageResponse([pageRow("key-after-retry")], 1)); + }); + expect(await screen.findByRole("button", { name: /key-after-retry/ })).toBeInTheDocument(); + expect(screen.queryByText("Loading keys...")).not.toBeInTheDocument(); + }); + + it("stops auto-retrying after a failed next page until Retry is clicked", async () => { + const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`)); + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse(firstPageRows, 52)) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50)); + render( + , + ); + + expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument(); + await intersectSentinel(); + expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + + await act(async () => { + triggerIntersection?.(); + }); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(3); + expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50); + }); + + it("hides the next-page Retry while searching and refetches the failed page once the search is cleared", async () => { + const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`)); + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse(firstPageRows, 52)) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50)); + render( + , + ); + + expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument(); + await intersectSentinel(); + expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "key-1" } }); + expect(screen.queryByText("Could not load more keys.")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Clear key search" })); + expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(3); + expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50); + }); + + it("shows server search matches even when the first key page failed", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockRejectedValue(new Error("boom")); + const searchKeys = vi.fn().mockResolvedValue({ + api_keys: [searchRow("key-remote", "remote server result")], + }); + render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + vi.useRealTimers(); + + expect(searchKeys).toHaveBeenCalledWith("remote"); + expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument(); + expect(screen.getByText("1 matching keys")).toBeInTheDocument(); + expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument(); + }); + + it("keeps the loader and the first page error visible for a short query instead of No keys match", async () => { + let resolveFirstPage: (page: DailyActivityKeyPageResponse) => void = () => undefined; + const fetchKeyPage = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstPage = resolve; + }), + ); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } }); + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument(); + expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument(); + + await act(async () => { + resolveFirstPage(pageResponse([pageRow("key-alpha")], 1)); + }); + expect(await screen.findByRole("button", { name: /key-alpha/ })).toBeInTheDocument(); + expect(screen.getByText("1 matching keys")).toBeInTheDocument(); + + cleanup(); + const failingFetch = vi.fn().mockRejectedValue(new Error("boom")); + render( + , + ); + expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } }); + expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument(); + expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument(); + expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument(); + }); + + it("does not restart an in-flight search when the parent passes a new teams array", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1)); + let resolveSearch: (response: { api_keys: KeyActivityRow[] }) => void = () => undefined; + const searchKeys = vi.fn().mockImplementation( + () => + new Promise<{ api_keys: KeyActivityRow[] }>((resolve) => { + resolveSearch = resolve; + }), + ); + const props = { + summary, + fetchKeyPage, + fetchKeyDetail: vi.fn().mockResolvedValue(detail("key-remote")), + searchKeys, + }; + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + + rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSearch({ api_keys: [searchRow("key-remote", "remote server result")] }); + }); + vi.useRealTimers(); + expect(await screen.findByRole("button", { name: /remote server result/ })).toBeInTheDocument(); + expect(screen.getByText("1 matching keys")).toBeInTheDocument(); + }); + + it("keeps paging past a page of already loaded keys by server offset instead of loaded row count", async () => { + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5)) + .mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5, 2)) + .mockResolvedValueOnce(pageResponse([pageRow("key-5")], 5, 4)); + render( + , + ); + expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument(); + + await intersectSentinel(); + expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 2, 50); + await intersectSentinel(); + expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 4, 50); + expect(await screen.findByRole("button", { name: /key-5/ })).toBeInTheDocument(); + expect(fetchKeyPage).toHaveBeenCalledTimes(3); + expect(screen.getAllByRole("button", { name: /key-/ })).toHaveLength(3); + }); + + it("stops requesting more keys when a later page is empty even though the total says more exist", async () => { + const fetchKeyPage = vi + .fn() + .mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 3)) + .mockResolvedValue(pageResponse([], 3, 2)); + render( + , + ); + expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument(); + + await intersectSentinel(); + await act(async () => { + triggerIntersection?.(); + }); + expect(fetchKeyPage).toHaveBeenCalledTimes(2); + expect(fetchKeyPage).toHaveBeenLastCalledWith(2, 50); + expect(triggerIntersection).toBeUndefined(); + expect(screen.getByText("3 keys")).toBeInTheDocument(); + }); + + it("shows a search error with Retry search instead of No keys match when the search fails", async () => { + vi.useFakeTimers(); + const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1)); + const searchKeys = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue({ api_keys: [searchRow("key-remote", "remote server result")] }); + render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Could not search keys\./)).toBeInTheDocument(); + expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument(); + expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument(); + expect(screen.getByText("Search failed")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry search" })); + expect(screen.getByText("Searching...")).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + vi.useRealTimers(); + + expect(searchKeys).toHaveBeenCalledTimes(2); + expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument(); + expect(screen.queryByText(/Could not search keys\./)).not.toBeInTheDocument(); + }); + + it("discards stale pages and clears loaded keys when the scope changes", async () => { + let resolveOldPage: (response: DailyActivityKeyPageResponse) => void = () => {}; + const oldPage = new Promise((resolve) => { + resolveOldPage = resolve; + }); + const firstScopeFetch = vi.fn().mockReturnValue(oldPage); + const secondScopeFetch = vi.fn().mockResolvedValue(pageResponse([pageRow("new-scope-key")], 1)); + const props = { + summary, + fetchKeyDetail: vi.fn().mockResolvedValue(detail("new-scope-key")), + searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }), + teams: [], + }; + const { rerender } = render(); + rerender(); + + expect(await screen.findByRole("button", { name: /new-scope-key/ })).toBeInTheDocument(); + await act(async () => { + resolveOldPage(pageResponse([pageRow("old-scope-key")], 1)); + await oldPage; + }); + expect(screen.queryByRole("button", { name: /old-scope-key/ })).not.toBeInTheDocument(); + expect(secondScopeFetch).toHaveBeenCalledWith(0, 50); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx deleted file mode 100644 index 693ac20a360..00000000000 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import type { ModelActivityData } from "../types"; -import KeyActivityPanel from "./KeyActivityPanel"; - -vi.mock("@/components/activity_metrics", () => ({ - ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record }) => ( -
    - {Object.keys(modelMetrics).map((hash) => ( -
  • {hash}
  • - ))} -
- ), -})); - -function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData { - return { - label, - key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email }, - total_requests: 1, - total_successful_requests: 1, - total_failed_requests: 0, - total_cache_read_input_tokens: 0, - total_cache_creation_input_tokens: 0, - total_tokens: 10, - prompt_tokens: 5, - completion_tokens: 5, - total_spend: 0.01, - top_api_keys: [], - top_models: [], - daily_data: [], - }; -} - -const keyMetrics: Record = { - "hash-alice": activity("alice-key", "alice@example.com", "user-alice"), - "hash-bob": activity("bob-key", "bob@example.com", "user-bob"), -}; - -describe("KeyActivityPanel", () => { - it("renders every key and the full count before searching", () => { - render(); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob"); - expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument(); - }); - - it("narrows the rendered keys to those matching the user email", () => { - render(); - fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } }); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob"); - expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice"); - expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument(); - }); - - it("shows an empty state instead of zeroed metrics when nothing matches", () => { - render(); - fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } }); - expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument(); - expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument(); - }); - - it("clears the search and restores every key", () => { - render(); - fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } }); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice"); - fireEvent.click(screen.getByLabelText("Clear key search")); - expect(screen.getByLabelText("Search keys")).toHaveValue(""); - expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob"); - }); -}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 8287a04d0c7..47226b1c2e4 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -1,27 +1,397 @@ import { Search, X } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ActivityMetrics } from "@/components/activity_metrics"; +import { ActivityMetrics, ModelCollapsible, ModelSection } from "@/components/activity_metrics"; +import type { Team } from "@/components/key_team_helpers/key_list"; +import { ChartLoader } from "@/components/shared/chart_loader"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import type { + DailyActivityKeyPageResponse, + DailyActivityKeySearchResponse, + KeyActivityRow, + KeySpendActivityRow, +} from "../dailyActivityApi"; import { filterKeyActivity } from "../keyActivityFilter"; +import { keyActivityRowsToMetrics } from "./keySearch"; +import { mergeKeyActivityPages } from "../keyActivityData"; import type { ModelActivityData } from "../types"; +const PAGE_SIZE = 50; +const SEARCH_DEBOUNCE_MS = 300; +const MIN_SEARCH_LENGTH = 2; + +type FetchKeyPage = (offset: number, limit: number) => Promise; +type FetchKeyDetail = (apiKey: string) => Promise; +type SearchKeys = (search: string) => Promise; + interface KeyActivityPanelProps { - keyMetrics: Record; + summary: ModelActivityData; + summaryLoading?: boolean; + fetchKeyPage: FetchKeyPage; + fetchKeyDetail: FetchKeyDetail; + searchKeys: SearchKeys; + teams: Team[]; hidePromptCachingMetrics?: boolean; } -const KeyActivityPanel: React.FC = ({ keyMetrics, hidePromptCachingMetrics = false }) => { - const [query, setQuery] = useState(""); - const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]); - const totalKeys = Object.keys(keyMetrics).length; - const shownKeys = Object.keys(filtered).length; - const isFiltering = query.trim() !== ""; +interface KeyPageState { + scope: FetchKeyPage; + rows: KeySpendActivityRow[]; + total: number; + nextOffset: number; + hasMore: boolean; + loading: boolean; + loadingMore: boolean; + failed: boolean; +} + +type KeyDetailState = + | { status: "loading" } + | { status: "failed" } + | { status: "loaded"; metrics: ModelActivityData | undefined }; + +const keyCountText = (searching: boolean, isFiltering: boolean, shownKeys: number, total: number): string => { + if (searching) return "Searching..."; + if (isFiltering) return `${shownKeys.toLocaleString()} matching keys`; + return `${total.toLocaleString()} keys`; +}; + +interface KeyDetailContentProps { + apiKey: string; + detail: KeyDetailState | undefined; + hidePromptCachingMetrics: boolean; + onRetry: () => void; +} + +const KeyDetailContent: React.FC = ({ apiKey, detail, hidePromptCachingMetrics, onRetry }) => { + if (detail?.status === "loading") { + return

Loading key details...

; + } + if (detail?.status === "failed") { + return ( +

+ Could not load key details.{" "} + +

+ ); + } + if (detail?.status !== "loaded" || detail.metrics === undefined) { + return

No daily details available

; + } + return ( + + ); +}; + +const KeyActivityPanel: React.FC = ({ + summary, + summaryLoading = false, + fetchKeyPage, + fetchKeyDetail, + searchKeys, + teams, + hidePromptCachingMetrics = false, +}) => { + const [queryState, setQueryState] = useState<{ scope: FetchKeyPage; value: string } | null>(null); + const query = queryState?.scope === fetchKeyPage ? queryState.value : ""; + const updateQuery = useCallback((value: string) => setQueryState({ scope: fetchKeyPage, value }), [fetchKeyPage]); + const [pageState, setPageState] = useState(null); + const [detailState, setDetailState] = useState<{ + scope: FetchKeyPage; + details: Record; + } | null>(null); + const [searchResult, setSearchResult] = useState<{ + scope: FetchKeyPage; + term: string; + searchKeys: SearchKeys; + rows: KeyActivityRow[]; + failed: boolean; + } | null>(null); + const [retryToken, setRetryToken] = useState(0); + const [searchRetryToken, setSearchRetryToken] = useState(0); + const searchIdRef = useRef(0); + const pageRequestIdRef = useRef(0); + const loadingMoreRef = useRef(false); + const activeFetcherRef = useRef(fetchKeyPage); + const sentinelRef = useRef(null); + + useLayoutEffect(() => { + activeFetcherRef.current = fetchKeyPage; + }, [fetchKeyPage]); + + useEffect(() => { + const requestId = ++pageRequestIdRef.current; + loadingMoreRef.current = false; + void fetchKeyPage(0, PAGE_SIZE) + .then((page) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + const loadedPageState = { + scope: fetchKeyPage, + rows: page.api_keys, + total: page.total_api_keys, + nextOffset: page.api_keys.length, + hasMore: page.api_keys.length > 0 && page.api_keys.length < page.total_api_keys, + loading: false, + loadingMore: false, + failed: false, + }; + setPageState(loadedPageState); + }) + .catch((error: unknown) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + console.error("Key activity page request failed:", error); + const failedPageState = { + scope: fetchKeyPage, + rows: [], + total: 0, + nextOffset: 0, + hasMore: false, + loading: false, + loadingMore: false, + failed: true, + }; + setPageState(failedPageState); + }); + return () => { + if (pageRequestIdRef.current === requestId) pageRequestIdRef.current += 1; + }; + }, [fetchKeyPage, retryToken]); + + const currentPageState = pageState?.scope === fetchKeyPage ? pageState : null; + const pageRows = useMemo(() => currentPageState?.rows ?? [], [currentPageState]); + const loading = currentPageState?.loading ?? true; + const loadingMore = currentPageState?.loadingMore ?? false; + const hasMore = currentPageState?.hasMore ?? false; + const total = currentPageState?.total ?? 0; + const failed = currentPageState?.failed ?? false; + const pageMetrics = useMemo(() => keyActivityRowsToMetrics(pageRows, teams), [pageRows, teams]); + + const loadMore = useCallback(() => { + if (currentPageState === null) return; + if (currentPageState.loading || currentPageState.loadingMore || !currentPageState.hasMore) return; + if (query.trim() !== "" || loadingMoreRef.current) return; + const requestId = pageRequestIdRef.current; + const offset = currentPageState.nextOffset; + loadingMoreRef.current = true; + setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, loadingMore: true } : current)); + void fetchKeyPage(offset, PAGE_SIZE) + .then((page) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + setPageState((current) => { + if (current?.scope !== fetchKeyPage) return current; + const merged = mergeKeyActivityPages(current.rows, page.api_keys, page.total_api_keys, offset); + return { + ...current, + rows: merged.rows, + total: page.total_api_keys, + nextOffset: merged.nextOffset, + hasMore: merged.hasMore, + loadingMore: false, + failed: false, + }; + }); + }) + .catch((error: unknown) => { + if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return; + console.error("Key activity page request failed:", error); + setPageState((current) => + current?.scope === fetchKeyPage ? { ...current, loadingMore: false, failed: true } : current, + ); + }) + .finally(() => { + if (pageRequestIdRef.current === requestId) loadingMoreRef.current = false; + }); + }, [currentPageState, fetchKeyPage, query]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (sentinel === null || currentPageState === null) return; + if (currentPageState.failed) return; + if (loading || loadingMore || !hasMore) return; + if (query.trim() !== "") return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) loadMore(); + }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [currentPageState, hasMore, loadMore, loading, loadingMore, query]); + + const trimmedQuery = query.trim(); + const searchTerm = trimmedQuery.length >= MIN_SEARCH_LENGTH ? trimmedQuery : null; + useEffect(() => { + if (searchTerm === null) return; + const searchId = ++searchIdRef.current; + const timer = setTimeout(() => { + void searchKeys(searchTerm) + .then((response) => { + if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return; + const result = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: response.api_keys, failed: false }; + setSearchResult(result); + }) + .catch((error: unknown) => { + if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return; + console.error("Key activity search failed:", error); + const failedResult = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: [], failed: true }; + setSearchResult(failedResult); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + clearTimeout(timer); + if (searchIdRef.current === searchId) searchIdRef.current += 1; + }; + }, [fetchKeyPage, searchKeys, searchRetryToken, searchTerm]); + + const currentSearch = + searchResult?.scope === fetchKeyPage && searchResult.term === searchTerm && searchResult.searchKeys === searchKeys + ? searchResult + : null; + const searching = searchTerm !== null && currentSearch === null; + const searchFailed = currentSearch?.failed ?? false; + const searchRows = currentSearch?.rows; + const searchMetrics = useMemo(() => keyActivityRowsToMetrics(searchRows ?? [], teams), [searchRows, teams]); + const localFiltered = useMemo(() => filterKeyActivity(pageMetrics, query), [pageMetrics, query]); + const filteredMetrics = useMemo(() => ({ ...searchMetrics, ...localFiltered }), [searchMetrics, localFiltered]); + const shownKeys = Object.keys(filteredMetrics).length; + const isFiltering = trimmedQuery.length > 0; + const visibleDetails = detailState?.scope === fetchKeyPage ? detailState.details : {}; + const keyCountLabel = + isFiltering && searchFailed && !searching && shownKeys === 0 + ? "Search failed" + : keyCountText(searching, isFiltering, shownKeys, total); + + const retryFirstPage = () => { + setPageState({ + scope: fetchKeyPage, + rows: [], + total: 0, + nextOffset: 0, + hasMore: false, + loading: true, + loadingMore: false, + failed: false, + }); + setRetryToken((token) => token + 1); + }; + const retrySearch = () => { + setSearchResult(null); + setSearchRetryToken((token) => token + 1); + }; + const retryNextPage = () => { + setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, failed: false } : current)); + loadMore(); + }; + const firstPageError = ( +

+ Could not load keys for this range.{" "} + +

+ ); + const emptyListBody = (() => { + if (failed) return firstPageError; + if (!loading) return null; + return

Loading keys...

; + })(); + const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => { + const detail = visibleDetails[apiKey]; + return ( + loadKeyDetail(apiKey)} + header={ +
+ {metrics.label} + + + $ + {metrics.total_spend.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {metrics.total_requests.toLocaleString()} requests + +
+ } + > + loadKeyDetail(apiKey)} + /> +
+ ); + }); + const firstPageFailed = failed && pageRows.length === 0; + const showNextPageRetry = failed && !firstPageFailed && !isFiltering; + const localOnly = searchTerm === null; + const pageUnavailable = localOnly && pageRows.length === 0 && (loading || firstPageFailed); + const listBody = pageUnavailable || (!isFiltering && pageRows.length === 0) ? emptyListBody : keyList; + const noMatches = isFiltering && !searching && !pageUnavailable && shownKeys === 0; + const showSearchErrorNote = !noMatches && searchFailed && !searching; + const searchRetryButton = ( + + ); + const emptyFilterBody = searchFailed ? ( +

+ Could not search keys. {searchRetryButton} +

+ ) : ( +

+ No keys match "{trimmedQuery}" in this date range +

+ ); + + const loadKeyDetail = useCallback( + (apiKey: string) => { + const existingDetails = detailState?.scope === fetchKeyPage ? detailState.details : {}; + const existing = existingDetails[apiKey]; + if (existing !== undefined && existing.status !== "failed") return; + setDetailState((current) => { + const currentDetails = current?.scope === fetchKeyPage ? current.details : {}; + return { scope: fetchKeyPage, details: { ...currentDetails, [apiKey]: { status: "loading" } } }; + }); + void fetchKeyDetail(apiKey) + .then((metrics) => { + setDetailState((current) => { + if (current?.scope !== fetchKeyPage) return current; + return { scope: fetchKeyPage, details: { ...current.details, [apiKey]: { status: "loaded", metrics } } }; + }); + }) + .catch((error: unknown) => { + console.error("Key activity detail request failed:", error); + setDetailState((current) => { + if (current?.scope !== fetchKeyPage) return current; + return { + scope: fetchKeyPage, + details: { ...current.details, [apiKey]: { status: "failed" } }, + }; + }); + }); + }, + [detailState, fetchKeyDetail, fetchKeyPage], + ); return (
-
+ {summaryLoading ? ( + + ) : ( + + )} +
@@ -30,27 +400,36 @@ const KeyActivityPanel: React.FC = ({ keyMetrics, hidePro aria-label="Search keys" placeholder="Search by key alias, key hash, user ID, or email" value={query} - onChange={(e) => setQuery(e.target.value)} + onChange={(event) => updateQuery(event.target.value)} /> {isFiltering && ( - setQuery("")}> + updateQuery("")}> )} - - Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys - + {!pageUnavailable && ( + + {keyCountLabel} + + )}
- {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( -

- No keys match "{query.trim()}" in this date range -

- ) : ( - + {noMatches ? emptyFilterBody :
{listBody}
} + {showSearchErrorNote && ( +

Could not search all keys. {searchRetryButton}

)} + {loadingMore &&

Loading more keys...

} + {showNextPageRetry && ( +

+ Could not load more keys.{" "} + +

+ )} + {!isFiltering && ); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts b/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts new file mode 100644 index 00000000000..fc0772f159c --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts @@ -0,0 +1,33 @@ +import type { Team } from "@/components/key_team_helpers/key_list"; +import { toKeyMetadata, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi"; +import { formatKeyLabel } from "@/components/activity_metrics"; +import type { ModelActivityData } from "../types"; + +export const keyActivityRowsToMetrics = ( + rows: readonly (KeyActivityRow | KeySpendActivityRow)[], + teams: Team[], +): Record => + Object.fromEntries( + rows.map((row) => { + const metadata = toKeyMetadata(row.metadata); + const metrics: ModelActivityData = { + label: formatKeyLabel({ metadata }, row.api_key, teams), + key_metadata: metadata, + total_requests: row.metrics.api_requests, + total_successful_requests: row.metrics.successful_requests, + total_failed_requests: row.metrics.failed_requests, + total_cache_read_input_tokens: row.metrics.cache_read_input_tokens, + total_cache_creation_input_tokens: row.metrics.cache_creation_input_tokens, + total_tokens: row.metrics.total_tokens, + prompt_tokens: row.metrics.prompt_tokens, + completion_tokens: row.metrics.completion_tokens, + total_spend: row.metrics.spend, + total_response_time_ms: + "total_response_time_ms" in row.metrics ? row.metrics.total_response_time_ms : undefined, + total_timed_requests: "timed_requests" in row.metrics ? row.metrics.timed_requests : undefined, + top_models: [], + daily_data: [], + }; + return [row.api_key, metrics]; + }), + ); diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts new file mode 100644 index 00000000000..36c2837d9ac --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -0,0 +1,101 @@ +import type { components } from "@/lib/http/schema"; +import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types"; + +export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent"; +export type DailyActivityAggregatedResponse = components["schemas"]["SpendAnalyticsPaginatedResponse"]; +export type DailyActivityMetadata = components["schemas"]["DailySpendMetadata"]; +export type ExportType = components["schemas"]["ExportType"]; +export type ExportFormat = "csv" | "json"; + +export type KeyActivityRow = components["schemas"]["KeyActivityRow"]; +export type KeySpendActivityRow = components["schemas"]["KeySpendActivityRow"]; +export type DailyActivityKeySearchResponse = components["schemas"]["DailyActivityKeySearchResponse"]; +export type DailyActivityKeyPageResponse = components["schemas"]["DailyActivityKeyPageResponse"]; +export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"]; +export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"]; + +export interface DailyActivityRequest { + accessToken: string; + startTime: Date; + endTime: Date; + entityIds?: readonly string[] | null; + excludeEntityIds?: readonly string[]; + apiKey?: string | null; + model?: string | null; + includeCurrentUtcDay?: boolean; + apiKeyLimit?: number; +} + +export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = { + has_more: false, + page: 1, + total_pages: 1, + total_spend: 0, + total_flat_cost: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + total_prompt_tokens: 0, + total_completion_tokens: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_compression_saved_tokens: 0, + total_compression_savings_spend: 0, + total_prompt_caching_savings_spend: 0, + total_gateway_injected_caching_savings_spend: 0, + total_autorouter_savings_spend: 0, + total_response_time_ms: 0, + total_timed_requests: 0, +}; + +export const EMPTY_DAILY_ACTIVITY_RESPONSE: DailyActivityAggregatedResponse = { + results: [], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, +}; + +type SchemaMetricWithMetadata = components["schemas"]["MetricWithMetadata"]; +type SchemaKeyMetricWithMetadata = components["schemas"]["KeyMetricWithMetadata"]; + +const toKeyMetric = (entry: SchemaKeyMetricWithMetadata): KeyMetricWithMetadata => ({ + metrics: entry.metrics, + metadata: toKeyMetadata(entry.metadata), +}); + +export const toKeyMetadata = (metadata: components["schemas"]["KeyMetadata"] | undefined): KeyMetadata => ({ + key_alias: metadata?.key_alias ?? null, + team_id: metadata?.team_id ?? null, + user_id: metadata?.user_id, + user_email: metadata?.user_email, + key_exists: metadata?.key_exists, +}); + +const toMetric = (entry: SchemaMetricWithMetadata): MetricWithMetadata => ({ + metrics: entry.metrics, + metadata: entry.metadata ?? {}, + api_key_breakdown: Object.fromEntries( + Object.entries(entry.api_key_breakdown ?? {}).map(([key, value]) => [key, toKeyMetric(value)]), + ), +}); + +const toMetricMap = ( + map: { [key: string]: SchemaMetricWithMetadata } | undefined, +): { [key: string]: MetricWithMetadata } => + Object.fromEntries(Object.entries(map ?? {}).map(([key, value]) => [key, toMetric(value)])); + +export const toDailyData = (response: DailyActivityAggregatedResponse): DailyData[] => + (response.results ?? []).map((day) => { + const breakdown = day.breakdown; + const normalized: BreakdownMetrics = { + models: toMetricMap(breakdown?.models), + model_groups: toMetricMap(breakdown?.model_groups), + mcp_servers: toMetricMap(breakdown?.mcp_servers), + providers: toMetricMap(breakdown?.providers), + api_keys: Object.fromEntries( + Object.entries(breakdown?.api_keys ?? {}).map(([key, value]) => [key, toKeyMetric(value)]), + ), + entities: toMetricMap(breakdown?.entities), + endpoints: toMetricMap(breakdown?.endpoints), + }; + return { date: day.date, metrics: day.metrics, breakdown: normalized }; + }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts new file mode 100644 index 00000000000..b35ea72896a --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { components } from "@/lib/http/schema"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + type DailyActivityAggregatedResponse, + type KeySpendActivityRow, + toDailyData, +} from "./dailyActivityApi"; +import { keyDetailFromResponse, mergeKeyActivityPages, overallUsageMetrics } from "./keyActivityData"; + +const completeMetrics: components["schemas"]["SpendMetrics"] = { + api_requests: 2, + autorouter_savings_spend: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 3, + compression_saved_tokens: 0, + compression_savings_spend: 0, + failed_requests: 0, + flat_cost: 0, + gateway_injected_caching_savings_spend: 0, + prompt_caching_savings_spend: 0, + prompt_tokens: 4, + spend: 1.25, + successful_requests: 2, + timed_requests: 0, + total_response_time_ms: 0, + total_tokens: 7, +}; + +const apiKeyActivity = { + metrics: completeMetrics, + metadata: { key_alias: "visible-key", team_id: null }, +}; + +const aggregatedResponse: DailyActivityAggregatedResponse = { + metadata: { + ...EMPTY_DAILY_ACTIVITY_METADATA, + total_api_requests: 200, + total_successful_requests: 198, + total_failed_requests: 2, + total_tokens: 700, + total_prompt_tokens: 400, + total_completion_tokens: 300, + total_spend: 500, + }, + results: [ + { + date: "2026-09-27", + metrics: completeMetrics, + breakdown: { + api_keys: { "key-hash": apiKeyActivity }, + models: { + "gpt-4o-mini": { + metrics: completeMetrics, + metadata: {}, + api_key_breakdown: { "key-hash": apiKeyActivity }, + }, + }, + }, + }, + ], +}; + +const pageRow = (api_key: string): KeySpendActivityRow => ({ + api_key, + metrics: { + api_requests: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + completion_tokens: 2, + failed_requests: 0, + prompt_tokens: 3, + spend: 1, + successful_requests: 1, + total_tokens: 5, + }, + metadata: { key_alias: api_key, team_id: null }, +}); + +describe("key activity data", () => { + it("builds overall totals from metadata and daily data from complete daily metrics", () => { + const summary = overallUsageMetrics( + toDailyData(aggregatedResponse), + aggregatedResponse.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, + ); + + expect(summary.total_requests).toBe(200); + expect(summary.total_spend).toBe(500); + expect(summary.daily_data).toStrictEqual([ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_response_time_ms: null, + }, + }, + ]); + }); + + it("appends pages without duplicate keys and compares the server offset to the total", () => { + const merged = mergeKeyActivityPages( + [pageRow("key-a"), pageRow("key-b")], + [pageRow("key-b"), pageRow("key-c")], + 5, + 2, + ); + + expect(merged.rows.map((row) => row.api_key)).toStrictEqual(["key-a", "key-b", "key-c"]); + expect(merged.nextOffset).toBe(4); + expect(merged.hasMore).toBe(true); + expect(mergeKeyActivityPages(merged.rows, [pageRow("key-d")], 5, 4).hasMore).toBe(false); + }); + + it("advances past a page of already loaded keys and stops only on an empty page", () => { + const current = [pageRow("key-a"), pageRow("key-b")]; + const duplicates = mergeKeyActivityPages(current, [pageRow("key-a"), pageRow("key-b")], 5, 2); + expect(duplicates).toEqual({ rows: current, nextOffset: 4, hasMore: true }); + expect(mergeKeyActivityPages(current, [], 5, 2)).toEqual({ rows: current, nextOffset: 2, hasMore: false }); + }); + + it("builds full daily details and top models for the requested key", () => { + const detail = keyDetailFromResponse(aggregatedResponse, "key-hash", []); + + expect(detail?.total_requests).toBe(2); + expect(detail?.daily_data).toStrictEqual([ + { + date: "2026-09-27", + metrics: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + api_requests: 2, + spend: 1.25, + successful_requests: 2, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + avg_response_time_ms: null, + }, + }, + ]); + expect(detail?.top_models.map((model) => model.model)).toStrictEqual(["gpt-4o-mini"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts new file mode 100644 index 00000000000..4b2e8c573f2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts @@ -0,0 +1,61 @@ +import type { Team } from "@/components/key_team_helpers/key_list"; +import { processActivityData } from "@/components/activity_metrics"; +import type { DailyActivityAggregatedResponse, DailyActivityMetadata, KeySpendActivityRow } from "./dailyActivityApi"; +import { toDailyData } from "./dailyActivityApi"; +import type { ModelActivityData } from "./types"; + +export const overallUsageMetrics = ( + results: ReturnType, + metadata: DailyActivityMetadata, +): ModelActivityData => ({ + label: "Overall Usage", + total_requests: metadata.total_api_requests, + total_successful_requests: metadata.total_successful_requests, + total_failed_requests: metadata.total_failed_requests, + total_cache_read_input_tokens: metadata.total_cache_read_input_tokens, + total_cache_creation_input_tokens: metadata.total_cache_creation_input_tokens, + total_tokens: metadata.total_tokens, + prompt_tokens: metadata.total_prompt_tokens, + completion_tokens: metadata.total_completion_tokens, + total_spend: metadata.total_spend, + total_response_time_ms: metadata.total_response_time_ms, + total_timed_requests: metadata.total_timed_requests, + top_models: [], + daily_data: results.map((day) => ({ + date: day.date, + metrics: { + prompt_tokens: day.metrics.prompt_tokens, + completion_tokens: day.metrics.completion_tokens, + total_tokens: day.metrics.total_tokens, + api_requests: day.metrics.api_requests, + spend: day.metrics.spend, + successful_requests: day.metrics.successful_requests, + failed_requests: day.metrics.failed_requests, + cache_read_input_tokens: day.metrics.cache_read_input_tokens, + cache_creation_input_tokens: day.metrics.cache_creation_input_tokens, + avg_response_time_ms: + day.metrics.timed_requests && day.metrics.timed_requests > 0 + ? (day.metrics.total_response_time_ms ?? 0) / day.metrics.timed_requests + : null, + }, + })), +}); + +export const mergeKeyActivityPages = ( + current: readonly KeySpendActivityRow[], + next: readonly KeySpendActivityRow[], + total: number, + offset: number, +): { rows: KeySpendActivityRow[]; nextOffset: number; hasMore: boolean } => { + const rows: KeySpendActivityRow[] = Array.from( + new Map([...current, ...next].map((row) => [row.api_key, row])).values(), + ); + const nextOffset = offset + next.length; + return { rows, nextOffset, hasMore: next.length > 0 && nextOffset < total }; +}; + +export const keyDetailFromResponse = ( + response: DailyActivityAggregatedResponse, + apiKey: string, + teams: Team[], +): ModelActivityData | undefined => processActivityData({ results: toDailyData(response) }, "api_keys", teams)[apiKey]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts index ce181f6b0c6..fbf45139e95 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts @@ -16,7 +16,6 @@ function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData prompt_tokens: 5, completion_tokens: 5, total_spend: 0.01, - top_api_keys: [], top_models: [], daily_data: [], }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index 6d0e0642ba2..06f1856a5a6 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -54,16 +54,6 @@ export interface KeyMetadata { tags?: { tag: string; usage: number }[]; } -export interface TopApiKeyData { - api_key: string; - key_alias: string | null; - team_id: string | null; - user: string | null; - spend: number; - requests: number; - tokens: number; -} - export interface TopModelData { model: string; spend: number; @@ -87,7 +77,6 @@ export interface ModelActivityData { total_spend: number; total_response_time_ms?: number; total_timed_requests?: number; - top_api_keys: TopApiKeyData[]; top_models: TopModelData[]; daily_data: { date: string; diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index 569f69adb56..8ce18884718 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { ActivityMetrics, formatKeyLabel, processActivityData, ResponseTimeTooltip } from "./activity_metrics"; @@ -120,7 +120,6 @@ const createMockModelActivityData = (label: string, overrides: Partial { expect(tokenElements.length).toBeGreaterThan(0); }); - it("should not display Top Virtual Keys section when model has no top_api_keys", () => { - render(); - expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument(); + it("only fetches top keys for sections that have been expanded", async () => { + const fetchTopApiKeys = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-123", + metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 }, + metadata: { key_alias: "Test Key", team_id: "team1" }, + }, + ], + }); + render( + , + ); + + expect(await screen.findAllByText("Test Key")).toHaveLength(1); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(1); + expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-4"); + + fireEvent.click(screen.getAllByText("GPT-3.5")[0]); + + await waitFor(() => expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-3.5")); + expect(await screen.findAllByText("Test Key")).toHaveLength(2); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(2); }); - it("should display top API keys section when present", () => { - const modelWithTopKeys: Record = { - "gpt-4": { - ...mockModelMetrics["gpt-4"], - top_api_keys: [ - { - api_key: "key-123", - key_alias: "Test Key", - team_id: "team1", - user: "owner@example.com", - spend: 50.25, - requests: 25, - tokens: 12500, - }, - { - api_key: "key-456", - key_alias: "Owner Alias", - team_id: null, - user: "Owner Alias", - spend: 40.25, - requests: 20, - tokens: 10000, - }, - ], - }, - }; + it("renders the keys the model_top_keys route returns", async () => { + const fetchTopApiKeys = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-123", + metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 }, + metadata: { key_alias: "Test Key", team_id: "team1", user_email: "owner@example.com" }, + }, + { + api_key: "key-456", + metrics: { ...EMPTY_SPEND_METRICS, spend: 40.25, api_requests: 20, total_tokens: 10000 }, + metadata: { key_alias: "Owner Alias", team_id: null, user_id: "Owner Alias" }, + }, + { + api_key: "key-7890123456", + metrics: { ...EMPTY_SPEND_METRICS, spend: 30, api_requests: 10, total_tokens: 5000 }, + metadata: { key_alias: null, team_id: null, user_id: "owner-id-3" }, + }, + ], + }); + render(); - render(); - expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument(); - expect(screen.getByText("Test Key")).toBeInTheDocument(); + expect(await screen.findByText("Top Virtual Keys by Spend")).toBeInTheDocument(); + expect(await screen.findByText("Test Key")).toBeInTheDocument(); + expect(screen.getByText(/Team: team1/)).toBeInTheDocument(); expect(screen.getByText("User: owner@example.com")).toBeInTheDocument(); expect(screen.getByText("Owner Alias")).toBeInTheDocument(); expect(screen.queryByText("User: Owner Alias")).not.toBeInTheDocument(); + expect(screen.getByText(/key-789012/)).toBeInTheDocument(); + expect(screen.getByText("User: owner-id-3")).toBeInTheDocument(); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(1); }); - it("should display API key hash when alias is missing", () => { - const modelWithTopKeys: Record = { - "gpt-4": { - ...mockModelMetrics["gpt-4"], - top_api_keys: [ - { - api_key: "key-1234567890", - key_alias: null, - team_id: null, - user: null, - spend: 50.25, - requests: 25, - tokens: 12500, - }, - ], - }, - }; + it("refetches and shows loading again when the fetcher identity changes on an expanded model", async () => { + const firstFetch = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-old", + metrics: { ...EMPTY_SPEND_METRICS, spend: 10, api_requests: 5, total_tokens: 100 }, + metadata: { key_alias: "Old Scope Key", team_id: null }, + }, + ], + }); + const secondFetch = vi.fn().mockResolvedValue({ + api_keys: [ + { + api_key: "key-new", + metrics: { ...EMPTY_SPEND_METRICS, spend: 20, api_requests: 7, total_tokens: 200 }, + metadata: { key_alias: "New Scope Key", team_id: null }, + }, + ], + }); + const { rerender } = render(); - render(); - expect(screen.getByText(/key-123456/)).toBeInTheDocument(); + expect(await screen.findByText("Old Scope Key")).toBeInTheDocument(); + expect(firstFetch).toHaveBeenCalledWith("gpt-4"); + + rerender(); + + expect(screen.getByText("Loading top keys...")).toBeInTheDocument(); + expect(screen.queryByText("Old Scope Key")).not.toBeInTheDocument(); + expect(await screen.findByText("New Scope Key")).toBeInTheDocument(); + expect(secondFetch).toHaveBeenCalledWith("gpt-4"); }); - it("should display team information for top API keys", () => { - const modelWithTopKeys: Record = { - "gpt-4": { - ...mockModelMetrics["gpt-4"], - top_api_keys: [ + it("shows an error with Retry when the top keys request fails and refetches on retry", async () => { + const fetchTopApiKeys = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ + api_keys: [ { - api_key: "key-123", - key_alias: "Test Key", - team_id: "team1", - user: null, - spend: 50.25, - requests: 25, - tokens: 12500, + api_key: "key-retry", + metrics: { ...EMPTY_SPEND_METRICS, spend: 5, api_requests: 1, total_tokens: 10 }, + metadata: { key_alias: "Retried Key", team_id: null }, }, ], - }, - }; + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render(); - render(); - expect(screen.getByText(/Team: team1/)).toBeInTheDocument(); + expect(await screen.findByText(/Could not load top keys\./)).toBeInTheDocument(); + expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument(); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(screen.getByText("Loading top keys...")).toBeInTheDocument(); + expect(await screen.findByText("Retried Key")).toBeInTheDocument(); + expect(fetchTopApiKeys).toHaveBeenCalledTimes(2); + expect(fetchTopApiKeys).toHaveBeenLastCalledWith("gpt-4"); + expect(screen.queryByText(/Could not load top keys\./)).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("hides the top keys section without a fetcher", () => { + render(); + expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument(); }); it("should display Model Usage when model has top_models", () => { @@ -386,7 +431,6 @@ describe("ActivityMetrics", () => { render(); - // Only the highest-spend section is expanded initially, so only its body is mounted. const sectionsMounted = () => screen.getAllByText("Spend per day").length; expect(sectionsMounted()).toBe(1); @@ -788,8 +832,6 @@ describe("processActivityData", () => { expect(Object.keys(result).sort()).toEqual(["gpt-5.2", "gpt-5.2-eu"]); expect(result["gpt-5.2-eu"].label).toBe("gpt-5.2-eu"); expect(result["gpt-5.2-eu"].total_spend).toBe(7); - expect(result["gpt-5.2-eu"].top_api_keys).toHaveLength(1); - expect(result["gpt-5.2-eu"].top_api_keys[0].key_alias).toBe("eu-key"); expect(result["gpt-5.2"].total_spend).toBe(3); expect(result["gpt-5.2"].total_requests).toBe(3); }); @@ -1107,151 +1149,6 @@ describe("processActivityData", () => { }; const result = processActivityData(dailyActivityWithBreakdown, "models"); - - expect(result["gpt-4"].top_api_keys).toHaveLength(2); - expect(result["gpt-4"].top_api_keys[0].spend).toBe(60.0); - expect(result["gpt-4"].top_api_keys[0].api_key).toBe("key-1"); - expect(result["gpt-4"].top_api_keys[1].spend).toBe(40.5); - expect(result["gpt-4"].top_api_keys.map(({ api_key, user }) => [api_key, user])).toEqual([ - ["key-1", "owner-1@example.com"], - ["key-2", "owner-id-2"], - ]); - }); - - it("should limit top_api_keys to 5 entries", () => { - const dailyActivityWithManyKeys: { results: DailyData[] } = { - results: [ - { - date: "2025-01-01", - metrics: { - spend: 100.5, - prompt_tokens: 30000, - completion_tokens: 20000, - total_tokens: 50000, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - cache_read_input_tokens: 1000, - cache_creation_input_tokens: 500, - }, - breakdown: { - models: { - "gpt-4": { - metrics: { - spend: 100.5, - prompt_tokens: 30000, - completion_tokens: 20000, - total_tokens: 50000, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - cache_read_input_tokens: 1000, - cache_creation_input_tokens: 500, - }, - metadata: {}, - api_key_breakdown: { - "key-1": { - metrics: { - spend: 20.0, - prompt_tokens: 6000, - completion_tokens: 4000, - total_tokens: 10000, - api_requests: 20, - successful_requests: 19, - failed_requests: 1, - cache_read_input_tokens: 200, - cache_creation_input_tokens: 100, - }, - metadata: { key_alias: "key-1", team_id: null }, - }, - "key-2": { - metrics: { - spend: 19.0, - prompt_tokens: 5700, - completion_tokens: 3800, - total_tokens: 9500, - api_requests: 19, - successful_requests: 18, - failed_requests: 1, - cache_read_input_tokens: 190, - cache_creation_input_tokens: 95, - }, - metadata: { key_alias: "key-2", team_id: null }, - }, - "key-3": { - metrics: { - spend: 18.0, - prompt_tokens: 5400, - completion_tokens: 3600, - total_tokens: 9000, - api_requests: 18, - successful_requests: 17, - failed_requests: 1, - cache_read_input_tokens: 180, - cache_creation_input_tokens: 90, - }, - metadata: { key_alias: "key-3", team_id: null }, - }, - "key-4": { - metrics: { - spend: 17.0, - prompt_tokens: 5100, - completion_tokens: 3400, - total_tokens: 8500, - api_requests: 17, - successful_requests: 16, - failed_requests: 1, - cache_read_input_tokens: 170, - cache_creation_input_tokens: 85, - }, - metadata: { key_alias: "key-4", team_id: null }, - }, - "key-5": { - metrics: { - spend: 16.0, - prompt_tokens: 4800, - completion_tokens: 3200, - total_tokens: 8000, - api_requests: 16, - successful_requests: 15, - failed_requests: 1, - cache_read_input_tokens: 160, - cache_creation_input_tokens: 80, - }, - metadata: { key_alias: "key-5", team_id: null }, - }, - "key-6": { - metrics: { - spend: 15.0, - prompt_tokens: 4500, - completion_tokens: 3000, - total_tokens: 7500, - api_requests: 15, - successful_requests: 14, - failed_requests: 1, - cache_read_input_tokens: 150, - cache_creation_input_tokens: 75, - }, - metadata: { key_alias: "key-6", team_id: null }, - }, - }, - }, - }, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: {}, - entities: {}, - }, - }, - ], - }; - - const result = processActivityData(dailyActivityWithManyKeys, "models"); - - expect(result["gpt-4"].top_api_keys).toHaveLength(5); - expect(result["gpt-4"].top_api_keys[0].spend).toBe(20.0); - expect(result["gpt-4"].top_api_keys[4].spend).toBe(16.0); }); it("should return empty object when results array is empty", () => { @@ -1368,8 +1265,6 @@ describe("processActivityData", () => { }; const result = processActivityData(dailyActivityWithBreakdown, "api_keys", MOCK_TEAMS); - - expect(result["key-1"].top_api_keys).toEqual([]); }); it("should handle missing cache tokens gracefully", () => { diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index 5ddb89703ea..e7712a99c71 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -13,16 +13,20 @@ import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ChevronDown } from "lucide-react"; -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Team } from "./key_team_helpers/key_list"; import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView"; import { keyActivityLabel } from "./UsagePage/keyActivityLabel"; -import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types"; +import type { ModelTopKeysResponse } from "./UsagePage/dailyActivityApi"; +import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopModelData } from "./UsagePage/types"; import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters"; interface ActivityMetricsProps { modelMetrics: Record; + summaryMetrics?: ModelActivityData; + summaryTitle?: string; hidePromptCachingMetrics?: boolean; + fetchTopApiKeys?: (model: string) => Promise; } const modelAverageResponseTimeMs = (metrics: ModelActivityData): number | null => @@ -37,14 +41,142 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp /> ); -const ModelSection = ({ +const ModelTopKeys = ({ + modelName, + fetchTopApiKeys, +}: { + modelName: string; + fetchTopApiKeys: (model: string) => Promise; +}) => { + interface ModelTopKeyRow { + api_key: string; + key_alias: string | null; + team_id: string | null; + user: string | null; + spend: number; + requests: number; + tokens: number; + } + const [settled, setSettled] = useState<{ + modelName: string; + fetchTopApiKeys: (model: string) => Promise; + rows: ModelTopKeyRow[]; + failed: boolean; + } | null>(null); + const [retryToken, setRetryToken] = useState(0); + + React.useEffect(() => { + let cancelled = false; + fetchTopApiKeys(modelName) + .then((response) => { + if (cancelled) return; + setSettled({ + modelName, + fetchTopApiKeys, + rows: response.api_keys.map((row) => ({ + api_key: row.api_key, + key_alias: row.metadata.key_alias ?? null, + team_id: row.metadata.team_id ?? null, + user: row.metadata.user_email ?? row.metadata.user_id ?? null, + spend: row.metrics.spend, + requests: row.metrics.api_requests, + tokens: row.metrics.total_tokens, + })), + failed: false, + }); + }) + .catch((error) => { + if (cancelled) return; + console.error(`Failed to fetch top keys for ${modelName}:`, error); + setSettled({ modelName, fetchTopApiKeys, rows: [], failed: true }); + }); + return () => { + cancelled = true; + }; + }, [modelName, fetchTopApiKeys, retryToken]); + + const current = settled?.modelName === modelName && settled.fetchTopApiKeys === fetchTopApiKeys ? settled : null; + + if (current === null) { + return ( + + +

Top Virtual Keys by Spend

+

Loading top keys...

+
+
+ ); + } + + if (current.failed) { + return ( + + +

Top Virtual Keys by Spend

+

+ Could not load top keys.{" "} + +

+
+
+ ); + } + + const rows = current.rows; + if (rows.length === 0) return null; + + return ( + + +

Top Virtual Keys by Spend

+
+
+ {rows.map((keyData) => { + const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`; + return ( +
+
+

{keyLabel}

+ {keyData.team_id &&

Team: {keyData.team_id}

} + {keyData.user && keyData.user !== keyLabel && ( +

User: {keyData.user}

+ )} +
+
+

${formatNumberWithCommas(keyData.spend, 2)}

+

+ {keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens +

+
+
+ ); + })} +
+
+
+
+ ); +}; + +export const ModelSection = ({ modelName, metrics, hidePromptCachingMetrics = false, + fetchTopApiKeys, }: { modelName: string; metrics: ModelActivityData; hidePromptCachingMetrics?: boolean; + fetchTopApiKeys?: (model: string) => Promise; }) => { return (
@@ -96,37 +228,7 @@ const ModelSection = ({
- {metrics.top_api_keys && metrics.top_api_keys.length > 0 && ( - - -

Top Virtual Keys by Spend

-
-
- {metrics.top_api_keys.map((keyData) => { - const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`; - return ( -
-
-

{keyLabel}

- {keyData.team_id &&

Team: {keyData.team_id}

} - {keyData.user && keyData.user !== keyLabel && ( -

User: {keyData.user}

- )} -
-
-

${formatNumberWithCommas(keyData.spend, 2)}

-

- {keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens -

-
-
- ); - })} -
-
-
-
- )} + {fetchTopApiKeys && } {metrics.top_models && metrics.top_models.length > 0 && } @@ -272,24 +374,33 @@ const ModelSection = ({ ); }; -const ModelCollapsible = ({ +export const ModelCollapsible = ({ defaultOpen, header, children, + onFirstOpen, }: { defaultOpen: boolean; header: React.ReactNode; children: React.ReactNode; + onFirstOpen?: () => void; }) => { const [open, setOpen] = useState(defaultOpen); const [everOpened, setEverOpened] = useState(defaultOpen); + const firstOpenRef = useRef(defaultOpen); return ( { setOpen(next); - if (next) setEverOpened(true); + if (next) { + setEverOpened(true); + if (!firstOpenRef.current) { + firstOpenRef.current = true; + onFirstOpen?.(); + } + } }} className="border-b last:border-b-0" > @@ -306,7 +417,13 @@ const ModelCollapsible = ({ ); }; -export const ActivityMetrics: React.FC = ({ modelMetrics, hidePromptCachingMetrics = false }) => { +export const ActivityMetrics: React.FC = ({ + modelMetrics, + summaryMetrics, + summaryTitle = "Overall Usage", + hidePromptCachingMetrics = false, + fetchTopApiKeys, +}) => { const modelNames = Object.keys(modelMetrics).sort((a, b) => { if (a === "") return 1; if (b === "") return -1; @@ -374,42 +491,44 @@ export const ActivityMetrics: React.FC = ({ modelMetrics, }); // Convert daily_data object to array and sort by date - const sortedDailyData = Object.entries(totalMetrics.daily_data) - .map(([date, metrics]) => ({ date, metrics })) - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const sortedDailyData = + summaryMetrics?.daily_data ?? + Object.entries(totalMetrics.daily_data) + .map(([date, metrics]) => ({ date, metrics })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const totalRequests = summaryMetrics?.total_requests ?? totalMetrics.total_requests; + const totalSuccessfulRequests = summaryMetrics?.total_successful_requests ?? totalMetrics.total_successful_requests; + const totalTokens = summaryMetrics?.total_tokens ?? totalMetrics.total_tokens; + const totalSpend = summaryMetrics?.total_spend ?? totalMetrics.total_spend; return (
{/* Global Summary */}
-

Overall Usage

+

{summaryTitle}

Total Requests

-

{totalMetrics.total_requests.toLocaleString()}

+

{totalRequests.toLocaleString()}

Total Successful Requests

-

- {totalMetrics.total_successful_requests.toLocaleString()} -

+

{totalSuccessfulRequests.toLocaleString()}

Total Tokens

-

{totalMetrics.total_tokens.toLocaleString()}

+

{totalTokens.toLocaleString()}

Total Spend

-

- ${formatNumberWithCommas(totalMetrics.total_spend, 2)} -

+

${formatNumberWithCommas(totalSpend, 2)}

@@ -463,40 +582,49 @@ export const ActivityMetrics: React.FC = ({ modelMetrics,
{/* Individual Model Sections */} -
- {modelNames.map((modelName) => ( - -

- {modelMetrics[modelName].label || "Unknown Item"} -

-
- ${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)} - {modelMetrics[modelName].total_requests.toLocaleString()} requests - {modelAverageResponseTimeMs(modelMetrics[modelName]) != null && ( - {formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response - )} + {modelNames.length > 0 && ( +
+ {modelNames.map((modelName) => ( + +

+ {modelMetrics[modelName].label || "Unknown Item"} +

+
+ ${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)} + {modelMetrics[modelName].total_requests.toLocaleString()} requests + {modelAverageResponseTimeMs(modelMetrics[modelName]) != null && ( + + {formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response + + )} +
-
- } - > - -
- ))} -
+ } + > + + + ))} +
+ )}
); }; // Helper function to format key label -export const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string, teams: Team[]): string => { +export const formatKeyLabel = ( + modelData: Pick, + model: string, + teams: Team[], +): string => { const keyAlias = keyActivityLabel(modelData.metadata, `key-hash-${model}`); const teamId = modelData.metadata.team_id; if (teamId) { @@ -536,7 +664,6 @@ export const processActivityData = ( total_cache_creation_input_tokens: 0, total_response_time_ms: 0, total_timed_requests: 0, - top_api_keys: [], top_models: [], daily_data: [], }; @@ -576,42 +703,6 @@ export const processActivityData = ( }); }); - // Process Virtual Key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication) - if (key !== "api_keys") { - Object.entries(modelMetrics).forEach(([model, _]) => { - const apiKeyBreakdown: Record = {}; - - // Aggregate Virtual Key data across all days - dailyActivity.results.forEach((day) => { - const modelData = day.breakdown[key]?.[model]; - if (modelData && "api_key_breakdown" in modelData) { - Object.entries(modelData.api_key_breakdown || {}).forEach(([apiKey, keyData]) => { - if (!apiKeyBreakdown[apiKey]) { - apiKeyBreakdown[apiKey] = { - api_key: apiKey, - key_alias: keyActivityLabel(keyData.metadata, "") || null, - team_id: keyData.metadata.team_id, - user: keyData.metadata.user_email ?? keyData.metadata.user_id ?? null, - spend: 0, - requests: 0, - tokens: 0, - }; - } - - apiKeyBreakdown[apiKey].spend += keyData.metrics.spend; - apiKeyBreakdown[apiKey].requests += keyData.metrics.api_requests; - apiKeyBreakdown[apiKey].tokens += keyData.metrics.total_tokens; - }); - } - }); - - // Sort by spend and take top 5 - modelMetrics[model].top_api_keys = Object.values(apiKeyBreakdown) - .sort((a, b) => b.spend - a.spend) - .slice(0, 5); - }); - } - // Process Model breakdowns for each API key (only when key is 'api_keys') if (key === "api_keys") { Object.entries(modelMetrics).forEach(([apiKeyHash, _]) => { diff --git a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx index b6c6b78fe0b..e839890a357 100644 --- a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { BarChart3 } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { userDailyActivityAggregatedCall } from "../networking"; +import { dailyActivityAggregatedCall } from "../networking"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -95,7 +95,15 @@ const UsagePanel: React.FC = ({ accessToken, userId }) => { const { data, isLoading } = useQuery({ queryKey: [USAGE_QUERY_KEY, accessToken, userId, timeRange], - queryFn: () => userDailyActivityAggregatedCall(accessToken, start, end, userId), + queryFn: () => { + const request = { + accessToken, + startTime: start, + endTime: end, + entityIds: userId ? [userId] : null, + }; + return dailyActivityAggregatedCall("user", request); + }, enabled: !!accessToken, }); diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts new file mode 100644 index 00000000000..6821d26fb33 --- /dev/null +++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + cacheLeakageKeysCall, + dailyActivityAggregatedCall, + dailyActivityExportCall, + dailyActivityKeySearchCall, + dailyActivityModelTopKeysCall, +} from "./networking"; +import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi"; + +const originalFetch = global.fetch; + +const captureFetch = () => { + const mockFetch = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ results: [], metadata: {}, api_keys: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = mockFetch; + return mockFetch; +}; + +const requestedUrl = (mockFetch: ReturnType, callIndex = 0): URL => + new URL(String(mockFetch.mock.calls[callIndex][0]), "http://example.com"); + +const start = new Date("2025-01-05T00:00:00Z"); +const end = new Date("2025-01-31T00:00:00Z"); + +const req = (overrides: Partial = {}): DailyActivityRequest => ({ + accessToken: "sk-key", + startTime: start, + endTime: end, + ...overrides, +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe("dailyActivityAggregatedCall", () => { + it.each<[DailyActivityEntity, string]>([ + ["user", "user_id"], + ["team", "team_ids"], + ["tag", "tags"], + ["organization", "organization_ids"], + ["customer", "end_user_ids"], + ["agent", "agent_ids"], + ])("GETs /%s/daily/activity/aggregated with %s filters", async (entity, param) => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1", "e2"] })); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe(`/${entity}/daily/activity/aggregated`); + const expected = entity === "user" ? "e1" : "e1,e2"; + expect(url.searchParams.get(param)).toBe(expected); + expect(url.searchParams.get("start_date")).toBe("2025-01-05"); + expect(url.searchParams.get("end_date")).toBe("2025-01-31"); + expect(url.searchParams.has("timezone")).toBe(true); + expect(url.searchParams.has("page")).toBe(false); + expect(url.searchParams.has("page_size")).toBe(false); + }); + + it("omits the entity filter when no ids are given", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("team", req({ entityIds: null })); + + const url = requestedUrl(mockFetch); + expect(url.searchParams.has("team_ids")).toBe(false); + expect(url.searchParams.has("exclude_team_ids")).toBe(false); + }); + + it.each<[DailyActivityEntity, string]>([ + ["team", "exclude_team_ids"], + ["organization", "exclude_organization_ids"], + ["customer", "exclude_end_user_ids"], + ["agent", "exclude_agent_ids"], + ])("sends exclude ids comma-joined under %s", async (entity, param) => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall( + entity, + req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard", "other"] }), + ); + + expect(requestedUrl(mockFetch).searchParams.get(param)).toBe("litellm-dashboard,other"); + }); + + it.each<[DailyActivityEntity]>([["tag"], ["user"]])("emits no exclude param for %s", async (entity) => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard"] })); + + const params = [...requestedUrl(mockFetch).searchParams.keys()]; + expect(params.some((key) => key.startsWith("exclude_"))).toBe(false); + }); + + it("keeps an empty api_key as a filter rather than widening the read", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req({ apiKey: "" })); + + expect(requestedUrl(mockFetch).searchParams.get("api_key")).toBe(""); + }); + + it("omits api_key entirely when none is given", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req({ apiKey: null })); + + expect(requestedUrl(mockFetch).searchParams.has("api_key")).toBe(false); + }); + + it("sends include_current_utc_day only when set", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req({ includeCurrentUtcDay: true })); + + expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true"); + }); + + it("sends api_key_limit only when provided", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("user", req()); + await dailyActivityAggregatedCall("user", req({ apiKeyLimit: 250 })); + + expect(requestedUrl(mockFetch, 0).searchParams.has("api_key_limit")).toBe(false); + expect(requestedUrl(mockFetch, 1).searchParams.get("api_key_limit")).toBe("250"); + }); +}); + +describe("dailyActivityKeySearchCall", () => { + it("GETs the search route with the search term", async () => { + const mockFetch = captureFetch(); + + await dailyActivityKeySearchCall("team", req({ entityIds: ["t1"] }), "alice"); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/team/daily/activity/aggregated/search"); + expect(url.searchParams.get("search")).toBe("alice"); + expect(url.searchParams.get("team_ids")).toBe("t1"); + expect(url.searchParams.has("limit")).toBe(false); + }); + + it("sends the optional limit", async () => { + const mockFetch = captureFetch(); + + await dailyActivityKeySearchCall("team", req(), "alice", 50); + + expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("50"); + }); +}); + +describe("dailyActivityModelTopKeysCall", () => { + it("GETs model_top_keys with model_group and by_model_group", async () => { + const mockFetch = captureFetch(); + + await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/user/daily/activity/aggregated/model_top_keys"); + expect(url.searchParams.get("model_group")).toBe("gpt-4o"); + expect(url.searchParams.get("by_model_group")).toBe("true"); + expect(url.searchParams.has("limit")).toBe(false); + }); + + it("sends by_model_group=false for the models view", async () => { + const mockFetch = captureFetch(); + + await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", false); + + expect(requestedUrl(mockFetch).searchParams.get("by_model_group")).toBe("false"); + }); + + it("sends the optional limit", async () => { + const mockFetch = captureFetch(); + + await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true, 25); + + expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("25"); + }); +}); + +describe("dailyActivityExportCall", () => { + it("GETs the export route with export_type and format", async () => { + const mockFetch = captureFetch(); + + await dailyActivityExportCall("organization", req({ entityIds: ["o1"] }), "daily_with_keys", "csv"); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/organization/daily/activity/export"); + expect(url.searchParams.get("export_type")).toBe("daily_with_keys"); + expect(url.searchParams.get("format")).toBe("csv"); + expect(url.searchParams.get("organization_ids")).toBe("o1"); + }); +}); + +describe("cacheLeakageKeysCall", () => { + it("GETs the user cache_leakage_keys route with the user scope", async () => { + const mockFetch = captureFetch(); + + await cacheLeakageKeysCall(req({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true })); + + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/user/daily/activity/aggregated/cache_leakage_keys"); + expect(url.searchParams.get("user_id")).toBe("u1"); + expect(url.searchParams.get("api_key")).toBe("hash-1"); + expect(url.searchParams.get("include_current_utc_day")).toBe("true"); + expect(url.searchParams.has("limit")).toBe(false); + }); + + it("sends the optional limit", async () => { + const mockFetch = captureFetch(); + + await cacheLeakageKeysCall(req(), 75); + + expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("75"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index b964231804e..d5e0b21251e 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -157,60 +157,6 @@ describe("modelInfoCall", () => { }); }); -describe("daily activity helpers", () => { - const startTime = new Date("2025-02-12T00:00:00.000Z"); - const endTime = new Date("2025-02-19T00:00:00.000Z"); - let currentFetch: typeof global.fetch; - - const setupSuccessfulFetch = () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ data: [] }), - } as any); - global.fetch = mockFetch as any; - return mockFetch; - }; - - beforeEach(() => { - vi.clearAllMocks(); - currentFetch = global.fetch; - }); - - afterEach(() => { - global.fetch = currentFetch; - }); - - it("appends tag list when tags argument is provided", async () => { - const mockFetch = setupSuccessfulFetch(); - - await Networking.tagDailyActivityCall("token", startTime, endTime, 2, ["alpha", "beta"]); - - expect(mockFetch).toHaveBeenCalledOnce(); - const calledUrl = mockFetch.mock.calls[0][0] as string; - const parsed = new URL(calledUrl, "http://example.com"); - - expect(parsed.pathname).toBe("/tag/daily/activity"); - expect(parsed.searchParams.get("tags")).toBe("alpha,beta"); - }); - - it("always includes exclude_team_ids but only adds team_ids when given", async () => { - const mockFetchWithoutTeams = setupSuccessfulFetch(); - - await Networking.teamDailyActivityCall("token", startTime, endTime, 1, null); - const urlWithoutTeams = new URL(mockFetchWithoutTeams.mock.calls[0][0] as string, "http://example.com"); - - expect(urlWithoutTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard"); - expect(urlWithoutTeams.searchParams.has("team_ids")).toBe(false); - - const mockFetchWithTeams = setupSuccessfulFetch(); - await Networking.teamDailyActivityCall("token", startTime, endTime, 3, ["team-a", "team-b"]); - const urlWithTeams = new URL(mockFetchWithTeams.mock.calls[0][0] as string, "http://example.com"); - - expect(urlWithTeams.searchParams.get("team_ids")).toBe("team-a,team-b"); - expect(urlWithTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard"); - }); -}); - describe("UI config and public endpoints", () => { const originalFetch = global.fetch; @@ -772,88 +718,6 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => { }); }); -describe("daily activity api_key filter", () => { - const originalFetch = global.fetch; - - const captureFetch = () => { - const mockFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ results: [], metadata: {} }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - global.fetch = mockFetch; - return mockFetch; - }; - - const requestedUrl = (mockFetch: ReturnType): string => String(mockFetch.mock.calls[0][0]); - - const start = new Date("2025-01-01T00:00:00Z"); - const end = new Date("2025-01-31T00:00:00Z"); - - afterEach(() => { - global.fetch = originalFetch; - }); - - it("sends the key hash as api_key from the paginated caller", async () => { - const mockFetch = captureFetch(); - - await Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "hash-abc"); - - expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc"); - }); - - it("sends the key hash as api_key from the aggregated caller", async () => { - const mockFetch = captureFetch(); - - await Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "hash-abc"); - - expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc"); - }); - - // The two wrappers serialize the same optional filters through different transports, so an - // absent key has to drop the param in both. Dropping it on one side and sending it empty on - // the other would widen a key-scoped read into an unscoped one. - it.each([ - ["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, null)], - ["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, null)], - ])("omits api_key entirely from the %s caller when no key is given", async (_label, call) => { - const mockFetch = captureFetch(); - - await call(); - - expect(requestedUrl(mockFetch)).not.toContain("api_key"); - }); - - // An empty key must not be coerced into "no filter". Dropping it would turn a key-scoped read - // into a proxy-wide one and report every key's savings as this key's, so both callers send it - // through and let the filter match nothing instead. - it.each([ - ["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "")], - ["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "")], - ])("keeps an empty api_key as a filter rather than widening the %s read", async (_label, call) => { - const mockFetch = captureFetch(); - - await call(); - - expect(requestedUrl(mockFetch)).toContain("api_key="); - }); - - // user_id rides the same two transports and widens the same way, so it gets the same guard. - // The aggregated caller used to drop "" via `||`; without this the two filters could drift - // apart again on one side only. - it.each([ - ["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, "", false, null)], - ["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, "", false, null)], - ])("keeps an empty user_id as a filter rather than widening the %s read", async (_label, call) => { - const mockFetch = captureFetch(); - - await call(); - - expect(requestedUrl(mockFetch)).toContain("user_id="); - }); -}); - describe("userListCall search serialization", () => { const originalFetch = global.fetch; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index da902d4a9e2..20037c9b12b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -121,7 +121,19 @@ import { deriveErrorMessage, extractProxyErrorMessage, unwrapProxyErrorMessage, + type QueryParams, } from "@/lib/http/client"; +import type { + CacheLeakageKeysResponse, + DailyActivityAggregatedResponse, + DailyActivityEntity, + DailyActivityKeyPageResponse, + DailyActivityKeySearchResponse, + DailyActivityRequest, + ExportFormat, + ExportType, + ModelTopKeysResponse, +} from "./UsagePage/dailyActivityApi"; import { resolveApiBase } from "@/lib/http/resolveApiBase"; import { registerAuthHeaderNameGetter, @@ -1281,192 +1293,110 @@ export const transformRequestCall = async (accessToken: string, request: object) } }; -type DailyActivityQueryValue = string | number | string[] | null | undefined; - -const DEFAULT_DAILY_ACTIVITY_PAGE_SIZE = "1000"; - -const appendDailyActivityQueryParam = (params: URLSearchParams, key: string, value: DailyActivityQueryValue) => { - if (value === null || value === undefined) { - return; - } - - if (Array.isArray(value)) { - if (value.length > 0) { - params.append(key, value.join(",")); - } - return; - } - - params.append(key, `${value}`); +const ENTITY_ID_QUERY_PARAM: Record = { + user: "user_id", + team: "team_ids", + tag: "tags", + organization: "organization_ids", + customer: "end_user_ids", + agent: "agent_ids", }; -const buildDailyActivityUrl = ( - endpoint: string, - startTime: Date, - endTime: Date, - page: number, - extraQueryParams?: Record, -) => { - const resolvedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; - const baseUrl = proxyBaseUrl ? `${proxyBaseUrl}${resolvedEndpoint}` : resolvedEndpoint; - - const params = new URLSearchParams(); - params.append("start_date", formatDate(startTime)); - params.append("end_date", formatDate(endTime)); - params.append("page_size", DEFAULT_DAILY_ACTIVITY_PAGE_SIZE); - params.append("page", page.toString()); - // Send timezone offset so backend can adjust date range for UTC storage - params.append("timezone", new Date().getTimezoneOffset().toString()); - - if (extraQueryParams) { - Object.entries(extraQueryParams).forEach(([key, value]) => { - appendDailyActivityQueryParam(params, key, value); - }); - } - - const queryString = params.toString(); - return queryString ? `${baseUrl}?${queryString}` : baseUrl; +const EXCLUDE_ENTITY_ID_QUERY_PARAM: Partial> = { + team: "exclude_team_ids", + organization: "exclude_organization_ids", + customer: "exclude_end_user_ids", + agent: "exclude_agent_ids", }; -type DailyActivityCallOptions = { - accessToken: string; - endpoint: string; - startTime: Date; - endTime: Date; - page?: number; - extraQueryParams?: Record; +const dailyActivityQuery = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + extra: QueryParams = {}, +): QueryParams => { + const entityIds = req.entityIds ?? undefined; + const excludeEntityIds = req.excludeEntityIds; + const joinedEntityIds = entityIds && entityIds.length > 0 ? entityIds.join(",") : undefined; + const entityIdValue = entity === "user" ? entityIds?.[0] : joinedEntityIds; + const excludeParam = EXCLUDE_ENTITY_ID_QUERY_PARAM[entity]; + return { + start_date: formatDate(req.startTime), + end_date: formatDate(req.endTime), + model: req.model, + api_key: req.apiKey, + [ENTITY_ID_QUERY_PARAM[entity]]: entityIdValue, + ...(excludeParam + ? { [excludeParam]: excludeEntityIds && excludeEntityIds.length > 0 ? excludeEntityIds.join(",") : undefined } + : {}), + timezone: new Date().getTimezoneOffset().toString(), + include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined, + ...extra, + }; }; -const fetchDailyActivity = async ({ - accessToken, - endpoint, - startTime, - endTime, - page = 1, - extraQueryParams, -}: DailyActivityCallOptions) => { - try { - const url = buildDailyActivityUrl(endpoint, startTime, endTime, page, extraQueryParams); - - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); - } - - const data = await response.json(); - return data; - } catch (error) { - console.error(`Failed to fetch daily activity (${endpoint}):`, error); - throw error; - } -}; - -export const userDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - userId: string | null = null, - includeCurrentUtcDay: boolean = false, - apiKey: string | null = null, -) => { - /** - * Get daily user activity on proxy - */ - return fetchDailyActivity({ - accessToken, - endpoint: "/user/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - user_id: userId, - include_current_utc_day: includeCurrentUtcDay ? "true" : undefined, - api_key: apiKey, - }, +export const dailyActivityAggregatedCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, req.apiKeyLimit === undefined ? {} : { api_key_limit: req.apiKeyLimit }), }); -}; -export const tagDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - tags: string[] | null = null, -) => { - /** - * Get daily user activity on proxy - */ - return fetchDailyActivity({ - accessToken, - endpoint: "/tag/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - tags, - }, +export const dailyActivityKeyPageCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + offset: number, + limit: number, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated/keys`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { offset, limit }), }); -}; -export const teamDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - teamIds: string[] | null = null, -) => { - /** - * Get daily user activity on proxy - */ - return fetchDailyActivity({ - accessToken, - endpoint: "/team/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - team_ids: teamIds, - exclude_team_ids: "litellm-dashboard", - }, +export const dailyActivityKeySearchCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + search: string, + limit?: number, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated/search`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { search, ...(limit === undefined ? {} : { limit }) }), }); -}; -export const teamDailyActivityAggregatedCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - teamIds: string[] | null = null, -) => { - /** - * Get aggregated daily team activity with per-team breakdown (no pagination) - */ - try { - return await apiClient.get(`/team/daily/activity/aggregated`, { - accessToken, - query: { - start_date: formatDate(startTime), - end_date: formatDate(endTime), - timezone: new Date().getTimezoneOffset().toString(), - team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined, - exclude_team_ids: "litellm-dashboard", - }, - }); - } catch (error) { - console.error("Failed to fetch aggregated team daily activity:", error); - throw error; - } -}; +export const dailyActivityModelTopKeysCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + model: string, + byModelGroup: boolean, + limit?: number, +): Promise => + apiClient.get(`/${entity}/daily/activity/aggregated/model_top_keys`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { + model_group: model, + by_model_group: byModelGroup ? "true" : "false", + ...(limit === undefined ? {} : { limit }), + }), + }); + +export const dailyActivityExportCall = ( + entity: DailyActivityEntity, + req: DailyActivityRequest, + exportType: ExportType, + format: ExportFormat, +): Promise => + apiClient.getBlob(`/${entity}/daily/activity/export`, { + accessToken: req.accessToken, + query: dailyActivityQuery(entity, req, { export_type: exportType, format }), + }); + +export const cacheLeakageKeysCall = (req: DailyActivityRequest, limit?: number): Promise => + apiClient.get(`/user/daily/activity/aggregated/cache_leakage_keys`, { + accessToken: req.accessToken, + query: dailyActivityQuery("user", req, limit === undefined ? {} : { limit }), + }); export type TeamUserSpendResponse = components["schemas"]["TeamUserSpendResponse"]; @@ -1485,63 +1415,6 @@ export const teamSpendByUserCall = async ( }, }); -export const organizationDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - organizationIds: string[] | null = null, -) => { - return fetchDailyActivity({ - accessToken, - endpoint: "/organization/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - organization_ids: organizationIds, - }, - }); -}; - -export const customerDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - customerIds: string[] | null = null, -) => { - return fetchDailyActivity({ - accessToken, - endpoint: "/customer/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - end_user_ids: customerIds, - }, - }); -}; - -export const agentDailyActivityCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - page: number = 1, - agentIds: string[] | null = null, -) => { - return fetchDailyActivity({ - accessToken, - endpoint: "/agent/daily/activity", - startTime, - endTime, - page, - extraQueryParams: { - agent_ids: agentIds, - }, - }); -}; - export const getOnboardingCredentials = async (inviteUUID: string) => { /** * Get all models on proxy @@ -2571,43 +2444,6 @@ export const keyAliasesCall = async ( } }; -export const userDailyActivityAggregatedCall = async ( - accessToken: string, - startTime: Date, - endTime: Date, - ...options: [userId?: string | null, includeCurrentUtcDay?: boolean, apiKey?: string | null] -) => { - /** - * Get aggregated daily user activity (no pagination) - */ - const [userId = null, includeCurrentUtcDay = false, apiKey = null] = options; - try { - const formatDate = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; - }; - return await apiClient.get(`/user/daily/activity/aggregated`, { - accessToken, - query: { - start_date: formatDate(startTime), - end_date: formatDate(endTime), - timezone: new Date().getTimezoneOffset().toString(), - // Passed raw, matching the paginated caller: both serializers drop null and undefined, - // and both keep "". An empty filter must not vanish, or a request scoped to one user or - // key would silently widen into an unscoped, proxy-wide read. - user_id: userId, - include_current_utc_day: includeCurrentUtcDay ? "true" : undefined, - api_key: apiKey, - }, - }); - } catch (error) { - console.error("Failed to fetch aggregated user daily activity:", error); - throw error; - } -}; - export const gatewayDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => { /** * Get gateway request counts (SGR) recorded by the proxy middleware. diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx deleted file mode 100644 index 93d464b6615..00000000000 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import PaginationStatusAlerts from "./PaginationStatusAlerts"; - -describe("PaginationStatusAlerts", () => { - it("shows page progress and wires the Stop button while fetching", () => { - const cancel = vi.fn(); - render( - , - ); - - expect(screen.getByText(/Currently fetching spend data: fetched 7 \/ 42 pages/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Stop" })); - expect(cancel).toHaveBeenCalledTimes(1); - }); - - it("shows the partial-data notice after a cancel, frozen at the last fetched page", () => { - render( - , - ); - - expect(screen.getByText("Showing partial spend data (7/42 pages loaded)")).toBeInTheDocument(); - }); - - it("calls out a failed page as an error so partial totals do not read as final", () => { - render( - , - ); - - expect( - screen.getByText(/Fetching spend data failed, so the totals below cover only 7 of 42 pages of the range/), - ).toBeInTheDocument(); - }); - - it("does not claim a page loaded when the very first request is what failed", () => { - render( - , - ); - - expect(screen.getByText(/failed before any of it arrived/)).toBeInTheDocument(); - expect(screen.queryByText(/pages of the range/)).not.toBeInTheDocument(); - }); - - it("shows only the failure when a stopped fetch also failed", () => { - render( - , - ); - - expect(screen.getByText(/Fetching spend data failed/)).toBeInTheDocument(); - expect(screen.queryByText(/Showing partial spend data/)).not.toBeInTheDocument(); - }); - - it("names the subject it is fetching", () => { - render( - , - ); - - expect(screen.getByText(/Currently fetching agent data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); - }); - - it("renders nothing when idle", () => { - const { container } = render( - , - ); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx deleted file mode 100644 index e9357993dd0..00000000000 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ExternalLink, Loader2 } from "lucide-react"; - -import { Alert, AlertDescription } from "@/components/shared/Alert"; -import { Button } from "@/components/ui/button"; - -interface PaginationStatusAlertsProps { - isFetchingMore: boolean; - cancelled: boolean; - progress: { currentPage: number; totalPages: number }; - cancel: () => void; - subject?: string; - failed?: boolean; -} - -const failureMessage = (subject: string, progress: { currentPage: number; totalPages: number }) => - progress.currentPage === 0 - ? `Fetching ${subject} failed before any of it arrived, so the totals below are empty rather than final. Reload the page to try again.` - : `Fetching ${subject} failed, so the totals below cover only ${progress.currentPage} of ${progress.totalPages} pages of the range. Reload the page to try again.`; - -const PaginationStatusAlerts = ({ - isFetchingMore, - cancelled, - progress, - cancel, - subject = "spend data", - failed = false, -}: PaginationStatusAlertsProps) => ( - <> - {isFetchingMore && ( - - - - - Currently fetching {subject}: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will - update periodically as data loads. Moving off of this page will stop and reset this. To continue using the - UI in the meantime,{" "} - - open a new tab - - . - - - - - )} - {failed && ( - - {failureMessage(subject, progress)} - - )} - {cancelled && !failed && ( - - - Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded) - - - )} - -); - -export default PaginationStatusAlerts; diff --git a/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx b/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx index 7a0455f9d7e..514ecc40004 100644 --- a/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx +++ b/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx @@ -24,19 +24,19 @@ import { import { useScopedDailyActivityRange, type ActivityDateRange, - type DailyActivityScope, + type ScopedActivityInput, } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; interface ScopedSavingsTabProps { accessToken: string | null; - scope: DailyActivityScope; + scope: ScopedActivityInput; activity: ActivityDateRange; entityType: "key" | "user"; scopeNote?: string; } const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote }: ScopedSavingsTabProps) => { - const { dateValue, onDateChange, results, loading, isFetchingMore, failed, cancelled } = useScopedDailyActivityRange( + const { dateValue, onDateChange, results, loading, failed } = useScopedDailyActivityRange( accessToken, scope, activity, @@ -63,8 +63,8 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote .filter(Boolean) .join(" · "); - const isLoading = loading || isFetchingMore; - const unavailable = failed || cancelled; + const isLoading = loading; + const unavailable = failed; const showResults = !isLoading && !unavailable; const hasRows = results.length > 0; const showEmpty = !unavailable && (isLoading || !hasRows); diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx index f6fea8ebe91..3567388de05 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import KeySavingsTab from "./KeySavingsTab"; import { DailyData, SpendMetrics } from "@/components/UsagePage/types"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; import * as useScopedDailyActivityRangeModule from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; const metrics = (overrides: Partial): SpendMetrics => ({ @@ -36,12 +37,16 @@ const mockActivity = ( dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") }, onDateChange: vi.fn(), results: [] as DailyData[], + metadata: EMPTY_DAILY_ACTIVITY_METADATA, loading: false, - isFetchingMore: false, - progress: { currentPage: 1, totalPages: 1 }, - cancelled: false, failed: false, - cancel: vi.fn(), + scope: { + accessToken: "test-token", + startTime: new Date("2025-01-01"), + endTime: new Date("2025-01-31"), + userId: null, + apiKey: null, + }, ...overrides, }); diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 0d765371e0c..3d28c154990 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -113,6 +113,25 @@ describe("createApiClient", () => { expect(init.headers).toEqual({ "Content-Type": "application/json" }); }); + it("getBlob returns the response body as a Blob on success", async () => { + const blob = new Blob(["csv,data"], { type: "text/csv" }); + const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, blob: async () => blob }) as unknown as Response); + const client = createApiClient({ getBaseUrl: () => "https://proxy.example", fetchImpl }); + + const result = await client.getBlob("/user/daily/activity/export", { accessToken: "sk" }); + + expect(result).toBe(blob); + const [, blobInit] = fetchImpl.mock.calls[0] as unknown as [unknown, RequestInit]; + expect(blobInit.method).toBe("GET"); + }); + + it("getBlob throws ApiError on a non-2xx response", async () => { + const fetchImpl = vi.fn(async () => errorResponse(500, { error: "export failed" })); + const client = createApiClient({ getBaseUrl: () => "", fetchImpl }); + + await expect(client.getBlob("/user/daily/activity/export", { accessToken: "sk" })).rejects.toBeInstanceOf(ApiError); + }); + it("resolves the global fetch per call, so a swap after construction takes effect", async () => { const client = createApiClient({ getBaseUrl: () => "" }); diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts index c78b2b10011..33d54c0fa28 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -111,6 +111,7 @@ export interface ApiClientConfig { export interface ApiClient { request(method: HttpMethod, path: string, options?: RequestOptions): Promise; get(path: string, options?: RequestOptions): Promise; + getBlob(path: string, options?: RequestOptions): Promise; post(path: string, options?: RequestOptions): Promise; put(path: string, options?: RequestOptions): Promise; delete(path: string, options?: RequestOptions): Promise; @@ -137,7 +138,7 @@ export function createApiClient(config: ApiClientConfig): ApiClient { const { getBaseUrl, getAuthHeaderName, onError, fetchImpl } = config; const doFetch: typeof fetch = (input, init) => (fetchImpl ?? fetch)(input, init); - async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + async function fetchChecked(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials } = options; const url = appendQuery(`${getBaseUrl()}${path}`, query); @@ -177,13 +178,24 @@ export function createApiClient(config: ApiClientConfig): ApiClient { throw new ApiError(message, response.status, errorBody); } + return response; + } + + async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + const response = await fetchChecked(method, path, options); const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } + async function getBlob(path: string, options: RequestOptions = {}): Promise { + const response = await fetchChecked("GET", path, options); + return response.blob(); + } + return { request, get: (path, options) => request("GET", path, options), + getBlob, post: (path, options) => request("POST", path, options), put: (path, options) => request("PUT", path, options), delete: (path, options) => request("DELETE", path, options), From 3a91029328aeb81dba11236d6427649ce35cbbe5 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 1 Oct 2026 17:06:08 -0700 Subject: [PATCH 08/83] build(docker): drop the no-op PROXY_EXTRAS_SOURCE switch from the non-root image (#44097) uv sync --frozen ignores --no-sources-package, so the "published" branch already installed litellm-proxy-extras from the workspace (the shipped main-stable non-root image records file:///app/litellm-proxy-extras in its direct_url.json). Both branches ran the same install. Keep the single uv sync that the main and database images use. --- docker-compose.hardened.yml | 2 -- docker/Dockerfile.non_root | 31 ++++++++----------------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/docker-compose.hardened.yml b/docker-compose.hardened.yml index 31d0c2e9ef2..84a23faa054 100644 --- a/docker-compose.hardened.yml +++ b/docker-compose.hardened.yml @@ -6,8 +6,6 @@ services: context: . dockerfile: docker/Dockerfile.non_root target: runtime - args: - PROXY_EXTRAS_SOURCE: "local" depends_on: - squid user: "101:101" diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index eca12855afa..ca526e06834 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -3,7 +3,6 @@ # Base images ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d -ARG PROXY_EXTRAS_SOURCE=published ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 @@ -44,7 +43,6 @@ COPY ui/litellm-dashboard/ ./ RUN npm run build FROM $LITELLM_BUILD_IMAGE AS builder -ARG PROXY_EXTRAS_SOURCE WORKDIR /app USER root @@ -107,26 +105,14 @@ RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \ touch /var/lib/litellm/ui/.litellm_ui_ready RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \ - if [ "$PROXY_EXTRAS_SOURCE" = "published" ]; then \ - uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --extra bedrock-realtime \ - --python python3.13 \ - --no-sources-package litellm-proxy-extras; \ - else \ - uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --extra saml \ - --extra bedrock-realtime \ - --python python3.13; \ - fi + uv sync --frozen --no-default-groups --no-editable \ + --extra proxy \ + --extra proxy-runtime \ + --extra extra_proxy \ + --extra semantic-router \ + --extra saml \ + --extra bedrock-realtime \ + --python python3.13 RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ npm_config_cache=/root/.npm \ @@ -136,7 +122,6 @@ RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \ sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh FROM $LITELLM_RUNTIME_IMAGE AS runtime -ARG PROXY_EXTRAS_SOURCE WORKDIR /app USER root From 935146375544953e7b91457ba60adddadc9a287d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:14:31 -0700 Subject: [PATCH 09/83] fix(proxy): persist SSO display name as user_alias on login (#44065) * fix(proxy): persist SSO display name as user_alias on login Generic/Microsoft SSO already parsed the IdP display_name, first_name and last_name into the SSO result, but the user upsert only wrote user_email and user_role, so the Users table never showed a name. Store the display name (first + last as fallback) as user_alias on first login and on later logins of users whose alias is still empty; never overwrite an alias already set. Whitespace-only names are treated as missing. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): keep stored user_email when SSO login carries no email claim Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert: keep stored user_email change, login writes the IdP email as before Reverts 47c65cecff. A stored email staying eligible for email-based account linking after the IdP stops sending it is not wanted; the PR goes back to the user_alias fix only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_endpoints/ui_sso.py | 43 ++++- .../proxy/management_endpoints/test_ui_sso.py | 158 ++++++++++++++++++ 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 19444dfe33b..0d7094f66dc 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -1853,10 +1853,36 @@ def _should_use_role_from_sso_response(sso_role: str | None) -> bool: return True +class _SsoUserNames(Protocol): + id: str | None + display_name: str | None + first_name: str | None + last_name: str | None + + +def _get_sso_user_alias(result: _SsoUserNames | Mapping[str, object] | None) -> str | None: + """Display name the IdP sent for the user, falling back to the joined first/last name.""" + if result is None: + return None + if isinstance(result, Mapping): + raw_names: tuple[object, ...] = tuple( + result.get(key) for key in ("id", "display_name", "first_name", "last_name") + ) + else: + raw_names = (result.id, result.display_name, result.first_name, result.last_name) + user_id, display_name, first_name, last_name = ( + name.strip() or None if isinstance(name, str) else None for name in raw_names + ) + if display_name and display_name != user_id: + return display_name + return " ".join(part for part in (first_name, last_name) if part) or None + + def _build_sso_user_update_data( - result: Union["CustomOpenID", OpenID, dict] | None, + result: Union["CustomOpenID", OpenID, Mapping[str, object]] | None, user_email: str | None, user_id: str | None, + existing_user_alias: str | None = None, ) -> dict[str, object]: """ Build the update data dictionary for SSO user upsert. @@ -1865,14 +1891,19 @@ def _build_sso_user_update_data( result: The SSO response containing user information user_email: The user's email from SSO user_id: The user's ID for logging purposes + existing_user_alias: The user's current alias in the DB; only an empty alias is filled from SSO Returns: - dict: Update data containing user_email and optionally user_role if valid + dict: Update data containing user_email, user_alias when newly available, and user_role if valid """ - update_data: Final[dict[str, object]] = {"user_email": normalize_email(user_email)} + sso_user_alias: Final = None if existing_user_alias else _get_sso_user_alias(result) + update_data: Final[dict[str, object]] = { + "user_email": normalize_email(user_email), + **({"user_alias": sso_user_alias} if sso_user_alias is not None else {}), + } # Get SSO role from result and include if valid - sso_role: Final = getattr(result, "user_role", None) + sso_role: Final = result.user_role if isinstance(result, CustomOpenID) else None if sso_role is not None: # Convert enum to string if needed sso_role_str: Final = sso_role.value if isinstance(sso_role, LitellmUserRoles) else sso_role @@ -2616,6 +2647,7 @@ async def insert_sso_user( new_user_request: Final = NewUserRequest( user_id=user_defined_values["user_id"], user_email=normalize_email(user_defined_values["user_email"]), + user_alias=_get_sso_user_alias(result_openid), user_role=user_defined_values["user_role"], max_budget=user_defined_values["max_budget"], budget_duration=user_defined_values["budget_duration"], @@ -3249,6 +3281,7 @@ class SSOAuthenticationHandler: result=result, user_email=user_email, user_id=user_id, + existing_user_alias=user_info.user_alias if isinstance(user_info, LiteLLM_UserTable) else None, ) await _user_meta_db(UserRepository(prisma_client)).update_many( @@ -3280,7 +3313,7 @@ class SSOAuthenticationHandler: if user_info is None: verbose_proxy_logger.debug("User not found in LiteLLM DB, skipping team member addition") return - sso_teams: Final = getattr(result, "team_ids", []) + sso_teams: Final = result.team_ids if isinstance(result, CustomOpenID) else [] await add_missing_team_member(user_info=user_info, sso_teams=sso_teams) @staticmethod diff --git a/tests/unit/proxy/management_endpoints/test_ui_sso.py b/tests/unit/proxy/management_endpoints/test_ui_sso.py index 7db37588cad..8ff0b24982f 100644 --- a/tests/unit/proxy/management_endpoints/test_ui_sso.py +++ b/tests/unit/proxy/management_endpoints/test_ui_sso.py @@ -939,6 +939,83 @@ def test_build_sso_user_update_data_normalizes_email(): assert "user_role" not in update_data +def test_build_sso_user_update_data_fills_empty_user_alias_from_display_name(): + """ + An existing SSO user with no alias gets the IdP display name on login. + """ + from litellm.proxy.management_endpoints.types import CustomOpenID + from litellm.proxy.management_endpoints.ui_sso import _build_sso_user_update_data + + sso_result = CustomOpenID( + id="S-1-5-21-adfs-user", + email="jane.doe@example.com", + first_name="Jane", + last_name="Doe", + display_name="Doe, Jane", + provider="generic", + team_ids=[], + ) + + update_data = _build_sso_user_update_data( + result=sso_result, + user_email="jane.doe@example.com", + user_id="S-1-5-21-adfs-user", + existing_user_alias=None, + ) + + assert update_data == {"user_email": "jane.doe@example.com", "user_alias": "Doe, Jane"} + + +def test_build_sso_user_update_data_keeps_existing_user_alias(): + """ + An alias already stored for the user is never overwritten by the IdP display name. + """ + from litellm.proxy.management_endpoints.types import CustomOpenID + from litellm.proxy.management_endpoints.ui_sso import _build_sso_user_update_data + + sso_result = CustomOpenID( + id="S-1-5-21-adfs-user", + email="jane.doe@example.com", + display_name="Doe, Jane", + provider="generic", + team_ids=[], + ) + + update_data = _build_sso_user_update_data( + result=sso_result, + user_email="jane.doe@example.com", + user_id="S-1-5-21-adfs-user", + existing_user_alias="Admin-set alias", + ) + + assert update_data == {"user_email": "jane.doe@example.com"} + + +@pytest.mark.parametrize( + "result, expected_alias", + [ + ( + CustomOpenID(id="user-1", display_name="Doe, Jane", first_name="Jane", last_name="Doe", team_ids=[]), + "Doe, Jane", + ), + (CustomOpenID(id="user-1", first_name="Jane", last_name="Doe", team_ids=[]), "Jane Doe"), + (CustomOpenID(id="user-1", display_name="user-1", first_name="Jane", team_ids=[]), "Jane"), + (CustomOpenID(id="user-1", display_name="user-1", team_ids=[]), None), + (CustomOpenID(id="user-1", display_name=" ", first_name=" Jane ", last_name="Doe", team_ids=[]), "Jane Doe"), + (CustomOpenID(id="user-1", display_name=" ", first_name=" ", team_ids=[]), None), + ({"id": "user-1", "display_name": "Dict User", "first_name": None, "last_name": None}, "Dict User"), + (None, None), + ], +) +def test_get_sso_user_alias(result: CustomOpenID | dict[str, str | None] | None, expected_alias: str | None): + """ + The alias is the IdP display name unless it is just the user id, then the joined first/last name. + """ + from litellm.proxy.management_endpoints.ui_sso import _get_sso_user_alias + + assert _get_sso_user_alias(result) == expected_alias + + def test_generic_response_convertor_normalizes_email(): """ Test that generic_response_convertor normalizes email addresses. @@ -1022,6 +1099,87 @@ async def test_upsert_sso_user_updates_role_for_existing_user(): assert call_args.kwargs["data"]["user_role"] == "proxy_admin" +@pytest.mark.asyncio +async def test_upsert_sso_user_fills_user_alias_for_existing_user(): + """ + An existing user row without an alias is updated with the SSO display name on login. + """ + from litellm.proxy._types import LiteLLM_UserTable + from litellm.proxy.management_endpoints.types import CustomOpenID + from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler + + mock_prisma = MagicMock() + mock_prisma.db.litellm_usertable.update_many = AsyncMock() + + existing_user = LiteLLM_UserTable( + user_id="S-1-5-21-adfs-user", + user_email="jane.doe@example.com", + user_role="internal_user", + user_alias=None, + ) + sso_result = CustomOpenID( + id="S-1-5-21-adfs-user", + email="jane.doe@example.com", + first_name="Jane", + last_name="Doe", + display_name="Doe, Jane", + provider="generic", + team_ids=[], + ) + + await SSOAuthenticationHandler.upsert_sso_user( + result=sso_result, + user_info=existing_user, + user_email="jane.doe@example.com", + user_defined_values=None, + prisma_client=mock_prisma, + ) + + mock_prisma.db.litellm_usertable.update_many.assert_called_once_with( + where={"user_id": "S-1-5-21-adfs-user"}, + data={"user_email": "jane.doe@example.com", "user_alias": "Doe, Jane"}, + ) + + +@pytest.mark.asyncio +async def test_insert_sso_user_sets_user_alias_from_display_name(): + """ + A newly created SSO user is inserted with the IdP display name as user_alias. + """ + from litellm.proxy._types import NewUserResponse, SSOUserDefinedValues + from litellm.proxy.management_endpoints.types import CustomOpenID + from litellm.proxy.management_endpoints.ui_sso import insert_sso_user + + sso_result = CustomOpenID( + id="S-1-5-21-adfs-user", + email="jane.doe@example.com", + first_name="Jane", + last_name="Doe", + display_name="Doe, Jane", + provider="generic", + team_ids=[], + ) + user_defined_values: SSOUserDefinedValues = { + "models": [], + "user_id": "S-1-5-21-adfs-user", + "user_email": "jane.doe@example.com", + "max_budget": None, + "user_role": "internal_user", + "budget_duration": None, + } + + with patch( + "litellm.proxy.management_endpoints.ui_sso.new_user", + return_value=NewUserResponse(user_id="S-1-5-21-adfs-user", key="sk-xxxxx", teams=None), + ) as mock_new_user: + await insert_sso_user(result_openid=sso_result, user_defined_values=user_defined_values) + + new_user_request = mock_new_user.call_args.kwargs["data"] + assert new_user_request.user_id == "S-1-5-21-adfs-user" + assert new_user_request.user_email == "jane.doe@example.com" + assert new_user_request.user_alias == "Doe, Jane" + + @pytest.mark.asyncio async def test_upsert_sso_user_does_not_update_invalid_role(): """ From 0737e26463e1cc5346af53d73387b18b8f2ae34e Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:16:03 -0700 Subject: [PATCH 10/83] fix(cost-map): restore later azure Models API retirement dates and date gpt-6.1-sol (#44072) Price-Sync: litellm-providers Co-authored-by: berriai-litellm-provider-info-sync[bot] <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Co-authored-by: kerry-berri --- ...odel_prices_and_context_window_backup.json | 19 +++++++++++-------- model_prices_and_context_window.json | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 40fdf083cf8..5787f2cf3fb 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -5516,7 +5516,7 @@ "supports_web_search": false }, "azure/gpt-4.1-nano": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, @@ -5550,7 +5550,7 @@ "supports_vision": true }, "azure/gpt-4.1-nano-2025-04-14": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, @@ -6133,7 +6133,7 @@ "cache_creation_input_audio_token_cost": 4e-07, "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, - "deprecation_date": "2027-06-25", + "deprecation_date": "2027-07-31", "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, "input_cost_per_token": 4e-06, @@ -6168,7 +6168,7 @@ "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 6e-08, - "deprecation_date": "2027-06-25", + "deprecation_date": "2027-07-31", "input_cost_per_audio_token": 1e-05, "input_cost_per_image_token": 8e-07, "input_cost_per_token": 6e-07, @@ -6346,7 +6346,7 @@ "supports_tool_choice": true }, "azure/gpt-4o-transcribe": { - "deprecation_date": "2026-10-15", + "deprecation_date": "2026-12-31", "input_cost_per_audio_token": 2.5e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", @@ -6354,6 +6354,7 @@ "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 1e-05, + "source": "https://management.azure.com/subscriptions/c873328e-b572-4770-8dff-aaeb6f1f0e79/providers/Microsoft.CognitiveServices/locations/eastus2/models?api-version=2024-10-01", "supported_endpoints": [ "/v1/audio/transcriptions" ] @@ -8571,6 +8572,7 @@ "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "deprecation_date": "2028-03-11", "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "litellm_provider": "azure", @@ -8619,6 +8621,7 @@ "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "deprecation_date": "2028-03-11", "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "litellm_provider": "azure", @@ -10972,7 +10975,7 @@ "supports_web_search": false }, "azure/us/gpt-4.1-nano-2025-04-14": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, "input_cost_per_token_batches": 5.5e-08, @@ -70653,7 +70656,7 @@ "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-4.1-nano": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, "input_cost_per_token_batches": 5.5e-08, @@ -71102,7 +71105,7 @@ "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-4.1-nano": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, "input_cost_per_token_batches": 5.5e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 40fdf083cf8..5787f2cf3fb 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -5516,7 +5516,7 @@ "supports_web_search": false }, "azure/gpt-4.1-nano": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, @@ -5550,7 +5550,7 @@ "supports_vision": true }, "azure/gpt-4.1-nano-2025-04-14": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, @@ -6133,7 +6133,7 @@ "cache_creation_input_audio_token_cost": 4e-07, "cache_read_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, - "deprecation_date": "2027-06-25", + "deprecation_date": "2027-07-31", "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image_token": 5e-06, "input_cost_per_token": 4e-06, @@ -6168,7 +6168,7 @@ "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 6e-08, - "deprecation_date": "2027-06-25", + "deprecation_date": "2027-07-31", "input_cost_per_audio_token": 1e-05, "input_cost_per_image_token": 8e-07, "input_cost_per_token": 6e-07, @@ -6346,7 +6346,7 @@ "supports_tool_choice": true }, "azure/gpt-4o-transcribe": { - "deprecation_date": "2026-10-15", + "deprecation_date": "2026-12-31", "input_cost_per_audio_token": 2.5e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", @@ -6354,6 +6354,7 @@ "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 1e-05, + "source": "https://management.azure.com/subscriptions/c873328e-b572-4770-8dff-aaeb6f1f0e79/providers/Microsoft.CognitiveServices/locations/eastus2/models?api-version=2024-10-01", "supported_endpoints": [ "/v1/audio/transcriptions" ] @@ -8571,6 +8572,7 @@ "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "deprecation_date": "2028-03-11", "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "litellm_provider": "azure", @@ -8619,6 +8621,7 @@ "cache_creation_input_token_cost_above_272k_tokens": 5e-06, "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "deprecation_date": "2028-03-11", "input_cost_per_token": 2e-06, "input_cost_per_token_above_272k_tokens": 4e-06, "litellm_provider": "azure", @@ -10972,7 +10975,7 @@ "supports_web_search": false }, "azure/us/gpt-4.1-nano-2025-04-14": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, "input_cost_per_token_batches": 5.5e-08, @@ -70653,7 +70656,7 @@ "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, "azure/eu/gpt-4.1-nano": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, "input_cost_per_token_batches": 5.5e-08, @@ -71102,7 +71105,7 @@ "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, "azure/us/gpt-4.1-nano": { - "deprecation_date": "2026-10-14", + "deprecation_date": "2027-04-14", "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 1.1e-07, "input_cost_per_token_batches": 5.5e-08, From cc42a352cb3642f07e04cb9c8aaf66d349cf5942 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Thu, 1 Oct 2026 17:21:41 -0700 Subject: [PATCH 11/83] feat(lens): simplify setup and investigation workflow (#44089) * feat(lens): simplify investigation setup and results * fix(lens): pin worker with actionable failure diagnostics * fix(lens): focus worker success on starting an investigation * feat(lens): simplify investigation setup and worker defaults * fix(lens): remove setup repetition and label billing access * fix(lens): finish agent selection and setup readiness * fix(lens): handle unavailable setup dependencies and restore UI build --- deploy/lens/compose.yaml | 2 +- .../crates/traces/query/lens_agents.sql | 6 + .../crates/traces/query/lens_availability.sql | 8 + .../crates/traces/query/lens_sample.sql | 2 + litellm-rust/crates/traces/src/sql.rs | 6 + .../crates/traces/tests/migrations.rs | 108 +++ litellm/proxy/lens/endpoints.py | 20 +- litellm/proxy/lens/models.py | 7 +- litellm/proxy/lens/sources.py | 22 + litellm/proxy/lens/worker.py | 43 +- litellm/rust_bridge/traces.py | 6 + tests/integration/spend/test_lens_billing.py | 4 +- tests/unit/proxy/lens/test_endpoints.py | 17 +- tests/unit/proxy/lens/test_sources.py | 45 ++ tests/unit/proxy/lens/test_state.py | 6 +- tests/unit/proxy/lens/test_worker.py | 34 +- .../lens/_components/ActivityScope.tsx | 532 ++++++++------ .../AnalysisKey.integration.test.tsx | 37 +- .../lens/_components/AnalysisKey.tsx | 115 ++- .../lens/_components/AnalysisKeyDetails.tsx | 99 +++ .../lens/_components/DurationInput.tsx | 31 +- .../lens/_components/LensFinding.tsx | 146 ++++ .../lens/_components/LensOverview.tsx | 270 +++++++ .../lens/_components/LensProgress.tsx | 2 +- .../(dashboard)/lens/_components/LensRuns.tsx | 106 +-- .../LensSetup.integration.test.tsx | 347 ++++++++- .../lens/_components/LensSetup.tsx | 585 ++++++++------- .../_components/LensView.integration.test.tsx | 153 +++- .../(dashboard)/lens/_components/LensView.tsx | 687 +++++++++--------- .../lens/_components/LensWelcome.tsx | 225 +++--- .../lens/_components/TracePanel.tsx | 2 +- .../WorkerSetup.integration.test.tsx | 93 ++- .../lens/_components/WorkerSetup.tsx | 323 +++++--- .../lens/_components/lensData.test.ts | 24 +- .../(dashboard)/lens/_components/lensData.ts | 40 +- .../src/app/(dashboard)/lens/page.test.tsx | 5 +- .../src/app/(dashboard)/lens/page.tsx | 3 +- .../TraceView/AgentTracesSection.test.tsx | 26 + .../TraceView/AgentTracesSection.tsx | 60 +- .../view_logs/TraceView/AgentTracesTable.tsx | 23 +- .../view_logs/TraceView/useAgentTraces.ts | 28 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 94 ++- .../src/utils/activityTimestamp.test.ts | 26 + .../src/utils/activityTimestamp.ts | 15 + 44 files changed, 3090 insertions(+), 1343 deletions(-) create mode 100644 litellm-rust/crates/traces/query/lens_agents.sql create mode 100644 litellm-rust/crates/traces/query/lens_availability.sql create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKeyDetails.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensFinding.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensOverview.tsx create mode 100644 ui/litellm-dashboard/src/utils/activityTimestamp.test.ts create mode 100644 ui/litellm-dashboard/src/utils/activityTimestamp.ts diff --git a/deploy/lens/compose.yaml b/deploy/lens/compose.yaml index d41cb8eb203..4d1224fd41e 100644 --- a/deploy/lens/compose.yaml +++ b/deploy/lens/compose.yaml @@ -1,6 +1,6 @@ services: lens-worker: - image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker@sha256:a8e8731d954916594eea462969946b9292fb771681ff515a9fd296b53f856c77} + image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker@sha256:67eba741c1b97c749975c5c38e2370a603e1105babc908d613c1b79d7b995393} environment: LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container} LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI} diff --git a/litellm-rust/crates/traces/query/lens_agents.sql b/litellm-rust/crates/traces/query/lens_agents.sql new file mode 100644 index 00000000000..fbdd578f8e7 --- /dev/null +++ b/litellm-rust/crates/traces/query/lens_agents.sql @@ -0,0 +1,6 @@ +SELECT DISTINCT AgentName AS agent_name +FROM otel_traces +WHERE AgentName != '' + AND ({all_teams:UInt8}=1 OR TeamId={team:String}) + AND ({key_hash:String}='' OR ApiKeyHash={key_hash:String}) +ORDER BY agent_name diff --git a/litellm-rust/crates/traces/query/lens_availability.sql b/litellm-rust/crates/traces/query/lens_availability.sql new file mode 100644 index 00000000000..8d350dd1779 --- /dev/null +++ b/litellm-rust/crates/traces/query/lens_availability.sql @@ -0,0 +1,8 @@ +SELECT + EXISTS(SELECT 1 FROM otel_traces + WHERE ({all_teams:UInt8}=1 OR TeamId={team:String}) + AND ({key_hash:String}='' OR ApiKeyHash={key_hash:String})) AS traces, + EXISTS(SELECT 1 FROM spend_logs + WHERE ({all_teams:UInt8}=1 OR team_id={team:String}) + AND ({key_hash:String}='' OR api_key={key_hash:String}) + AND NOT JSONExtractBool(metadata,'litellm_lens_internal')) AS requests diff --git a/litellm-rust/crates/traces/query/lens_sample.sql b/litellm-rust/crates/traces/query/lens_sample.sql index 1fc9c964a6f..92086c33c13 100644 --- a/litellm-rust/crates/traces/query/lens_sample.sql +++ b/litellm-rust/crates/traces/query/lens_sample.sql @@ -28,6 +28,7 @@ SELECT *, selection_key FROM ( GROUP BY TeamId,ApiKeyHash,TraceId HAVING max(EngineReceivedMs) < {end:UInt64} AND max(toUnixTimestamp64Milli(Timestamp)+toInt64(intDiv(Duration,1000000))) < {end:UInt64} + AND ({agent_name:String}='' OR countIf(AgentName={agent_name:String}) > 0) AND countIf(arrayAll((k,v) -> ResourceAttributes[k]=v OR SpanAttributes[k]=v, {filter_keys:Array(String)},{filter_values:Array(String)}) AND ({service:String}='' OR ServiceName={service:String})) > 0 @@ -48,6 +49,7 @@ SELECT *, selection_key FROM ( OR JSONExtractString(metadata,'requester_metadata',k)=v OR (k='tag' AND has(request_tags,v)), {filter_keys:Array(String)},{filter_values:Array(String)}) AND ({service:String}='' OR model_group={service:String}) + AND {agent_name:String}='' AND NOT JSONExtractBool(metadata,'litellm_lens_internal') AND ({source:String}!='both' OR (team_id,api_key,response_id) NOT IN ( SELECT TeamId,ApiKeyHash,LiteLLMRequestId FROM otel_traces diff --git a/litellm-rust/crates/traces/src/sql.rs b/litellm-rust/crates/traces/src/sql.rs index 36d6e3b4521..51632066fdd 100644 --- a/litellm-rust/crates/traces/src/sql.rs +++ b/litellm-rust/crates/traces/src/sql.rs @@ -37,6 +37,8 @@ impl ReadQuery { #[derive(Clone, Copy)] pub enum LensQuery { + Availability, + Agents, Sample, Content, Evidence, @@ -45,6 +47,8 @@ pub enum LensQuery { impl LensQuery { pub fn parse(name: &str) -> Result { match name { + "availability" => Ok(Self::Availability), + "agents" => Ok(Self::Agents), "sample" => Ok(Self::Sample), "content" => Ok(Self::Content), "evidence" => Ok(Self::Evidence), @@ -53,6 +57,8 @@ impl LensQuery { } pub fn sql(self) -> &'static str { match self { + Self::Availability => include_str!("../query/lens_availability.sql"), + Self::Agents => include_str!("../query/lens_agents.sql"), Self::Sample => include_str!("../query/lens_sample.sql"), Self::Content => include_str!("../query/lens_content.sql"), Self::Evidence => include_str!("../query/lens_evidence.sql"), diff --git a/litellm-rust/crates/traces/tests/migrations.rs b/litellm-rust/crates/traces/tests/migrations.rs index 01e8982423f..238c5e671c6 100644 --- a/litellm-rust/crates/traces/tests/migrations.rs +++ b/litellm-rust/crates/traces/tests/migrations.rs @@ -551,6 +551,7 @@ async fn lens_filters_reads_and_evidence_keep_reused_trace_ids_separate( "end".into(), Parameter::Integer(timestamp / 1_000_000 + 1000), ), + ("agent_name".into(), Parameter::Text(String::new())), ("service".into(), Parameter::Text("review".into())), ( "filter_keys".into(), @@ -652,6 +653,7 @@ async fn lens_request_sample_does_not_trust_caller_tags( ("key_hash".into(), Parameter::Text(String::new())), ("start".into(), Parameter::Integer(timestamp - 1000)), ("end".into(), Parameter::Integer(timestamp + 60000)), + ("agent_name".into(), Parameter::Text(String::new())), ("service".into(), Parameter::Text(String::new())), ("filter_keys".into(), Parameter::Strings(vec![])), ("filter_values".into(), Parameter::Strings(vec![])), @@ -719,6 +721,7 @@ async fn lens_selection_pages_without_losing_or_repeating_runs( ("key_hash".into(), Parameter::Text(String::new())), ("start".into(), Parameter::Integer(0)), ("end".into(), Parameter::Integer(end)), + ("agent_name".into(), Parameter::Text(String::new())), ("service".into(), Parameter::Text(String::new())), ("filter_keys".into(), Parameter::Strings(vec![])), ("filter_values".into(), Parameter::Strings(vec![])), @@ -980,3 +983,108 @@ async fn duplicate_span_preview_matches_diagnostic( assert_eq!(diagnostic["data"][0]["message"], message); Ok(()) } + +#[rstest] +#[tokio::test] +async fn lens_agent_discovery_and_selection_preserve_scope( + #[future] database: TestResult, +) -> TestResult { + use litellm_traces::LensQuery; + let database = database.await?; + let writer = Connection::writer(&database.url)?; + ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?; + let timestamp = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64; + for (team, key, trace, agent, span, parent) in [ + ("alpha", "one", "research", "research_agent", "root", ""), + ("alpha", "one", "research", "", "tool", "root"), + ("alpha", "one", "support", "support_agent", "root", ""), + ("alpha", "two", "hidden-key", "private_agent", "root", ""), + ("beta", "one", "hidden-team", "other_agent", "root", ""), + ] { + insert_rows( + &database, + "otel_traces", + vec![serde_json::from_value(serde_json::json!({ + "Timestamp": timestamp, "TraceId": trace, "SpanId": span, "ParentSpanId": parent, + "ServiceName": "shared-app", "SpanName": "run", "Input": "test", + "SpanAttributes": {"gen_ai.agent.name": agent}, + "ResourceAttributes": {"litellm.team_id": team, "litellm.api_key_hash": key} + }))?], + ) + .await?; + } + let connection = Connection::configured(&database.url, "trace_test", "default", "")?; + let scope_parameters = BTreeMap::from([ + ("all_teams".into(), Parameter::Integer(0)), + ("team".into(), Parameter::Text("alpha".into())), + ("key_hash".into(), Parameter::Text("one".into())), + ]); + let agents: serde_json::Value = serde_json::from_str( + &execute_read( + &database.client, + &connection, + LensQuery::Agents.sql(), + &scope_parameters, + ) + .await?, + )?; + assert_eq!( + agents["data"], + serde_json::json!([ + {"agent_name": "research_agent"}, {"agent_name": "support_agent"} + ]) + ); + let parameters = scope_parameters + .into_iter() + .chain([ + ("source".into(), Parameter::Text("traces".into())), + ( + "start".into(), + Parameter::Integer(timestamp / 1_000_000 - 1000), + ), + ( + "end".into(), + Parameter::Integer(timestamp / 1_000_000 + 1000), + ), + ("service".into(), Parameter::Text("shared-app".into())), + ( + "agent_name".into(), + Parameter::Text("research_agent".into()), + ), + ("filter_keys".into(), Parameter::Strings(vec![])), + ("filter_values".into(), Parameter::Strings(vec![])), + ("limit".into(), Parameter::Integer(100)), + ("offset".into(), Parameter::Integer(0)), + ("after".into(), Parameter::Text(String::new())), + ("sample_percent".into(), Parameter::Text("100".into())), + ("sample_cap".into(), Parameter::Integer(0)), + ("preview".into(), Parameter::Integer(1)), + ("selected_team".into(), Parameter::Text(String::new())), + ("execution_ids".into(), Parameter::Strings(vec![])), + ]) + .collect::>(); + let sample: serde_json::Value = serde_json::from_str( + &execute_read( + &database.client, + &connection, + LensQuery::Sample.sql(), + ¶meters, + ) + .await?, + )?; + assert_eq!(sample["data"].as_array().expect("rows").len(), 1); + assert_eq!(sample["data"][0]["trace_id"], "research"); + assert_eq!(sample["data"][0]["span_count"], 2); + let available: serde_json::Value = serde_json::from_str( + &execute_read( + &database.client, + &connection, + LensQuery::Availability.sql(), + ¶meters, + ) + .await?, + )?; + assert_eq!(available["data"][0]["traces"], 1); + assert_eq!(available["data"][0]["requests"], 0); + Ok(()) +} diff --git a/litellm/proxy/lens/endpoints.py b/litellm/proxy/lens/endpoints.py index 0349c594adf..349ecb9a353 100644 --- a/litellm/proxy/lens/endpoints.py +++ b/litellm/proxy/lens/endpoints.py @@ -2,6 +2,7 @@ import hashlib import secrets from datetime import datetime, timedelta, timezone from functools import reduce +from itertools import chain from types import MappingProxyType from typing import Annotated, Final, TypeAlias from uuid import uuid4 @@ -35,7 +36,7 @@ from litellm.proxy.lens.models import ( WorkerCreated, ) from litellm.proxy.lens.repository import LensRepository, WriterDatabase -from litellm.proxy.lens.sources import SourceReader, Storage, parse_execution +from litellm.proxy.lens.sources import ActivityAvailability, SourceReader, Storage, parse_execution from litellm.proxy.lens.state import ( can_access, claim_job, @@ -168,6 +169,18 @@ async def create_lens(settings: LensSettings, auth: Auth) -> Lens: return await repository().create(queue_job(lens, now, str(uuid4()))) +@router.get("/activity/available", response_model=ActivityAvailability) +async def activity_available(auth: Auth, storage: StorageDep) -> ActivityAvailability: + scope: Final = user_scope(auth) + return await source_reader(storage).availability(scope) if storage is not None else ActivityAvailability() + + +@router.get("/agents", response_model=tuple[str, ...]) +async def list_agents(auth: Auth, storage: StorageDep) -> tuple[str, ...]: + scope: Final = user_scope(auth) + return await source_reader(storage).agents(scope) if storage is not None else () + + @router.put("/{lens_id}", response_model=Lens) async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens: await get_lens(lens_id, user_scope(auth, write=True)) @@ -264,7 +277,7 @@ class Preview(BaseModel): as_of: AwareDatetime | None = None offset: int = Field(default=0, ge=0) settings: LensSettings - lookback_hours: int = Field(default=24, ge=1, le=720) + lookback_hours: int = Field(default=24, ge=1, le=8760) @router.post("/preview/sample", response_model=Sample) @@ -326,6 +339,9 @@ async def revoke_worker(worker_id: str, auth: Auth) -> bool: worker: Final = next((w for w in await repository().workers() if w.id == worker_id), None) if worker is None or not can_access(scope, worker.scope): raise HTTPException(404, "Worker not found") + jobs: Final = chain.from_iterable(lens.jobs for lens in await repository().lenses()) + if any(job.status == "running" and job.worker_id == worker.id for job in jobs): + raise HTTPException(409, "Wait for this worker's investigation to finish or cancel it before revoking access") await repository().revoke_worker(worker.id) return True diff --git a/litellm/proxy/lens/models.py b/litellm/proxy/lens/models.py index eb88801d065..91f0ad582bf 100644 --- a/litellm/proxy/lens/models.py +++ b/litellm/proxy/lens/models.py @@ -29,8 +29,9 @@ class LensSettings(Record): name: str = Field(min_length=1, max_length=100) context: str = Field(default="", max_length=6000) source: Literal["traces", "requests", "both"] = "traces" - lookback_hours: int = Field(default=24, ge=1, le=720) + lookback_hours: int = Field(default=24, ge=1, le=8760) service: str = Field(default="", max_length=200) + agent_name: str = Field(default="", max_length=200) filters: tuple[MetadataFilter, ...] = Field(default=(), max_length=8) checks: tuple[Check, ...] = () model: str = Field(min_length=1, max_length=200) @@ -41,7 +42,7 @@ class LensSettings(Record): concurrency: int = Field(default=8, ge=1) team_id: str = "" execution_ids: tuple[str, ...] = () - monthly_budget: float = Field(default=20, gt=0, le=100000, allow_inf_nan=False) + monthly_budget: float = Field(default=100, gt=0, le=100000, allow_inf_nan=False) @model_validator(mode="after") def unique_checks(self) -> "LensSettings": @@ -214,7 +215,7 @@ class LensList(Record): class RunRequest(Record): settings: LensSettings | None = None - lookback_hours: int | None = Field(default=None, ge=1, le=720) + lookback_hours: int | None = Field(default=None, ge=1, le=8760) class FindingUpdate(Record): diff --git a/litellm/proxy/lens/sources.py b/litellm/proxy/lens/sources.py index 12d26cd4974..17550cc4aab 100644 --- a/litellm/proxy/lens/sources.py +++ b/litellm/proxy/lens/sources.py @@ -18,7 +18,14 @@ from litellm.proxy.lens.models import ( ) +class ActivityAvailability(BaseModel): + traces: bool = False + requests: bool = False + + class Storage(Protocol): + def lens_availability(self, parameters: Mapping[str, object]) -> Awaitable[object]: ... + def lens_agents(self, parameters: Mapping[str, object]) -> Awaitable[object]: ... def lens_sample(self, parameters: Mapping[str, object]) -> Awaitable[object]: ... def lens_content(self, parameters: Mapping[str, object]) -> Awaitable[object]: ... def lens_evidence(self, parameters: Mapping[str, object]) -> Awaitable[object]: ... @@ -53,6 +60,12 @@ class CountRow(BaseModel): count: int +class AgentRow(BaseModel): + agent_name: str + + +_AVAILABILITY: Final = TypeAdapter(tuple[ActivityAvailability, ...]) +_AGENTS: Final = TypeAdapter(tuple[AgentRow, ...]) _ROWS: Final = TypeAdapter(tuple[ExecutionRow, ...]) _PARTS: Final = TypeAdapter(tuple[PartRow, ...]) _COUNTS: Final = TypeAdapter(tuple[CountRow, ...]) @@ -90,6 +103,14 @@ class SourceReader: def __init__(self, storage: Storage) -> None: self.storage: Final = storage + async def availability(self, scope: Scope) -> ActivityAvailability: + rows: Final = _AVAILABILITY.validate_python(await self.storage.lens_availability(parameters(scope, ()))) + return rows[0] if rows else ActivityAvailability() + + async def agents(self, scope: Scope) -> tuple[str, ...]: + rows: Final = _AGENTS.validate_python(await self.storage.lens_agents(parameters(scope, ()))) + return tuple(row.agent_name for row in rows) + async def sample( self, scope: Scope, @@ -108,6 +129,7 @@ class SourceReader: "start": start, "end": end, "service": settings.service, + "agent_name": settings.agent_name, "limit": page_size, "offset": offset, "after": cursor, diff --git a/litellm/proxy/lens/worker.py b/litellm/proxy/lens/worker.py index 2980f62deed..62f8295e7d3 100644 --- a/litellm/proxy/lens/worker.py +++ b/litellm/proxy/lens/worker.py @@ -15,6 +15,40 @@ from .models import Claim, Coverage, ExecutionContent, ModelRequest, ModelResult logger: Final = logging.getLogger("litellm.lens.worker") +def failure_message(error: Exception) -> str: + if isinstance(error, (OSError, sqlite3.Error)): + return "Worker temporary storage failed. Increase its capacity or reduce analysis parallelism." + if isinstance(error, httpx.TimeoutException): + return "The worker timed out waiting for the proxy. Check proxy availability and model response times." + if isinstance(error, httpx.TransportError): + return "The worker could not connect to the proxy. Check the proxy URL, network access, and TLS configuration." + if isinstance(error, httpx.HTTPStatusError): + path: Final = error.request.url.path + action: Final = ( + "Model request" + if path.endswith("/model") + else "Reading trace data" + if path.endswith(("/sample", "/content")) + else "Saving results" + if path.endswith("/result") + else "Worker request" + ) + status: Final = error.response.status_code + guidance: Final = MappingProxyType( + { + 400: "Check the configured model and whether the worker's billing key is enabled.", + 401: "Check the worker credential and its assigned billing key.", + 402: "Check the investigation's monthly limit and the worker key's remaining budget.", + 403: "Check the worker key's model permissions and access restrictions.", + 404: "Check that the proxy and worker versions match and the requested model is configured.", + 409: "This worker no longer owns the run. Check whether it was cancelled or claimed again.", + 429: "The request was rate limited. Retry later or check the worker key's rate limits.", + } + ).get(status, "Check proxy and model availability, then retry the investigation.") + return f"{action} failed (HTTP {status}). {guidance}" + return "The worker could not read an analysis response. Check structured JSON support and matching proxy/worker versions." + + class LensWorker: def __init__(self, client: httpx.AsyncClient, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep) -> None: self.client: Final = client @@ -82,14 +116,7 @@ class LensWorker: saved: Final = await self.client.post(prefix + "/result", json=result.model_dump(mode="json")) saved.raise_for_status() except (httpx.HTTPError, ValueError, OSError, sqlite3.Error) as exc: - status: Final = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None - message: Final = ( - "Worker temporary storage failed. Increase its capacity or reduce analysis parallelism." - if isinstance(exc, (OSError, sqlite3.Error)) - else "Monthly budget reached" - if status == 402 - else "Analysis interrupted. Check worker connectivity, model configuration, and trace storage." - ) + message: Final = failure_message(exc) logger.warning("Analysis %s interrupted (%s)", claim.job.id, type(exc).__name__) failed: Final = await self.client.post( prefix + "/result", json=Result(coverage=Coverage(), error=message).model_dump() diff --git a/litellm/rust_bridge/traces.py b/litellm/rust_bridge/traces.py index 6724db41ad3..06e006be89c 100644 --- a/litellm/rust_bridge/traces.py +++ b/litellm/rust_bridge/traces.py @@ -108,6 +108,12 @@ class ClickHouseStorage: async def lens_sample(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]: return await self._lens_query("sample", parameters) + async def lens_availability(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]: + return await self._lens_query("availability", parameters) + + async def lens_agents(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]: + return await self._lens_query("agents", parameters) + async def lens_content(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]: return await self._lens_query("content", parameters) diff --git a/tests/integration/spend/test_lens_billing.py b/tests/integration/spend/test_lens_billing.py index bedcf6c5380..d8eded62b39 100644 --- a/tests/integration/spend/test_lens_billing.py +++ b/tests/integration/spend/test_lens_billing.py @@ -124,6 +124,9 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway, seconds=70, ) assert second_rows[0]["spend"] == pytest.approx(expected) + active_revoke: Final = gateway.request("DELETE", f"/lens/workers/{worker_id}") + assert active_revoke.status_code == 409, active_revoke.text + gateway.post(f"/lens/{lens_id}/cancel", {}) revoked: Final = gateway.request("DELETE", f"/lens/workers/{worker_id}") assert revoked.status_code == 200, revoked.text denied_worker: Final = gateway.request( @@ -134,7 +137,6 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway, "PUT", f"/lens/workers/{worker_id}/billing-key", {"analysis_key_id": replacement_id} ) assert forbidden_change.status_code == 409, forbidden_change.text - gateway.post(f"/lens/{lens_id}/cancel", {}) @pytest.mark.parametrize("cancel_on_disconnect", (False, True)) diff --git a/tests/unit/proxy/lens/test_endpoints.py b/tests/unit/proxy/lens/test_endpoints.py index 97bb7759a02..ca19277da08 100644 --- a/tests/unit/proxy/lens/test_endpoints.py +++ b/tests/unit/proxy/lens/test_endpoints.py @@ -4,7 +4,22 @@ import pytest from fastapi import HTTPException from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth -from litellm.proxy.lens.endpoints import user_scope +from litellm.proxy.lens.endpoints import list_agents, user_scope + + +@pytest.mark.parametrize("role", (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)) +@pytest.mark.asyncio +async def test_agent_discovery_without_trace_storage_is_empty(role: LitellmUserRoles) -> None: + auth: Final = UserAPIKeyAuth(user_role=role) + assert await list_agents(auth, None) == () + + +@pytest.mark.asyncio +async def test_agent_discovery_without_trace_storage_still_requires_admin_access() -> None: + auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER) + with pytest.raises(HTTPException) as error: + await list_agents(auth, None) + assert error.value.status_code == 403 @pytest.mark.parametrize( diff --git a/tests/unit/proxy/lens/test_sources.py b/tests/unit/proxy/lens/test_sources.py index 5dc6e2652f0..f063f496314 100644 --- a/tests/unit/proxy/lens/test_sources.py +++ b/tests/unit/proxy/lens/test_sources.py @@ -61,3 +61,48 @@ async def test_sample_never_returns_authentication_attributes() -> None: assert sample.executions[0].metadata == (MetadataFilter(key="environment", value="production"),) assert "opaque-oauth-bearer" not in sample.model_dump_json() assert sample.eligible == 1 + + +@pytest.mark.asyncio +async def test_agents_use_the_same_team_and_key_scope_as_samples() -> None: + class AgentStorage: + async def lens_agents(self, parameters): + assert parameters["all_teams"] == 0 + assert parameters["team"] == "alpha" + assert parameters["key_hash"] == "key-hash" + return [{"agent_name": "research_agent"}, {"agent_name": "support_agent"}] + + names: Final = await SourceReader(AgentStorage()).agents(Scope(team_id="alpha", api_key_hash="key-hash")) + assert names == ("research_agent", "support_agent") + + +@pytest.mark.asyncio +async def test_request_only_storage_is_available_for_investigation() -> None: + class RequestStorage: + async def lens_availability(self, parameters): + assert parameters["team"] == "alpha" + return [{"traces": 0, "requests": 1}] + + available: Final = await SourceReader(RequestStorage()).availability(Scope(team_id="alpha")) + assert available.requests + assert not available.traces + + +@pytest.mark.asyncio +async def test_agent_filter_is_independent_of_service_and_metadata() -> None: + class SampleStorage: + async def lens_sample(self, parameters): + assert parameters["agent_name"] == "research_agent" + assert parameters["service"] == "shared-app" + assert parameters["filter_keys"] == ("enduser.id",) + assert parameters["filter_values"] == ("user-42",) + return [] + + settings: Final = lens().settings.model_copy( + update={ + "agent_name": "research_agent", + "service": "shared-app", + "filters": (MetadataFilter(key="enduser.id", value="user-42"),), + } + ) + assert not (await SourceReader(SampleStorage()).sample(Scope(all_teams=True), settings, 1, 2)).executions diff --git a/tests/unit/proxy/lens/test_state.py b/tests/unit/proxy/lens/test_state.py index ac70a22077e..0e01085fb04 100644 --- a/tests/unit/proxy/lens/test_state.py +++ b/tests/unit/proxy/lens/test_state.py @@ -86,7 +86,7 @@ def test_behavior_description_is_sufficient_without_separate_checks() -> None: @pytest.mark.parametrize( - "field,value", (("sample_percent", 0), ("sample_percent", 101), ("sample_size", 0), ("concurrency", 0)) + "field,value", (("sample_percent", 0), ("sample_percent", 101), ("sample_size", 0), ("concurrency", 0), ("lookback_hours", 0), ("lookback_hours", 8761)) ) def test_invalid_selection_and_parallelism_are_rejected(field: str, value: int) -> None: from pydantic import ValidationError @@ -145,11 +145,11 @@ def test_monthly_budget_renews_without_erasing_job_costs() -> None: assert renew_budget(spent, NOW) is spent -@pytest.mark.parametrize("hours", (24, 168, 720)) +@pytest.mark.parametrize("hours", (24, 168, 720, 4800, 8760)) def test_every_scan_uses_the_configured_lookback_window(hours: int) -> None: original: Final = lens() configured: Final = original.model_copy( - update={"settings": original.settings.model_copy(update={"lookback_hours": hours})} + update={"settings": LensSettings.model_validate({**original.settings.model_dump(), "lookback_hours": hours})} ) first: Final = queue_job(configured, NOW, "first") assert first.jobs[0].start == NOW - timedelta(hours=hours) diff --git a/tests/unit/proxy/lens/test_worker.py b/tests/unit/proxy/lens/test_worker.py index a0212e03319..dce3fc04d45 100644 --- a/tests/unit/proxy/lens/test_worker.py +++ b/tests/unit/proxy/lens/test_worker.py @@ -15,7 +15,7 @@ from litellm.proxy.lens.models import ( TracePart, ) from litellm.proxy.lens.state import queue_job -from litellm.proxy.lens.worker import LensWorker +from litellm.proxy.lens.worker import LensWorker, failure_message from tests.unit.proxy.lens.test_state import NOW, lens @@ -126,6 +126,34 @@ async def test_worker_reads_claimed_activity_and_reports_analysis_or_failure(mod assert result.coverage.screened == 1 assert result.coverage.unassessable == 0 elif model_status == 402: - assert result.error == "Monthly budget reached" + assert "HTTP 402" in result.error and "remaining budget" in result.error else: - assert result.error.startswith("Analysis interrupted.") + assert result.error.startswith("Model request failed (HTTP 503).") + + +@pytest.mark.parametrize("status", (400, 401, 402, 403, 404, 409, 429, 503)) +def test_failure_reports_action_and_status_without_private_response_content(status: int) -> None: + request: Final = httpx.Request( + "POST", "https://private-host.test/lens/worker/private-lens/private-run/model?token=secret" + ) + response: Final = httpx.Response(status, request=request, text="private trace content and key") + error: Final = httpx.HTTPStatusError("private exception details", request=request, response=response) + message: Final = failure_message(error) + assert message.startswith(f"Model request failed (HTTP {status}).") + assert "private" not in message and "secret" not in message + + +@pytest.mark.parametrize( + "route,action", (("sample", "Reading trace data"), ("content", "Reading trace data"), ("result", "Saving results")) +) +def test_failure_identifies_the_failing_worker_operation(route: str, action: str) -> None: + request: Final = httpx.Request("GET", f"https://proxy.test/lens/worker/lens/job/{route}") + response: Final = httpx.Response(503, request=request) + error: Final = httpx.HTTPStatusError("private body", request=request, response=response) + assert failure_message(error).startswith(f"{action} failed (HTTP 503).") + + +def test_connection_timeout_and_invalid_response_have_distinct_private_diagnostics() -> None: + assert "connect to the proxy" in failure_message(httpx.ConnectError("private hostname")) + assert "timed out" in failure_message(httpx.ReadTimeout("private prompt")) + assert "structured JSON" in failure_message(ValueError("private model response")) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/ActivityScope.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/ActivityScope.tsx index 188c1e6db92..a8f845b02fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/ActivityScope.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/ActivityScope.tsx @@ -1,10 +1,18 @@ "use client"; -import { useEffect, useId, useState } from "react"; +import { useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Plus, X, ArrowUpRight } from "lucide-react"; +import { Plus, X, ChevronRight, RotateCw } from "lucide-react"; import { apiClient } from "@/components/networking"; import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxEmpty, +} from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import { TracePanel } from "./TracePanel"; import { type Sample, type Settings, runTime, durationLabel } from "./lensData"; @@ -15,7 +23,14 @@ export type ActivitySelection = Pick & Partial< Pick< Settings, - "service" | "filters" | "lookback_hours" | "sample_percent" | "sample_size" | "team_id" | "execution_ids" + | "service" + | "agent_name" + | "filters" + | "lookback_hours" + | "sample_percent" + | "sample_size" + | "team_id" + | "execution_ids" > >; @@ -28,10 +43,8 @@ export function RunList({ executions }: { executions: Sample["executions"] }) {

{run.name}

- {runTime(run.start_time)} · {run.source === "traces" ? `${run.span_count} steps` : "LLM request"} -

-

- {run.trace_id} + {runTime(run.start_time)} ·{" "} + {run.source === "traces" ? `${run.span_count} ${run.span_count === 1 ? "step" : "steps"}` : "LLM request"}

))} @@ -43,12 +56,22 @@ export function ActivityScope({ value, onChange, accessToken, + mode = "scope", + onPreviewReady, + manualSelection = false, + nameField, }: { value: ActivitySelection; onChange: (selection: ActivitySelection) => void; accessToken: string; + mode?: "scope" | "activity"; + onPreviewReady?: (ready: boolean) => void; + manualSelection?: boolean; + nameField?: ReactNode; }) { const id = useId(); + const hasFilters = !!value.filters?.length || !!value.team_id; + const [advanced, setAdvanced] = useState(hasFilters || !!value.service || value.source !== "traces"); const [offset, setOffset] = useState(0); const [scope, setScope] = useState(value); const [trace, setTrace] = useState<{ id: string; ref?: string } | null>(null); @@ -63,7 +86,7 @@ export function ActivityScope({ return () => clearTimeout(timer); }, [serialized]); const historyHours = value.lookback_hours ?? 24; - const validWindow = Number.isInteger(historyHours) && historyHours >= 1 && historyHours <= 720; + const validWindow = Number.isInteger(historyHours) && historyHours >= 1 && historyHours <= 8760; const percent = scope.sample_percent ?? 100; const cap = scope.sample_size; const validCap = cap == null || (Number.isInteger(cap) && cap > 0); @@ -96,12 +119,19 @@ export function ActivityScope({ lookback_hours: value.lookback_hours, }; const discoveryOptions = { - queryKey: ["lens-activity-options", value.source, value.lookback_hours, accessToken], + queryKey: ["lens-activity-options", value.source, value.lookback_hours, asOf, accessToken], queryFn: () => load(discoveryScope), staleTime: 60000, enabled: validWindow, }; const discovery = useQuery(discoveryOptions); + const agentOptions = { + queryKey: ["lens-agents", accessToken, asOf], + queryFn: () => apiClient.get("/lens/agents", { accessToken }), + enabled: value.source !== "requests", + staleTime: 60000, + }; + const agents = useQuery(agentOptions); const previewOptions = { queryKey: ["lens-activity-preview", scope, offset, asOf, accessToken], queryFn: () => load(scope, offset), @@ -109,18 +139,38 @@ export function ActivityScope({ staleTime: 30000, }; const preview = useQuery(previewOptions); + const empty = preview.data?.eligible === 0; + useEffect(() => { + if (!empty || !valid) return; + const timer = window.setTimeout(() => setAsOf(new Date().toISOString()), 15000); + return () => window.clearTimeout(timer); + }, [empty, valid, asOf]); + const refreshPreview = () => { + setOffset(0); + setAsOf(new Date().toISOString()); + }; const runs = discovery.data?.executions ?? []; const services = [...new Set(runs.map((r) => r.service).filter(Boolean))].sort(); + const selectedName = value.source === "requests" ? value.service : value.agent_name; + const names = value.source === "requests" ? services : agents.data ?? []; + const selectName = (name: string) => + onChange({ ...value, [value.source === "requests" ? "service" : "agent_name"]: name, execution_ids: [] }); const attributes = runs.flatMap((r) => r.metadata ?? []); const keys = [...new Set(attributes.map((a) => a.key).filter((key) => !key.startsWith("litellm.")))].sort(); const pending = serialized !== JSON.stringify(scope) || preview.isFetching; const ready = !pending && valid; + const hasSelection = !manualSelection || !!value.execution_ids?.length; + const hasMatches = !preview.error && (preview.data?.selected ?? 0) > 0; + const canReview = ready && hasMatches && hasSelection; + useEffect(() => { + onPreviewReady?.(canReview); + }, [canReview, onPreviewReady]); const filters = value.filters ?? []; const edit = (index: number, field: "key" | "value", text: string) => onChange({ ...value, filters: filters.map((f, i) => (i === index ? { ...f, [field]: text } : f)) }); const changeSource = (source: Settings["source"]) => { - const selection = { ...value, source, service: "", filters: [], execution_ids: [] }; + const selection = { ...value, source, service: "", agent_name: "", filters: [], execution_ids: [] }; onChange(selection); }; const windowLabel = validWindow @@ -128,193 +178,201 @@ export function ActivityScope({ : "Choose a valid history window"; const previewTitle = () => { if (pending) return "Finding matching activity…"; - if (!validWindow) return "Choose a history window between 1 and 720 hours"; + if (!validWindow) return "Choose a history window between 1 hour and 365 days"; if (!valid) return "Complete your condition to preview matches"; if (!preview.data) return "Preview unavailable"; - return `${preview.data.eligible} matching ${value.source === "requests" ? "requests" : "runs"}`; + const noun = value.source === "requests" ? "request" : "run"; + return `${preview.data.eligible} matching ${noun}${preview.data.eligible === 1 ? "" : "s"}`; }; return ( -
-
- -

- {value.source === "requests" - ? "Each request is one model call, not an entire agent run." - : "An agent run contains the steps recorded under one trace ID. Separate sessions are not joined automatically."} -

- -

- { - { - requests: "The model alias configured on your LiteLLM gateway. Leave blank for all models.", - both: "Matches the application name on agent runs or the model group on requests. Leave blank to include both without a name filter.", - traces: - "The service.name recorded by your agent’s OpenTelemetry instrumentation. Leave blank for all applications.", - }[value.source ?? "traces"] - } -

-
-

- Narrow by metadata (optional) -

-

- Match a recorded tag, swarm, or environment. Every condition must match exactly. -

- {filters.map((f, index) => ( -
- edit(index, "key", e.target.value)} - /> - is - edit(index, "value", e.target.value)} - /> - - {[...new Set(attributes.filter((a) => a.key === f.key).map((a) => a.value))].sort().map((v) => ( - - -
- ))} - - {keys.map((key) => ( - - -

- Suggestions come from up to 100 recent runs. You can also type a recorded key or value. -

-
- - onChange({ ...value, lookback_hours })} - /> -

- Time window used by each scan. Activity becomes eligible two minutes after it finishes. -

-
- + {value.source !== "requests" && agents.isError && ( +

+ Could not load agents.{" "} + +

+ )} +
setAdvanced(event.currentTarget.open)} className="group"> + + Advanced filters{filters.length ? ` (${filters.length})` : ""} + +
+ {value.source !== "requests" && ( + + )} + +

+ Match any recorded metadata, such as a user ID, environment, or tag. All conditions must match. +

+ {filters.map((f, index) => ( +
+
+ edit(index, "key", e.target.value)} + /> + +
+ edit(index, "value", e.target.value)} + /> + + {[...new Set(attributes.filter((a) => a.key === f.key).map((a) => a.value))].sort().map((v) => ( + +
+ ))} + + {keys.map((key) => ( + + + +
+
+ + ) : ( + <> + onChange({ ...value, lookback_hours })} /> - - -
-

100% with no limit selects all matching activity.

- {!!value.execution_ids?.length && ( - )}
- - onChange({ - ...value, - execution_ids: checked - ? [...(value.execution_ids ?? []), runId] - : (value.execution_ids ?? []).filter((id) => id !== runId), - }) - } - selectedIds={value.execution_ids ?? []} - selectedCount={ - value.execution_ids?.length - ? Math.min( - Math.ceil((value.execution_ids.length * (value.sample_percent ?? 100)) / 100), - value.sample_size ?? Infinity, - ) - : preview.data?.selected ?? 0 - } - title={previewTitle()} - windowLabel={windowLabel} - ready={ready} - error={preview.error} - data={preview.data} - onOpen={(run) => setTrace({ id: run.trace_id, ref: run.trace_ref })} - /> + {mode === "activity" && ( + + onChange({ + ...value, + execution_ids: checked + ? [...(value.execution_ids ?? []), runId] + : (value.execution_ids ?? []).filter((id) => id !== runId), + }) + } + manualSelection={manualSelection} + selectedIds={value.execution_ids ?? []} + selectedCount={ + manualSelection + ? Math.min( + Math.ceil(((value.execution_ids?.length ?? 0) * (value.sample_percent ?? 100)) / 100), + value.sample_size ?? Infinity, + ) + : preview.data?.selected ?? 0 + } + title={previewTitle()} + windowLabel={windowLabel} + ready={ready} + error={preview.error} + data={preview.data} + onRetry={refreshPreview} + onOpen={(run) => setTrace({ id: run.trace_id, ref: run.trace_ref })} + /> + )} {trace && ( void; onSelect: (id: string, checked: boolean) => void; selectedIds: string[]; + manualSelection: boolean; selectedCount: number; title: string; windowLabel: string; ready: boolean; error: Error | null; data: Sample | undefined; + onRetry: () => void; onOpen: (run: Sample["executions"][number]) => void; }) { + const paginated = data?.next_offset != null || offset > 0; + const showSelection = selectedCount !== data?.eligible || paginated; return (
-

- {title} -

-

{windowLabel} · Preview only, no analysis cost

+
+

+ {title} +

+ +
+

{windowLabel} · No analysis cost

-
+
{ready && error && (

- {error.message} + {error.message}{" "} +

)} {ready && data?.eligible === 0 && (

- No matches. Try removing a condition or check that your agent records this metadata. Very recent runs need - two minutes to settle. + No matches. Try removing a condition or check that your agent records this metadata. Recent trace updates + need two minutes to settle.

)} {ready && data?.executions.map((run) => (
- onSelect(run.id, e.target.checked)} - /> + {manualSelection && ( + onSelect(run.id, e.target.checked)} + /> + )}
{run.source === "traces" && ( - )}
))}
- {ready && data && ( + {ready && data && showSelection && (

- {selectedCount} selected for analysis · Showing {offset + (data.executions.length ? 1 : 0)}– - {offset + data.executions.length} of {data.eligible} + {selectedCount} selected for analysis + {paginated && ( + <> + {" "} + · Showing {offset + (data.executions.length ? 1 : 0)}–{offset + data.executions.length} of{" "} + {data.eligible} + + )}

-
- - -
+ {paginated && ( +
+ + +
+ )}
)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.integration.test.tsx index 7a0130bd929..f3174b2518d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.integration.test.tsx @@ -12,36 +12,27 @@ describe("Lens billing key", () => { testQueryClient.clear(); vi.clearAllMocks(); }); - it("creates a normal key and only passes its ID to worker settings", async () => { - const user = userEvent.setup(); - const changed = vi.fn(); - vi.mocked(apiClient.get).mockResolvedValue({ keys: [], total_pages: 0 }); - vi.mocked(apiClient.post).mockResolvedValue({ token_id: "b".repeat(64), key: "sk-secret-not-for-settings" }); - renderWithProviders(); - await user.click(screen.getByRole("button", { name: "Create worker key" })); - expect(await screen.findByRole("combobox", { name: "Charge analysis to" })).toHaveValue("Lens: Research"); - expect(apiClient.post).toHaveBeenCalledWith("/key/generate", { - accessToken: "test", - body: { key_alias: "Lens: Research", models: [], metadata: { purpose: "lens" } }, - }); - expect(changed).toHaveBeenCalledExactlyOnceWith("b".repeat(64)); - expect(screen.queryByText("sk-secret-not-for-settings")).not.toBeInTheDocument(); - }); it("pages existing keys without dropping the selected billing key", async () => { const user = userEvent.setup(); const changed = vi.fn(); - vi.mocked(apiClient.get).mockImplementation(async (_path, options) => ({ - keys: - options?.query?.page === "2" - ? [{ token: "c".repeat(64), key_alias: "Second page" }] - : [{ token: "a".repeat(64), key_alias: "First page" }], - total_pages: 2, - })); - renderWithProviders(); + vi.mocked(apiClient.get).mockImplementation(async (path, options) => + path === "/key/info" + ? { info: { models: ["restricted-model"], max_budget: 4, budget_duration: "1d" } } + : { + keys: + options?.query?.page === "2" + ? [{ token: "c".repeat(64), key_alias: "Second page" }] + : [{ token: "a".repeat(64), key_alias: "First page" }], + total_pages: 2, + }, + ); + renderWithProviders(); await user.click(screen.getByRole("combobox", { name: "Charge analysis to" })); await user.click(await screen.findByRole("option", { name: "Load more keys" })); await user.click(await screen.findByRole("option", { name: "Second page" })); expect(changed).toHaveBeenCalledExactlyOnceWith("c".repeat(64)); + expect(await screen.findByText("restricted-model")).toBeInTheDocument(); + expect(screen.getByText("$4.00 / day")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.tsx index c26c42f5700..4033b9634b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKey.tsx @@ -1,10 +1,12 @@ "use client"; import { useState } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { z } from "zod"; import { apiClient } from "@/components/networking"; -import { Button } from "@/components/ui/button"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Input } from "@/components/ui/input"; +import { AnalysisKeyDetails } from "./AnalysisKeyDetails"; import { Combobox, ComboboxContent, @@ -22,17 +24,14 @@ export function AnalysisKey({ accessToken, value, onChange, - name, }: { accessToken: string; value: string | null; onChange: (key: string | null) => void; - name: string; }) { const [query, setQuery] = useState(""); const [selected, setSelected] = useState(value ? { token: value } : null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(""); + const queryOptions = { queryKey: ["lens-analysis-keys", accessToken, query], initialPageParam: 1, @@ -61,28 +60,6 @@ export function AnalysisKey({ const choice = keys.find((key) => key.token === value) ?? selected; const loading = keyPages.isFetching; - const create = async () => { - setCreating(true); - setError(""); - try { - const result = await apiClient.post("/key/generate", { - accessToken, - body: { - key_alias: `Lens: ${name}`, - models: [], - metadata: { purpose: "lens" }, - }, - }); - if (!result.token_id) throw new Error("The proxy did not return the new key's ID"); - const key = { token: result.token_id, key_alias: `Lens: ${name}` }; - setSelected(key); - onChange(key.token); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Could not create a key"); - } finally { - setCreating(false); - } - }; const changeKey = (key: Key | null, details: { cancel: () => void }) => { if (key?.token === "load-more") { details.cancel(); @@ -99,7 +76,7 @@ export function AnalysisKey({ return (

Charge analysis to

-
+
-
-

- Spend appears under this key in API Keys. Its permissions and limits apply. -

- {(error || keyPages.error) && ( + {choice && } + {keyPages.error && (

- {error || keyPages.error?.message} + {keyPages.error.message}

)}
); } + +export type AnalysisAccess = { model: string | null; budget: string }; + +export function AnalysisAccessFields({ + accessToken, + value, + onChange, +}: { + accessToken: string; + value: AnalysisAccess; + onChange: (value: AnalysisAccess) => void; +}) { + const models = useQuery({ + queryKey: ["lens-models", accessToken], + queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }), + }); + return ( +
+
+ + ({ label: id, value: id }))} + value={value.model} + onValueChange={(model) => onChange({ ...value, model })} + placeholder={models.isLoading ? "Loading models…" : "Select a model"} + /> +
+
+ + onChange({ ...value, budget: e.target.value })} + /> +

Shared across all investigations.

+
+ {models.error && ( +

+ {models.error.message} +

+ )} +
+ ); +} + +export async function createAnalysisKey(accessToken: string, access: AnalysisAccess): Promise { + if (!access.model || !Number.isFinite(Number(access.budget)) || Number(access.budget) <= 0) + throw new Error("Choose a model and a monthly limit greater than zero"); + const result = await apiClient.post("/key/generate", { + accessToken, + body: { + key_alias: "Lens analysis", + models: [access.model], + max_budget: Number(access.budget), + budget_duration: "1mo", + metadata: { purpose: "lens" }, + }, + }); + if (!result.token_id) throw new Error("The proxy did not return the new key's ID"); + return result.token_id; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKeyDetails.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKeyDetails.tsx new file mode 100644 index 00000000000..e94099ac6e6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/AnalysisKeyDetails.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; +import { apiClient } from "@/components/networking"; +import { Button } from "@/components/ui/button"; +import { runTime } from "./lensData"; + +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) }); + +function budgetLabel(amount: number | null, duration?: string | null): string { + if (amount === null) return "No key budget"; + const periods: Record = { + "1mo": "month", + "30d": "month", + "1d": "day", + "24h": "day", + "7d": "week", + "1h": "hour", + }; + const dollars = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(amount); + return duration ? `${dollars} / ${periods[duration] ?? duration}` : `${dollars} total`; +} + +export function useAnalysisKeyInfo(accessToken: string, keyId?: string) { + return useQuery({ + queryKey: ["lens-key-info", accessToken, keyId], + enabled: !!keyId, + queryFn: async () => + keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info, + }); +} + +export function AnalysisKeyDetails({ + accessToken, + keyId, + showName = false, +}: { + accessToken: string; + keyId: string; + showName?: boolean; +}) { + const key = useAnalysisKeyInfo(accessToken, keyId); + if (key.isLoading) return

Loading key permissions…

; + if (key.error || !key.data) + return ( +
+ Could not load key permissions + +
+ ); + const info = key.data; + return ( +
+
+ {showName && ( + <> +
Billing key
+
{info.key_alias || "Assigned virtual key"}
+ + )} +
Models
+
{info.models.length ? info.models.join(", ") : "All models"}
+
Key limit
+
{budgetLabel(info.max_budget, info.budget_duration)}
+
+ {info.status && info.status !== "active" && ( +

+ This key is {info.status}. Choose an active key. +

+ )} +
+ Other limits +
+

Requests per minute: {info.rpm_limit ?? "No key limit"}

+

Tokens per minute: {info.tpm_limit ?? "No key limit"}

+

Expires: {info.expires ? runTime(info.expires) : "No expiry"}

+

Team, organization, and model limits still apply.

+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/DurationInput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/DurationInput.tsx index 7e1227eac8b..ce37ee952cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/DurationInput.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/DurationInput.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react"; import { Input } from "@/components/ui/input"; +import { ChevronDown } from "lucide-react"; export function DurationInput({ label, @@ -47,18 +48,24 @@ export function DurationInput({ value={Number.isFinite(value) ? value / scale : ""} onChange={(event) => onChange(event.target.value === "" ? NaN : Number(event.target.value) * scale)} /> - +
+ +
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensFinding.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensFinding.tsx new file mode 100644 index 00000000000..e1f0a47330b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensFinding.tsx @@ -0,0 +1,146 @@ +import { ArrowUpRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; +import { evidenceTarget, runTime, type Finding, type Sample } from "./lensData"; + +export function LensFinding({ + finding, + sampledRuns, + readOnly, + reason, + busy, + onClose, + onReason, + onEvidence, + changeFinding, +}: { + finding?: Finding; + sampledRuns: Sample["executions"]; + readOnly: boolean; + reason: string; + busy: boolean; + onClose: () => void; + onReason: (reason: string) => void; + onEvidence: (evidence: { id: string; span: string }) => void; + changeFinding: (status: Finding["status"]) => Promise; +}) { + const evidenceGroups = finding + ? [...new Set(finding.evidence.map((e) => e.execution_id))].map((id) => ({ + id, + run: sampledRuns.find((r) => r.id === id), + quotes: finding.evidence.filter((e) => e.execution_id === id), + })) + : []; + return ( + { + if (!open) onClose(); + }} + > + + {finding && ( + <> + + {finding.title} + + {finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} ·{" "} + {finding.occurrences?.length ?? 0} linked {finding.occurrences?.length === 1 ? "run" : "runs"} + + +
+
+

What happened

+

{finding.description}

+
+ {finding.suggestion && ( +
+

What to do next

+

{finding.suggestion}

+
+ )} + {finding.limitation && ( +
+ Evidence limits +

{finding.limitation}

+
+ )} +
+

Evidence by run

+

+ Exact quotes from the recorded activity. Counterexamples are labeled separately from supporting + evidence. +

+
+ {evidenceGroups.map((group) => ( +
+ + {group.run?.name ?? evidenceTarget(group.id)?.id.slice(0, 12) ?? "Recorded run"} + + {group.quotes.length} {group.quotes.length === 1 ? "quote" : "quotes"} + {group.run ? ` · ${runTime(group.run.start_time)}` : ""} + + +
+ {group.quotes.map((e, i) => ( +
+ {e.role === "counterexample" && ( +

Counterexample

+ )} +
+ {e.quote} +
+ +
+ ))} +
+
+ ))} +
+
+ {!readOnly && ( +
+