diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTagSummary.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTagSummary.ts index d20187d1287..9ee14469374 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTagSummary.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTagSummary.ts @@ -8,7 +8,7 @@ const isoDay = (date: Date) => { }; /** Per-tag spend, tokens and requests for a date range, including the `User-Agent:` tags agents are read from. */ -export const useTagSummary = (startTime: Date | null, endTime: Date | null) => { +export const useTagSummary = (startTime: Date | null, endTime: Date | null, enabled = true) => { const { accessToken } = useAuthorized(); return $api.useQuery( "get", @@ -22,7 +22,7 @@ export const useTagSummary = (startTime: Date | null, endTime: Date | null) => { }, }, { - enabled: Boolean(accessToken && startTime && endTime), + enabled: enabled && Boolean(accessToken && startTime && endTime), select: (data) => data.results, }, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx index ee2a50b5505..cd35034fe1f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx @@ -59,7 +59,6 @@ export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProp return keys; }, [chartData]); - // Same palette as the Overview / Model Leaderboard stacked chart const colors = useMemo(() => categories.map((_, i) => stackedUsageColor(i)), [categories]); return ( 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 165fdfef703..ab1c678869a 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 @@ -36,6 +36,7 @@ import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } fro import { bucketSeries, bucketTotals, + labelForDate, dailyTotals, formatMetricValue, type Granularity, @@ -66,15 +67,16 @@ const SCALE_OPTIONS = [ { value: "linear", label: "Linear" }, { value: "log", label: "Log" }, ] as const satisfies readonly { value: StackedUsageScale; label: string }[]; -/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */ const QUIET_HEADER = { headerClassName: "font-normal" }; +const FLAT_COST_KEY = "flat_cost"; /** Stacks reserved-capacity flat cost on top of the per-model spend, so each bar is the day's full cost. */ const withFlatCost = (series: Series, results: readonly DailyData[]): Series => { const flatByDate = new Map(results.map((day) => [day.date, day.metrics.flat_cost ?? 0])); return { - data: series.data.map((day) => ({ ...day, [FLAT_COST_SERIES]: flatByDate.get(day.date) ?? 0 })), - keys: [...series.keys, FLAT_COST_SERIES], + data: series.data.map((day) => ({ ...day, [FLAT_COST_KEY]: flatByDate.get(day.date) ?? 0 })), + keys: [...series.keys, FLAT_COST_KEY], + labels: [...series.labels, FLAT_COST_SERIES], colors: [...series.colors, FLAT_COST_COLOR], }; }; @@ -418,7 +420,6 @@ const EntityUsage: React.FC = ({ const trend = trendKey ? ( ) : undefined; - // The lead tile spans the row on narrow screens so five tiles never leave an empty cell. const span = index === 0 ? "col-span-2 lg:col-span-1" : undefined; if (!tile.expandable) { return ( @@ -499,11 +500,13 @@ const EntityUsage: React.FC = ({ labelForDate(chartSeries, date)} scale={scale} format={chartFormat} - totalFor={(label) => chartTotals.get(label)} + totalFor={(date) => chartTotals.get(date)} className="h-[320px]" /> @@ -657,7 +660,6 @@ const EntityUsage: React.FC = ({ )} - {/* A compact toolbar row: the header's own bottom margin and stacked label give way to the grid gap. */}
[] = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx index 8b1ac2cea28..cd488cbd4ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamUserSpendCard.tsx @@ -28,7 +28,6 @@ interface TeamUserSpendCardProps { teamIds: string[]; } -/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */ const QUIET_HEADER = { headerClassName: "font-normal" }; const columns: ColumnDef[] = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx index 87e9d352b3c..0d9544f17c0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx @@ -31,9 +31,7 @@ const VIEW_OPTIONS = [ { value: "chart", label: "Chart View" }, ] as const satisfies readonly { value: ViewMode; label: string }[]; -/** Brand blue, matching the single-series charts on the usage overview. */ const BAR_COLOR = "#2b3fd6"; -/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */ const QUIET_HEADER = { headerClassName: "font-normal" }; function ModelName({ name }: { name: string | undefined }) { 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 68e4a3aada6..e69f6f0afd8 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 @@ -188,16 +188,17 @@ const UsagePage: React.FC = ({ teams, organizations }) => { enabled: dailyActivityRequest !== null, deps: [accessToken, startTime, endTime, effectiveUserId], }); - // Per-day tag activity for the Top agents chart; each day's User-Agent tags are folded into agents. - // Not user-scoped: tags are deployment-wide, the same as the User Agent Activity view. + // Tag data is deployment-wide with no per-user dimension, so Top agents only appears where the + // rest of the page is deployment-wide too: an admin's global view with no user selected. + const showTopAgents = isAdmin && usageView === "global" && effectiveUserId === null; const tagDailyRequest = useMemo( () => (accessToken && startTime && endTime ? { accessToken, startTime, endTime, entityIds: null } : null), [accessToken, startTime, endTime], ); const { data: tagDailyRaw, loading: tagDailyLoading } = useAggregatedDailyActivity({ fetch: () => ENTITY_API.tag.aggregated(tagDailyRequest as DailyActivityRequest), - enabled: tagDailyRequest !== null && canViewTagUsage, - deps: [accessToken, startTime, endTime, canViewTagUsage], + enabled: tagDailyRequest !== null && showTopAgents, + deps: [accessToken, startTime, endTime, showTopAgents], }); const tagDaily = useMemo(() => toDailyData(tagDailyRaw), [tagDailyRaw]); const [agentActivityTags, setAgentActivityTags] = useState(undefined); @@ -257,7 +258,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { () => rollUpBreakdown(userSpendData.results, "providers").map(({ key, ...row }) => ({ provider: key, ...row })), [userSpendData.results], ); - const { data: tagSummary, isLoading: tagSummaryLoading } = useTagSummary(startTime, endTime); + const { data: tagSummary, isLoading: tagSummaryLoading } = useTagSummary(startTime, endTime, showTopAgents); // Calculate top API keys from the breakdown data const topKeys = useMemo( @@ -414,13 +415,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { ) : null } topAgents={ - canViewTagUsage ? ( + showTopAgents ? ( ) : null } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/BreakdownChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/BreakdownChart.tsx index c7b924590a4..f10ea08256b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/BreakdownChart.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/BreakdownChart.tsx @@ -123,8 +123,8 @@ export function Leaderboard({ columns?: 1 | 2; dense?: boolean; }) { - const colorOf = (key: string) => { - const index = series.keys.indexOf(key); + const colorOf = (name: string) => { + const index = series.labels.indexOf(name); return index === -1 ? OTHER_COLOR : series.colors[index]; }; const value = (row: LeaderRow) => rankValue(row, metric); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.test.tsx new file mode 100644 index 00000000000..867fd954b3a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React, { useState } from "react"; +import { describe, expect, it } from "vitest"; +import { Segmented } from "./Primitives"; + +const OPTIONS = [ + { value: "spend", label: "Spend" }, + { value: "tokens", label: "Tokens" }, + { value: "requests", label: "Requests" }, +] as const; + +function Harness() { + const [value, setValue] = useState<(typeof OPTIONS)[number]["value"]>("spend"); + return ; +} + +describe("Segmented", () => { + it("is a radio group with one tab stop that moves selection with the arrow keys", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("radiogroup", { name: "Metric" })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Spend" })).toBeChecked(); + + await user.tab(); + expect(screen.getByRole("radio", { name: "Spend" })).toHaveFocus(); + await user.keyboard("{ArrowRight}"); + expect(screen.getByRole("radio", { name: "Tokens" })).toBeChecked(); + expect(screen.getByRole("radio", { name: "Tokens" })).toHaveFocus(); + + // A single tab stop: tabbing again leaves the group rather than visiting each option. + await user.tab(); + expect(screen.getByRole("radio", { name: "Requests" })).not.toHaveFocus(); + }); + + it("selects an option on click", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("radio", { name: "Requests" })); + expect(screen.getByRole("radio", { name: "Requests" })).toBeChecked(); + expect(screen.getByRole("radio", { name: "Spend" })).not.toBeChecked(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.tsx index 5903a96a7d5..56fb628f348 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/Primitives.tsx @@ -1,6 +1,8 @@ "use client"; import React, { type ReactNode } from "react"; +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"; import { Area, AreaChart } from "recharts"; import type { LucideIcon } from "lucide-react"; import { ChartContainer } from "@/components/ui/chart"; @@ -10,7 +12,6 @@ import type { SeriesDay } from "./overviewData"; const EMPTY_CONFIG = {}; -/** Every overview card shares this inset so titles, numbers and chart edges line up down the page. */ export const PANEL_INSET_X = "px-5"; export function Panel({ @@ -111,25 +112,22 @@ export function Segmented({ label: string; }) { return ( -
+ onChange(next as T)} + className="inline-flex items-center rounded-lg bg-muted p-0.5" + > {options.map((option) => ( - + ))} -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx index 4d7f1022df6..be47b96f3f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/TopAgents.tsx @@ -8,6 +8,7 @@ import type { DailyData } from "@/components/UsagePage/types"; import { cn } from "@/lib/cva.config"; import { bucketTotals, + labelForDate, formatCompact, formatCompactUsd, formatMetricValue, @@ -57,13 +58,12 @@ function ClaudeSpark() { ); } -function AgentMark({ agent, size = "md" }: { agent: AgentRow; size?: "sm" | "md" }) { +function AgentGlyph({ agent }: { agent: AgentRow }) { + if (agent.id === "claude-code") return ; + if (agent.logo) return ; const monogram = MONOGRAMS[agent.id]; - let mark: React.ReactNode; - if (agent.id === "claude-code") mark = ; - else if (agent.logo) mark = ; - else if (monogram) - mark = ( + if (monogram) { + return ( ); - else mark = {agent.label.charAt(0)}; + } + return {agent.label.charAt(0)}; +} + +function AgentMark({ agent }: { agent: AgentRow }) { return ( - - {mark} + + ); } @@ -137,6 +136,12 @@ function AgentItem({ ); } +function TopAgentsBody({ loading, empty, children }: { loading: boolean; empty: boolean; children: React.ReactNode }) { + if (loading) return ; + if (empty) return

No agent traffic in this range

; + return <>{children}; +} + /** * The agents and clients driving traffic, read from the proxy's `User-Agent:` tags: a daily stacked * chart (like Top models) over a ranked list. Named agents lead the list; SDKs and scripts follow, @@ -159,43 +164,51 @@ export default function TopAgents({ onOpenAgent?: (agent: AgentRow) => void; }) { const [metric, setMetric] = useState("tokens"); - const agents = useMemo(() => { - const all = topAgents(rows ?? []); - return [...all.filter((agent) => agent.kind === "agent"), ...all.filter((agent) => agent.kind === "sdk")].slice( - 0, - ROWS, - ); - }, [rows]); + const allAgents = useMemo(() => topAgents(rows ?? []), [rows]); + const agents = useMemo( + () => + [ + ...allAgents.filter((agent) => agent.kind === "agent"), + ...allAgents.filter((agent) => agent.kind === "sdk"), + ].slice(0, ROWS), + [allAgents], + ); const series = useMemo(() => seriesBy(agentDailyData(daily), "models", metric, CHART_SERIES), [daily, metric]); const totalsByDay = useMemo(() => bucketTotals(series), [series]); const colorOf = (label: string) => { - const index = series.keys.indexOf(label); + const index = series.labels.indexOf(label); return index === -1 ? undefined : series.colors[index]; }; const totalShare = agents.reduce((sum, agent) => sum + agent.tokens, 0); - const coverage = totalTokens > 0 ? Math.min(totalShare / totalTokens, 1) : 1; + const attributedTokens = allAgents.reduce((sum, agent) => sum + agent.tokens, 0); + const coverage = totalTokens > 0 ? Math.min(attributedTokens / totalTokens, 1) : 1; const subtitle = coverage < COVERAGE_NOTE_BELOW ? `Daily usage by agent. ${Math.round(coverage * 100)}% of tokens came with a User-Agent; the rest is unattributed` : "Daily usage by agent, from each request's User-Agent"; const half = Math.ceil(agents.length / 2); - let body: React.ReactNode; - if (loading) body = ; - else if (agents.length === 0) - body =

No agent traffic in this range

; - else - body = ( - <> + return ( + } + bodyClassName="px-0 pt-4 pb-0" + > +
labelForDate(series, date)} format={(value) => formatMetricValue(value, metric)} - totalFor={(label) => totalsByDay.get(label)} + totalFor={(date) => totalsByDay.get(date)} className="h-64" />
@@ -215,19 +228,7 @@ export default function TopAgents({ ))}
- - ); - - return ( - } - bodyClassName="px-0 pt-4 pb-0" - > - {body} + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx index 7b3f3a5ef7f..b6623a8bc6c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx @@ -13,6 +13,7 @@ import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/c import { bucketSeries, bucketTotals, + labelForDate, dailyTotals, formatCompact, formatLatency, @@ -32,13 +33,9 @@ interface UsageOverviewProps { loading: boolean; requestCountsPending: boolean; budget: number | null; - /** The existing Top Virtual Keys table, rendered as-is so key drill-in keeps working. */ topKeys: ReactNode; - /** The gateway-by-endpoint chart, only present for admins with gateway counts. */ gatewayByEndpoint: ReactNode; - /** Top agents read from User-Agent tags; rendered after the models, the next thing people look for. */ topAgents: ReactNode; - /** Spend by provider with its zero/unknown filters. */ providerBreakdown: ReactNode; } @@ -139,7 +136,6 @@ export default function UsageOverview({ } bodyClassName="px-0 pt-4 pb-0" > - {/* The chart's axis gutter supplies the rest of the inset, so its plot edge lines up with the title. */}
{loading ? ( @@ -147,11 +143,13 @@ export default function UsageOverview({ labelForDate(series, date)} scale={scale} format={format} - totalFor={(label) => totalsByBucket.get(label)} + totalFor={(date) => totalsByBucket.get(date)} /> )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts index b6592f4ba2c..14ad625c79f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.test.ts @@ -132,14 +132,24 @@ describe("topAgents", () => { expect(agent.users).toBe(8); }); - it("records every raw tag an agent was built from, for the drill-in filter", () => { + it("drills in on exactly the tags it counted, so a request carrying bare and versioned tags is not counted twice", () => { const [agent] = topAgents([ row("User-Agent: claude-cli", 10, 100), row("User-Agent: claude-cli/2.1.263 (external, cli)", 10, 100), + row("User-Agent: claude-code", 2, 20), + ]); + expect([...agent.tags].sort()).toEqual(["User-Agent: claude-cli", "User-Agent: claude-code"]); + expect(agent).toMatchObject({ spend: 12, tokens: 120 }); + }); + + it("drills in on every versioned tag when a product has no bare rollup", () => { + const [agent] = topAgents([ + row("User-Agent: opencode/1.18.29 ai-sdk", 5, 50), + row("User-Agent: opencode/1.18.30 ai-sdk", 7, 70), ]); expect([...agent.tags].sort()).toEqual([ - "User-Agent: claude-cli", - "User-Agent: claude-cli/2.1.263 (external, cli)", + "User-Agent: opencode/1.18.29 ai-sdk", + "User-Agent: opencode/1.18.30 ai-sdk", ]); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts index 46936ee61c8..57f481c14be 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/agentCatalog.ts @@ -210,56 +210,66 @@ export const userAgentProduct = (tag: string): string => { const familyFor = (product: string): AgentFamily | null => FAMILIES.find((family) => family.match.test(product)) ?? null; +const isBareTag = (row: TagSummaryRow, product: string) => + row.tag.trim().replace(USER_AGENT_PREFIX, "").trim() === product; + /** - * Collapses `/tag/summary` rows into one row per agent. The endpoint reports a bare product tag - * (`claude-cli`) as the rollup of its versioned children (`claude-cli/2.1.263 ...`), so a product - * that has a bare row is counted from that row alone; summing both would double-count. Products - * with only versioned rows are summed across them. Unrecognised products keep their own name. + * The rows that count toward a product. `/tag/summary` reports a bare tag (`claude-cli`) as the + * rollup of its versioned children, and a request carries both, so a bare row alone stands for the + * product. These same rows become the agent's drill-in filter, keeping it from double-counting. + */ +const contributingRows = (product: string, rows: readonly TagSummaryRow[]): readonly TagSummaryRow[] => { + const bare = rows.find((row) => isBareTag(row, product)); + return bare ? [bare] : rows; +}; + +const agentFor = (product: string, rows: readonly TagSummaryRow[]): AgentRow => { + const family = familyFor(product); + return { + id: family?.id ?? `ua:${product.toLowerCase()}`, + label: family?.label ?? product, + description: family?.description ?? "Custom client", + kind: family?.kind ?? "sdk", + logo: family?.logo ?? null, + spend: rows.reduce((sum, row) => sum + (row.total_spend || 0), 0), + tokens: rows.reduce((sum, row) => sum + (row.total_tokens || 0), 0), + requests: rows.reduce((sum, row) => sum + (row.total_requests || 0), 0), + // Users overlap across a family's products, so the largest is the honest lower bound. + users: Math.max(0, ...rows.map((row) => row.unique_users || 0)), + tags: rows.map((row) => row.tag), + }; +}; + +const mergeAgents = (a: AgentRow, b: AgentRow): AgentRow => ({ + ...a, + spend: a.spend + b.spend, + tokens: a.tokens + b.tokens, + requests: a.requests + b.requests, + users: Math.max(a.users, b.users), + tags: [...a.tags, ...b.tags], +}); + +/** + * One row per agent from `/tag/summary`. Products are keyed case-sensitively: prod reports `python` + * and `Python` as distinct products, and folding them let one rollup overwrite the other. */ export const topAgents = (rows: readonly TagSummaryRow[]): AgentRow[] => { - const byProduct = new Map(); - for (const row of rows) { - if (!isUserAgentTag(row.tag)) continue; - // Keyed case-sensitively: prod reports `python` ($61K) and `Python` ($1) as distinct products, - // and folding them together let the small one's rollup overwrite the large one's. - const key = userAgentProduct(row.tag); - if (!key) continue; - const entry = byProduct.get(key) ?? { bare: null, versioned: [] }; - if (row.tag.trim().replace(USER_AGENT_PREFIX, "").trim() === key) entry.bare = row; - else entry.versioned.push(row); - byProduct.set(key, entry); - } - - const agents = new Map(); - for (const [key, { bare, versioned }] of byProduct) { - const source = bare ? [bare] : versioned; - const family = familyFor(key); - const id = family?.id ?? `ua:${key.toLowerCase()}`; - const row = agents.get(id) ?? { - id, - label: family?.label ?? (bare ? userAgentProduct(bare.tag) : userAgentProduct(versioned[0].tag)), - description: family?.description ?? "Custom client", - kind: family?.kind ?? "sdk", - logo: family?.logo ?? null, - spend: 0, - tokens: 0, - requests: 0, - users: 0, - tags: [], - }; - // Every tag the agent was seen under, so a drill-in filter catches all its versions. - row.tags.push(...(bare ? [bare.tag] : []), ...versioned.map((item) => item.tag)); - for (const item of source) { - row.spend += item.total_spend || 0; - row.tokens += item.total_tokens || 0; - row.requests += item.total_requests || 0; - // Users can overlap across a family's products, so take the largest rather than a sum that would overstate. - row.users = Math.max(row.users, item.unique_users || 0); - } - agents.set(id, row); - } - - return [...agents.values()] + const userAgentRows = rows.filter((row) => isUserAgentTag(row.tag) && userAgentProduct(row.tag)); + const products = [...new Set(userAgentRows.map((row) => userAgentProduct(row.tag)))]; + const perProduct = products.map((product) => + agentFor( + product, + contributingRows( + product, + userAgentRows.filter((row) => userAgentProduct(row.tag) === product), + ), + ), + ); + const byAgent = perProduct.reduce>((acc, agent) => { + const existing = acc.get(agent.id); + return new Map([...acc, [agent.id, existing ? mergeAgents(existing, agent) : agent]]); + }, new Map()); + return [...byAgent.values()] .filter((agent) => agent.spend > 0 || agent.tokens > 0 || agent.requests > 0) .sort((a, b) => b.tokens - a.tokens || b.spend - a.spend); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts new file mode 100644 index 00000000000..eb4ad2379b7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { DailyData } from "@/components/UsagePage/types"; +import { bucketSeries, bucketTotals, labelForDate, rollUpBreakdown, seriesBy } from "./overviewData"; + +const metrics = (spend: number) => ({ + spend, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: spend * 10, + api_requests: 1, + successful_requests: 1, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +const day = (date: string, total: number, models: Record): DailyData => + ({ + date, + metrics: metrics(total), + breakdown: { + models: {}, + model_groups: Object.fromEntries( + Object.entries(models).map(([name, spend]) => [ + name, + { metrics: metrics(spend), metadata: {}, api_key_breakdown: {} }, + ]), + ), + mcp_servers: {}, + providers: {}, + api_keys: {}, + entities: {}, + }, + }) as unknown as DailyData; + +describe("seriesBy", () => { + it("keeps a model named date, label or Other from overwriting the day's own fields or the remainder", () => { + const series = seriesBy([day("2026-10-01", 10, { date: 3, label: 2, Other: 4 })], "model_groups", "spend"); + const [point] = series.data; + expect(point.date).toBe("2026-10-01"); + expect(point.label).toBe("Oct 1"); + const valueOf = (name: string) => point[series.keys[series.labels.indexOf(name)]]; + expect(valueOf("Other")).toBe(4); + expect(valueOf("date")).toBe(3); + expect(valueOf("label")).toBe(2); + // The real remainder (10 - 9) is a separate trailing series, not merged into the model named "Other". + expect(series.labels).toHaveLength(4); + expect(point[series.keys[3]]).toBeCloseTo(1); + }); + + it("folds everything past the top N into a trailing Other in slate", () => { + const series = seriesBy([day("2026-10-01", 10, { a: 5, b: 3, c: 2 })], "model_groups", "spend", 2); + expect(series.labels).toEqual(["a", "b", "Other"]); + expect(series.colors[2]).toBe("#94a3b8"); + }); +}); + +describe("bucketTotals", () => { + it("keys totals by ISO date, so the same day in two years does not collide", () => { + const series = seriesBy([day("2025-01-01", 5, { a: 5 }), day("2026-01-01", 9, { a: 9 })], "model_groups", "spend"); + const totals = bucketTotals(series); + expect(totals.get("2025-01-01")).toBe(5); + expect(totals.get("2026-01-01")).toBe(9); + expect(labelForDate(series, "2025-01-01")).toBe("Jan 1"); + }); +}); + +describe("bucketSeries", () => { + it("sums days into 7-day buckets anchored on the first day", () => { + const series = seriesBy( + [day("2026-10-01", 1, { a: 1 }), day("2026-10-03", 2, { a: 2 }), day("2026-10-09", 4, { a: 4 })], + "model_groups", + "spend", + ); + const weekly = bucketSeries(series, "week"); + expect(weekly.data.map((point) => point.date)).toEqual(["2026-10-01", "2026-10-08"]); + expect(bucketTotals(weekly).get("2026-10-01")).toBe(3); + expect(bucketTotals(weekly).get("2026-10-08")).toBe(4); + }); +}); + +describe("rollUpBreakdown", () => { + it("sums a dimension across days without changing the input", () => { + const days = [day("2026-10-01", 3, { a: 3 }), day("2026-10-02", 2, { a: 2 })]; + const before = JSON.stringify(days); + expect(rollUpBreakdown(days, "model_groups")).toEqual([ + expect.objectContaining({ key: "a", spend: 5, requests: 2 }), + ]); + expect(JSON.stringify(days)).toBe(before); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts index 524057b9edd..5d5386f8a3f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/overviewData.ts @@ -15,27 +15,28 @@ interface RankedRow extends Record { tokens: number; } +const EMPTY_RANKED = { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 } as const; + /** Sums one breakdown dimension across every day, highest spend first. */ export const rollUpBreakdown = (results: readonly DailyData[], dimension: BreakdownDimension): RankedRow[] => { - const totals = new Map(); - for (const day of results) { - for (const [key, entry] of Object.entries(day.breakdown[dimension] ?? {})) { - const row = totals.get(key) ?? { + const entries = results.flatMap((day) => Object.entries(day.breakdown[dimension] ?? {})); + const totals = entries.reduce>((acc, [key, entry]) => { + const row = acc.get(key) ?? { key, ...EMPTY_RANKED }; + return new Map([ + ...acc, + [ key, - spend: 0, - requests: 0, - successful_requests: 0, - failed_requests: 0, - tokens: 0, - }; - row.spend += entry.metrics.spend; - row.requests += entry.metrics.api_requests; - row.successful_requests += entry.metrics.successful_requests || 0; - row.failed_requests += entry.metrics.failed_requests || 0; - row.tokens += entry.metrics.total_tokens; - totals.set(key, row); - } - } + { + ...row, + spend: row.spend + entry.metrics.spend, + requests: row.requests + entry.metrics.api_requests, + successful_requests: row.successful_requests + (entry.metrics.successful_requests || 0), + failed_requests: row.failed_requests + (entry.metrics.failed_requests || 0), + tokens: row.tokens + entry.metrics.total_tokens, + }, + ], + ]); + }, new Map()); return [...totals.values()].sort((a, b) => b.spend - a.spend); }; @@ -43,16 +44,24 @@ export const OTHER_COLOR = "#94a3b8"; const OTHER_SERIES = "Other"; export interface SeriesDay extends Record { + /** ISO date, the chart's category key: unique across years, unlike its display label. */ date: string; label: string; } +/** + * `keys` are internal (`s0`, `s1`, ...) so a series named `date`, `label` or `Other` cannot + * collide with the day's own fields; `labels` carry the display names in the same order. + */ export interface Series { data: SeriesDay[]; keys: string[]; + labels: string[]; colors: string[]; } +const seriesKey = (index: number) => `s${index}`; + const formatDayLabel = (date: string): string => { const parsed = new Date(`${date}T00:00:00`); if (Number.isNaN(parsed.getTime())) return date; @@ -76,9 +85,6 @@ export const rankValue = (row: RankedRow, metric: UsageMetric): number => { return row.requests; }; -const seriesColor = (key: string, index: number): string => - key === OTHER_SERIES ? OTHER_COLOR : stackedUsageColor(index); - /** * Daily `metric` stacked by the top `top` keys of a dimension, the rest folded * into "Other" so every bar still sums to the day's total. @@ -94,26 +100,28 @@ export const seriesBy = ( .sort((a, b) => rankValue(b, metric) - rankValue(a, metric)) .slice(0, top) .map((row) => row.key); - const leaderSet = new Set(leaders); - let hasOther = false; - const data = sortByDate(results).map((day) => { - const point: SeriesDay = { date: day.date, label: formatDayLabel(day.date) }; - for (const key of leaders) point[key] = 0; - let other = metricOf(day.metrics, metric); - for (const [key, entry] of Object.entries(day.breakdown[dimension] ?? {})) { - if (!leaderSet.has(key)) continue; - const value = metricOf(entry.metrics, metric); - point[key] = value; - other -= value; - } + const days = sortByDate(results).map((day) => { + const values = leaders.map((leader) => { + const entry = day.breakdown[dimension]?.[leader]; + return entry ? metricOf(entry.metrics, metric) : 0; + }); + const named = values.reduce((sum, value) => sum + value, 0); // Rounding in the per-key sums leaves dust; only a real remainder earns a segment. - const remainder = other > 1e-6 ? other : 0; - point[OTHER_SERIES] = remainder; - if (remainder > 0) hasOther = true; - return point; + const remainder = metricOf(day.metrics, metric) - named; + return { day, values, other: remainder > 1e-6 ? remainder : 0 }; }); - const keys = hasOther || leaders.length === 0 ? [...leaders, OTHER_SERIES] : leaders; - return { data, keys, colors: keys.map((key, i) => seriesColor(key, i)) }; + const hasOther = days.some(({ other }) => other > 0) || leaders.length === 0; + const labels = hasOther ? [...leaders, OTHER_SERIES] : leaders; + const keys = labels.map((_, index) => seriesKey(index)); + const data = days.map(({ day, values, other }) => ({ + date: day.date, + label: formatDayLabel(day.date), + ...Object.fromEntries((hasOther ? [...values, other] : values).map((value, index) => [keys[index], value])), + })); + const colors = labels.map((label, index) => + hasOther && index === labels.length - 1 ? OTHER_COLOR : stackedUsageColor(index), + ); + return { data, keys, labels, colors }; }; export type Granularity = "day" | "week"; @@ -124,20 +132,31 @@ const DAY_MS = 86_400_000; export const bucketSeries = (series: Series, granularity: Granularity): Series => { if (granularity === "day" || series.data.length === 0) return series; const origin = Date.parse(`${series.data[0].date}T00:00:00Z`); - const buckets = new Map(); - for (const day of series.data) { - const index = Math.floor((Date.parse(`${day.date}T00:00:00Z`) - origin) / (7 * DAY_MS)); - const start = new Date(origin + index * 7 * DAY_MS).toISOString().slice(0, 10); - const bucket = buckets.get(index) ?? { date: start, label: `Wk of ${formatDayLabel(start)}` }; - for (const key of series.keys) bucket[key] = Number(bucket[key] ?? 0) + Number(day[key] ?? 0); - buckets.set(index, bucket); - } - return { ...series, data: [...buckets.values()] }; + const bucketStart = (date: string) => { + const index = Math.floor((Date.parse(`${date}T00:00:00Z`) - origin) / (7 * DAY_MS)); + return new Date(origin + index * 7 * DAY_MS).toISOString().slice(0, 10); + }; + const starts = [...new Set(series.data.map((day) => bucketStart(day.date)))]; + const data = starts.map((start) => { + const members = series.data.filter((day) => bucketStart(day.date) === start); + return { + date: start, + label: `Wk of ${formatDayLabel(start)}`, + ...Object.fromEntries( + series.keys.map((key) => [key, members.reduce((sum, day) => sum + Number(day[key] ?? 0), 0)]), + ), + }; + }); + return { ...series, data }; }; -/** Bar totals per bucket label, for the "· Total" tooltip header. */ -export const bucketTotals = (series: Series): Map => - new Map(series.data.map((day) => [day.label, series.keys.reduce((sum, key) => sum + Number(day[key] ?? 0), 0)])); +/** Bar totals keyed by ISO date, so two years' "Jan 1" in one range never overwrite each other. */ +export const bucketTotals = (series: Series): ReadonlyMap => + new Map(series.data.map((day) => [day.date, series.keys.reduce((sum, key) => sum + Number(day[key] ?? 0), 0)])); + +/** Display label for a bucket's ISO date, for chart ticks and tooltip headers. */ +export const labelForDate = (series: Series, date: string): string => + series.data.find((day) => day.date === date)?.label ?? date; export interface LeaderRow extends RankedRow { share: number; @@ -145,6 +164,12 @@ export interface LeaderRow extends RankedRow { delta: number | null; } +const shareIn = (rows: readonly RankedRow[], metric: UsageMetric) => { + const total = rows.reduce((sum, row) => sum + rankValue(row, metric), 0); + const byKey = new Map(rows.map((row) => [row.key, rankValue(row, metric)])); + return { total, of: (key: string) => (total > 0 ? ((byKey.get(key) ?? 0) / total) * 100 : 0) }; +}; + /** Share of `metric` per key, plus how that share moved between the two halves of the range. */ export const leaderboard = ( results: readonly DailyData[], @@ -155,14 +180,8 @@ export const leaderboard = ( const rows = rollUpBreakdown(days, dimension).sort((a, b) => rankValue(b, metric) - rankValue(a, metric)); const grand = rows.reduce((sum, row) => sum + rankValue(row, metric), 0); const half = Math.floor(days.length / 2); - const shareIn = (slice: readonly DailyData[]) => { - const sliceRows = rollUpBreakdown(slice, dimension); - const total = sliceRows.reduce((sum, row) => sum + rankValue(row, metric), 0); - const byKey = new Map(sliceRows.map((row) => [row.key, rankValue(row, metric)])); - return { total, of: (key: string) => (total > 0 ? ((byKey.get(key) ?? 0) / total) * 100 : 0) }; - }; - const earlier = shareIn(days.slice(0, half)); - const later = shareIn(days.slice(half)); + const earlier = shareIn(rollUpBreakdown(days.slice(0, half), dimension), metric); + const later = shareIn(rollUpBreakdown(days.slice(half), dimension), metric); const comparable = half > 0 && earlier.total > 0 && later.total > 0; return rows .filter((row) => rankValue(row, metric) > 0) diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index ca60db1ef47..90a26919e01 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -21,9 +21,7 @@ const VIEW_OPTIONS = [ { value: "chart", label: "Chart View" }, ] as const satisfies readonly { value: ViewMode; label: string }[]; -/** Brand blue, matching the single-series charts on the usage overview. */ const BAR_COLOR = "#2b3fd6"; -/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */ const QUIET_HEADER = { headerClassName: "font-normal" }; export interface TopKeyItem { diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index d5bf0c3446b..5ef9fbdbc6c 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -55,7 +55,6 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp /> ); -/** Quiet series key for a panel header: small swatch and readable name. */ const SeriesKey = ({ categories, colors }: { categories: readonly string[]; colors: readonly ChartColor[] }) => (
{categories.map((category, idx) => ( diff --git a/ui/litellm-dashboard/src/components/shared/charts/StackedUsageChart.tsx b/ui/litellm-dashboard/src/components/shared/charts/StackedUsageChart.tsx index d30d4a52a26..1dce03d23fc 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/StackedUsageChart.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/StackedUsageChart.tsx @@ -27,12 +27,16 @@ export interface StackedUsageChartProps { data: readonly Record[]; /** Series keys, bottom of the stack first. */ series: readonly string[]; + /** Display names for `series`, same order; defaults to the keys. */ + labels?: readonly string[]; /** Overrides the palette per series, e.g. to grey out an "Other" bucket. */ colors?: readonly string[]; xKey: string; + /** Turns an `xKey` value into its tick and tooltip text, e.g. an ISO date into "Oct 3". */ + xLabel?: (value: string) => string; format: (value: number) => string; - /** Appended to the tooltip header as "· Total …" for the hovered bucket. */ - totalFor?: (label: string) => number | undefined; + /** Appended to the tooltip header as "· Total …" for the hovered bucket, looked up by its `xKey` value. */ + totalFor?: (xValue: string) => number | undefined; totalLabel?: string; scale?: StackedUsageScale; className?: string; @@ -67,8 +71,10 @@ export function StackedUsageTooltipRow({ export function StackedUsageChart({ data, series, + labels, colors, xKey, + xLabel = String, format, totalFor, totalLabel = "Total", @@ -76,15 +82,27 @@ export function StackedUsageChart({ className, }: StackedUsageChartProps) { const fill = (index: number) => colors?.[index] ?? stackedUsageColor(index); + const nameOf = (index: number) => labels?.[index] ?? series[index]; const config = Object.fromEntries( - series.map((key, index) => [key, { label: key, color: fill(index) }]), + series.map((key, index) => [key, { label: nameOf(index), color: fill(index) }]), ) satisfies ChartConfig; + const tooltipHeader = (_label: unknown, payload: readonly { payload?: Record }[]) => { + const xValue = String(payload?.[0]?.payload?.[xKey] ?? ""); + const total = totalFor?.(xValue); + return total === undefined ? xLabel(xValue) : `${xLabel(xValue)} · ${totalLabel} ${format(total)}`; + }; return ( - + xLabel(String(value))} + /> { - const total = totalFor?.(String(label)); - return total === undefined ? String(label) : `${label} · ${totalLabel} ${format(total)}`; - }} - formatter={(value, name, item) => ( - + labelFormatter={tooltipHeader} + formatter={(value, _name, item) => ( + )} /> } /> {series.map((key, index) => ( - + ))} diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index 17061eb38f9..ca318bfb4fe 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -491,7 +491,6 @@ const UserAgentActivity: React.FC = ({
} > - {/* Date range is controlled by the parent page header */} {summaryLoading ? ( ) : ( @@ -519,7 +518,6 @@ const UserAgentActivity: React.FC = ({ /> ); })} - {/* Fill remaining slots if less than 4 agents */} {Array.from({ length: Math.max(0, SUMMARY_SLOTS - summaryRows.length) }).map((_, index) => (