From 505dd015cda510507684413c74171f85061d1356 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Mon, 19 Oct 2026 20:08:18 -0700 Subject: [PATCH 1/7] feat(ui): Spend Per Tag card on the Usage page's Team view With exactly one team selected on Usage -> Team Usage, a Spend Per Tag card sits under Spend Per Team and lists that team's spend per tag, highest first, from /team/daily/activity/aggregated?group_by=tag. It is not shown for zero or several teams, or in any other view. A caption notes that a request with several tags counts once per tag, so the rows can add up to more than the team total, and a failed fetch says so instead of reading as no spend. DailyActivityRequest gains groupBy, sent as group_by for team reports only. The per-tag folding lives in teamTagSpend.ts with its own unit tests. LIT-8516 --- .../EntityUsage/EntityUsage.test.tsx | 95 ++++++++++++++++++ .../components/EntityUsage/EntityUsage.tsx | 7 ++ .../EntityUsage/TeamTagSpendCard.tsx | 98 +++++++++++++++++++ .../EntityUsage/teamTagSpend.test.ts | 79 +++++++++++++++ .../components/EntityUsage/teamTagSpend.ts | 49 ++++++++++ .../components/UsagePage/dailyActivityApi.ts | 9 +- .../networking.dailyActivity.test.ts | 12 +++ .../src/components/networking.tsx | 1 + 8 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts 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 5c8b284c72f..9cfda5ec313 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 @@ -616,6 +616,101 @@ describe("EntityUsage", () => { expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument(); }); + const tagSpendFor = (tags: Record) => ({ + results: [ + { + date: "2025-01-01", + metrics: { ...emptySpendMetrics }, + breakdown: { + models: {}, + model_groups: {}, + mcp_servers: {}, + providers: {}, + api_keys: {}, + entities: Object.fromEntries( + Object.entries(tags).map(([tag, { spend, api_requests }]) => [ + tag, + { + metrics: { ...emptySpendMetrics, spend, api_requests, successful_requests: api_requests }, + metadata: {}, + api_key_breakdown: {}, + }, + ]), + ), + }, + }, + ], + metadata: { total_spend: 0, total_api_requests: 0 }, + }); + + it("shows one selected team's spend per tag from a group_by=tag request", async () => { + mockTeamDailyActivityCall.mockImplementation((request: { groupBy?: string }) => + Promise.resolve( + request.groupBy === "tag" + ? tagSpendFor({ + "project:rag-v2": { spend: 0.25, api_requests: 1 }, + "cost-center:cc-42": { spend: 0.5, api_requests: 2 }, + }) + : mockSpendData, + ), + ); + const user = userEvent.setup(); + render(); + expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Team Multi Select" })); + + const card = await screen.findByTestId("team-tag-spend-card"); + expect(within(card).getByRole("heading", { name: "Spend Per Tag" })).toBeInTheDocument(); + const rows = (await within(card).findAllByRole("row")).slice(1); + expect(rows.map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([ + "cost-center:cc-42", + "project:rag-v2", + ]); + expect(within(rows[0]).getByText("$0.5000")).toBeInTheDocument(); + expect(mockTeamDailyActivityCall).toHaveBeenCalledWith( + expect.objectContaining({ entityIds: ["team-1"], groupBy: "tag" }), + ); + }); + + it("asks for tag spend only for exactly one team in the team view", async () => { + render(); + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument(); + + cleanup(); + render( + , + ); + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument(); + + cleanup(); + render( + , + ); + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument(); + + expect(mockTeamDailyActivityCall).not.toHaveBeenCalledWith(expect.objectContaining({ groupBy: "tag" })); + }); + it("should render with organization entity type and call organization API", async () => { render(); 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 ca170dc718a..c39efdb48dd 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 @@ -53,7 +53,9 @@ import { } from "../overview/overviewData"; import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives"; import TopModelView from "./TopModelView"; +import TeamTagSpendCard from "./TeamTagSpendCard"; import TeamUserSpendCard from "./TeamUserSpendCard"; +import { singleTeamId } from "./teamTagSpend"; import { ProviderSpendBreakdown } from "./SpendByProvider"; const BRAND = "#2b3fd6"; @@ -361,6 +363,7 @@ const EntityUsage: React.FC = ({ const filterSlot = entityFilterSlots[entityType]; const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); + const tagSpendTeamId = entityType === "team" ? singleTeamId(selectedTags) : null; const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata); const showPtuHours = entityType === "team" && hasPtuHours(spendData.metadata); const userSpendTeamIds = useMemo( @@ -573,6 +576,10 @@ const EntityUsage: React.FC = ({ /> + {tagSpendTeamId !== null && ( + + )} + {entityType === "team" && ( [] = [ + { + header: "Tag", + accessorKey: "tag", + meta: QUIET_HEADER, + cell: ({ row }) => {row.original.tag}, + }, + { + header: "Spend", + accessorKey: "spend", + meta: { numeric: true, ...QUIET_HEADER }, + cell: ({ row }) => , + }, + { + header: "Requests", + accessorKey: "api_requests", + meta: { numeric: true, ...QUIET_HEADER }, + cell: ({ row }) => row.original.api_requests.toLocaleString(), + }, + { + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true, className: "text-success", ...QUIET_HEADER }, + cell: ({ row }) => row.original.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER }, + cell: ({ row }) => row.original.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "total_tokens", + meta: { numeric: true, ...QUIET_HEADER }, + cell: ({ row }) => row.original.total_tokens.toLocaleString(), + }, +]; + +const TeamTagSpendCard: React.FC = ({ accessToken, startTime, endTime, teamId }) => { + const tagSpendRequest: DailyActivityRequest = { + accessToken: accessToken as string, + startTime: startTime as Date, + endTime: endTime as Date, + entityIds: [teamId], + groupBy: "tag", + }; + const { data, loading, failed } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.team.aggregated(tagSpendRequest), + enabled: Boolean(accessToken && startTime && endTime), + deps: [accessToken, startTime, endTime, teamId], + }); + const rows = useMemo(() => teamTagSpendRows(toDailyData(data)), [data]); + + return ( + +

+ A request with several tags counts once per tag, so the rows can add up to more than the team total +

+ row.tag} + isLoading={loading} + maxBodyHeight={280} + noDataMessage={ + failed ? "Could not load tag spend. Reload the page to try again" : "No tagged spend in this range" + } + size="compact" + /> +
+ ); +}; + +export default TeamTagSpendCard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts new file mode 100644 index 00000000000..2d497162287 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; + +import { singleTeamId, teamTagSpendRows } from "./teamTagSpend"; + +const metrics = (spend: number, api_requests: number, failed_requests = 0): SpendMetrics => ({ + spend, + prompt_tokens: 10 * api_requests, + completion_tokens: 5 * api_requests, + total_tokens: 15 * api_requests, + api_requests, + successful_requests: api_requests - failed_requests, + failed_requests, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +const entity = (spend: number, api_requests: number, failed_requests = 0): MetricWithMetadata => ({ + metrics: metrics(spend, api_requests, failed_requests), + metadata: {}, + api_key_breakdown: {}, +}); + +const day = (date: string, entities: Record): DailyData => ({ + date, + metrics: metrics(0, 0), + breakdown: { models: {}, model_groups: {}, mcp_servers: {}, providers: {}, api_keys: {}, entities }, +}); + +describe("teamTagSpendRows", () => { + it("sums each tag across days and orders by spend, highest first", () => { + const rows = teamTagSpendRows([ + day("2026-10-18", { "cost-center:cc-42": entity(0.5, 2), "project:rag-v2": entity(0.25, 1) }), + day("2026-10-19", { "cost-center:cc-42": entity(0.25, 1, 1), "env:prod": entity(1, 4) }), + ]); + + expect(rows).toEqual([ + { tag: "env:prod", spend: 1, api_requests: 4, successful_requests: 4, failed_requests: 0, total_tokens: 60 }, + { + tag: "cost-center:cc-42", + spend: 0.75, + api_requests: 3, + successful_requests: 2, + failed_requests: 1, + total_tokens: 45, + }, + { + tag: "project:rag-v2", + spend: 0.25, + api_requests: 1, + successful_requests: 1, + failed_requests: 0, + total_tokens: 15, + }, + ]); + }); + + it("drops tags with no spend and breaks spend ties by tag name", () => { + const rows = teamTagSpendRows([ + day("2026-10-19", { "b-tag": entity(0.5, 1), "a-tag": entity(0.5, 1), "free-tag": entity(0, 3) }), + ]); + + expect(rows.map((row) => row.tag)).toEqual(["a-tag", "b-tag"]); + }); + + it("returns no rows when the team sent no tagged spend", () => { + expect(teamTagSpendRows([day("2026-10-19", {})])).toEqual([]); + expect(teamTagSpendRows([])).toEqual([]); + }); +}); + +describe("singleTeamId", () => { + it("is the team only when exactly one is selected", () => { + expect(singleTeamId(["team-a"])).toBe("team-a"); + expect(singleTeamId([])).toBeNull(); + expect(singleTeamId(["team-a", "team-b"])).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts new file mode 100644 index 00000000000..cecbd1da74e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts @@ -0,0 +1,49 @@ +import type { DailyData } from "@/components/UsagePage/types"; + +export interface TeamTagSpendRow { + tag: string; + spend: number; + api_requests: number; + successful_requests: number; + failed_requests: number; + total_tokens: number; +} + +const emptyRow = (tag: string): TeamTagSpendRow => ({ + tag, + spend: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + total_tokens: 0, +}); + +/** + * The Team view's per-tag breakdown, from a group_by=tag report whose + * breakdown.entities are keyed by tag: one row per tag summed across days, + * zero-spend tags dropped, highest spend first (ties by tag name). + */ +export const teamTagSpendRows = (days: readonly DailyData[]): TeamTagSpendRow[] => { + const byTag = new Map(); + for (const day of days) { + for (const [tag, { metrics }] of Object.entries(day.breakdown.entities ?? {})) { + const row = byTag.get(tag) ?? emptyRow(tag); + const summed: TeamTagSpendRow = { + tag, + spend: row.spend + metrics.spend, + api_requests: row.api_requests + metrics.api_requests, + successful_requests: row.successful_requests + metrics.successful_requests, + failed_requests: row.failed_requests + metrics.failed_requests, + total_tokens: row.total_tokens + metrics.total_tokens, + }; + byTag.set(tag, summed); + } + } + return [...byTag.values()] + .filter((row) => row.spend > 0) + .sort((a, b) => b.spend - a.spend || a.tag.localeCompare(b.tag)); +}; + +/** The card answers "what did this team spend per tag", so it needs exactly one team. */ +export const singleTeamId = (selectedTeamIds: readonly string[]): string | null => + selectedTeamIds.length === 1 ? selectedTeamIds[0] : null; diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index c3201b563b1..0cd9d0b6633 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -1,4 +1,4 @@ -import type { components } from "@/lib/http/schema"; +import type { components, operations } from "@/lib/http/schema"; import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types"; export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent"; @@ -26,8 +26,15 @@ export interface DailyActivityRequest { model?: string | null; includeCurrentUtcDay?: boolean; apiKeyLimit?: number; + /** Team reports only: "tag" buckets one team's spend by tag (needs exactly one id in entityIds). */ + groupBy?: TeamGroupBy; } +type TeamAggregatedQuery = NonNullable< + operations["get_team_daily_activity_aggregated_team_daily_activity_aggregated_get"]["parameters"]["query"] +>; +export type TeamGroupBy = NonNullable; + export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = { has_more: false, page: 1, diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts index 6821d26fb33..44f5b1d3017 100644 --- a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts +++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts @@ -123,6 +123,18 @@ describe("dailyActivityAggregatedCall", () => { expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true"); }); + it("sends group_by on team reports only, and only when set", async () => { + const mockFetch = captureFetch(); + + await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"], groupBy: "tag" })); + await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"] })); + await dailyActivityAggregatedCall("tag", req({ entityIds: ["tag-a"], groupBy: "tag" })); + + expect(requestedUrl(mockFetch, 0).searchParams.get("group_by")).toBe("tag"); + expect(requestedUrl(mockFetch, 1).searchParams.has("group_by")).toBe(false); + expect(requestedUrl(mockFetch, 2).searchParams.has("group_by")).toBe(false); + }); + it("sends api_key_limit only when provided", async () => { const mockFetch = captureFetch(); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 994c2948346..2920e6a79c3 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1379,6 +1379,7 @@ const dailyActivityQuery = ( : {}), timezone: new Date().getTimezoneOffset().toString(), include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined, + group_by: entity === "team" ? req.groupBy : undefined, ...extra, }; }; From d00b8eeee50484c7eb52f5fb3382a2d757cd53c6 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Tue, 20 Oct 2026 14:40:16 -0700 Subject: [PATCH 2/7] refactor(ui): trim comments that restated the Spend Per Tag code --- .../_components/components/EntityUsage/teamTagSpend.ts | 7 +------ .../src/components/UsagePage/dailyActivityApi.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts index cecbd1da74e..8a6ed8171cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts @@ -18,11 +18,7 @@ const emptyRow = (tag: string): TeamTagSpendRow => ({ total_tokens: 0, }); -/** - * The Team view's per-tag breakdown, from a group_by=tag report whose - * breakdown.entities are keyed by tag: one row per tag summed across days, - * zero-spend tags dropped, highest spend first (ties by tag name). - */ +/** One row per tag summed across days, zero-spend tags dropped, highest spend first (ties by tag name). */ export const teamTagSpendRows = (days: readonly DailyData[]): TeamTagSpendRow[] => { const byTag = new Map(); for (const day of days) { @@ -44,6 +40,5 @@ export const teamTagSpendRows = (days: readonly DailyData[]): TeamTagSpendRow[] .sort((a, b) => b.spend - a.spend || a.tag.localeCompare(b.tag)); }; -/** The card answers "what did this team spend per tag", so it needs exactly one team. */ export const singleTeamId = (selectedTeamIds: readonly string[]): string | null => selectedTeamIds.length === 1 ? selectedTeamIds[0] : null; diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index 0cd9d0b6633..ab1d0ebbc6d 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -26,7 +26,6 @@ export interface DailyActivityRequest { model?: string | null; includeCurrentUtcDay?: boolean; apiKeyLimit?: number; - /** Team reports only: "tag" buckets one team's spend by tag (needs exactly one id in entityIds). */ groupBy?: TeamGroupBy; } From 709b761cdda5ca744c7d07a01d5bef2754d74fe7 Mon Sep 17 00:00:00 2001 From: mrinal Date: Wed, 21 Oct 2026 16:04:11 +0000 Subject: [PATCH 3/7] feat(ui): page the Spend Per Tag card through the team tags report Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../EntityUsage/EntityUsage.test.tsx | 95 +++++++------- .../EntityUsage/TeamTagSpendCard.tsx | 118 ++++++++++++++---- .../components/EntityUsage/entityFetchFns.ts | 14 ++- .../EntityUsage/teamTagSpend.test.ts | 70 +---------- .../components/EntityUsage/teamTagSpend.ts | 42 ------- .../components/UsagePage/dailyActivityApi.ts | 20 +-- .../networking.dailyActivity.test.ts | 27 ++-- .../src/components/networking.tsx | 17 ++- 8 files changed, 210 insertions(+), 193 deletions(-) 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 9cfda5ec313..36ee54fb490 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 @@ -12,6 +12,7 @@ import type { ModelActivityData, SpendMetrics, } from "@/components/UsagePage/types"; +import type { DailyActivityTagPageResponse } from "@/components/UsagePage/dailyActivityApi"; import EntityUsage from "./EntityUsage"; import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations"; @@ -34,6 +35,37 @@ const emptySpendMetrics: SpendMetrics = { cache_creation_input_tokens: 0, }; +const tagSpendRow = ( + tag: string, + spend: number, + apiRequests: number, +): DailyActivityTagPageResponse["tags"][number] => ({ + tag, + spend, + prompt_tokens: apiRequests * 10, + completion_tokens: apiRequests * 5, + total_tokens: apiRequests * 15, + api_requests: apiRequests, + successful_requests: apiRequests, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +const tagSpendPage = (offset: number): DailyActivityTagPageResponse => ({ + tags: + offset === 0 + ? [ + tagSpendRow("project:rag-v2", 0.25, 1), + tagSpendRow("cost-center:cc-42", 0.5, 2), + ...Array.from({ length: 48 }, (_, index) => tagSpendRow(`tag-${index}`, 0.01, 1)), + ] + : Array.from({ length: 10 }, (_, index) => tagSpendRow(`next-${index}`, 0.01, 1)), + total_tags: 60, + offset, + limit: 50, +}); + const createKeyMetrics = (spend: number, metadata: KeyMetadata): KeyMetricWithMetadata => ({ metrics: { ...emptySpendMetrics, spend }, metadata, @@ -68,6 +100,7 @@ vi.mock("@/components/networking", () => ({ dailyActivityKeyPageCall: vi.fn(), dailyActivityKeySearchCall: vi.fn(), dailyActivityModelTopKeysCall: vi.fn(), + dailyActivityTagPageCall: vi.fn(), dailyActivityExportCall: vi.fn(), })); @@ -171,6 +204,7 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ describe("EntityUsage", () => { const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall); const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall); + const mockDailyActivityTagPageCall = vi.mocked(networking.dailyActivityTagPageCall); const mockTagDailyActivityCall = vi.fn(); const mockTeamDailyActivityCall = vi.fn(); const mockOrganizationDailyActivityCall = vi.fn(); @@ -482,6 +516,7 @@ describe("EntityUsage", () => { beforeEach(() => { mockDailyActivityAggregatedCall.mockReset(); mockDailyActivityKeyPageCall.mockReset(); + mockDailyActivityTagPageCall.mockReset(); const emptyKeyPage = { api_keys: [], total_api_keys: 0, @@ -489,6 +524,7 @@ describe("EntityUsage", () => { limit: 50, }; mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage); + mockDailyActivityTagPageCall.mockImplementation((request) => Promise.resolve(tagSpendPage(request.offset))); mockDailyActivityAggregatedCall.mockImplementation((entity, request) => ( entityMocks[entity] as unknown as ( @@ -616,44 +652,7 @@ describe("EntityUsage", () => { expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument(); }); - const tagSpendFor = (tags: Record) => ({ - results: [ - { - date: "2025-01-01", - metrics: { ...emptySpendMetrics }, - breakdown: { - models: {}, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: {}, - entities: Object.fromEntries( - Object.entries(tags).map(([tag, { spend, api_requests }]) => [ - tag, - { - metrics: { ...emptySpendMetrics, spend, api_requests, successful_requests: api_requests }, - metadata: {}, - api_key_breakdown: {}, - }, - ]), - ), - }, - }, - ], - metadata: { total_spend: 0, total_api_requests: 0 }, - }); - - it("shows one selected team's spend per tag from a group_by=tag request", async () => { - mockTeamDailyActivityCall.mockImplementation((request: { groupBy?: string }) => - Promise.resolve( - request.groupBy === "tag" - ? tagSpendFor({ - "project:rag-v2": { spend: 0.25, api_requests: 1 }, - "cost-center:cc-42": { spend: 0.5, api_requests: 2 }, - }) - : mockSpendData, - ), - ); + it("shows one selected team's tag page in API order and loads the next page", async () => { const user = userEvent.setup(); render(); expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument(); @@ -663,14 +662,22 @@ describe("EntityUsage", () => { const card = await screen.findByTestId("team-tag-spend-card"); expect(within(card).getByRole("heading", { name: "Spend Per Tag" })).toBeInTheDocument(); const rows = (await within(card).findAllByRole("row")).slice(1); - expect(rows.map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([ - "cost-center:cc-42", + expect(rows.slice(0, 2).map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([ "project:rag-v2", + "cost-center:cc-42", ]); - expect(within(rows[0]).getByText("$0.5000")).toBeInTheDocument(); - expect(mockTeamDailyActivityCall).toHaveBeenCalledWith( - expect.objectContaining({ entityIds: ["team-1"], groupBy: "tag" }), + expect(within(card).getByText("Showing 1-50 of 60")).toBeInTheDocument(); + expect(mockDailyActivityTagPageCall).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-1", offset: 0, limit: 50 }), ); + + await user.click(within(card).getByRole("button", { name: "Go to next page" })); + await waitFor(() => + expect(mockDailyActivityTagPageCall).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "team-1", offset: 50, limit: 50 }), + ), + ); + expect(await within(card).findByText("Showing 51-60 of 60")).toBeInTheDocument(); }); it("asks for tag spend only for exactly one team in the team view", async () => { @@ -708,7 +715,7 @@ describe("EntityUsage", () => { }); expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument(); - expect(mockTeamDailyActivityCall).not.toHaveBeenCalledWith(expect.objectContaining({ groupBy: "tag" })); + expect(mockDailyActivityTagPageCall).not.toHaveBeenCalled(); }); it("should render with organization entity type and call organization API", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx index 1c5e1589722..44c2760c0f5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx @@ -1,16 +1,17 @@ import type { ColumnDef } from "@tanstack/react-table"; import { Tags } from "lucide-react"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; -import { DataTable } from "@/components/shared/DataTable"; +import { DataTable, DataTablePagination } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { toDailyData, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi"; +import type { + DailyActivityTagPageResponse, + DailyActivityTagSpendRow, +} from "@/components/UsagePage/dailyActivityApi"; -import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity"; import { Panel } from "../overview/Primitives"; import { ENTITY_API } from "./entityFetchFns"; -import { teamTagSpendRows, type TeamTagSpendRow } from "./teamTagSpend"; interface TeamTagSpendCardProps { accessToken: string | null; @@ -19,9 +20,26 @@ interface TeamTagSpendCardProps { teamId: string; } -const QUIET_HEADER = { headerClassName: "font-normal" }; +interface TagPageState { + scope: object; + page: number; + response: DailyActivityTagPageResponse | null; + failed: boolean; +} -const columns: ColumnDef[] = [ +interface TeamTagSpendPage { + page: number; + loading: boolean; + failed: boolean; + rows: DailyActivityTagSpendRow[]; + totalTags: number; + onPageChange: (nextPage: number) => void; +} + +const QUIET_HEADER = { headerClassName: "font-normal" }; +const PAGE_SIZE = 50; + +const columns: ColumnDef[] = [ { header: "Tag", accessorKey: "tag", @@ -60,21 +78,68 @@ const columns: ColumnDef[] = [ }, ]; -const TeamTagSpendCard: React.FC = ({ accessToken, startTime, endTime, teamId }) => { - const tagSpendRequest: DailyActivityRequest = { - accessToken: accessToken as string, - startTime: startTime as Date, - endTime: endTime as Date, - entityIds: [teamId], - groupBy: "tag", - }; - const { data, loading, failed } = useAggregatedDailyActivity({ - fetch: () => ENTITY_API.team.aggregated(tagSpendRequest), - enabled: Boolean(accessToken && startTime && endTime), - deps: [accessToken, startTime, endTime, teamId], - }); - const rows = useMemo(() => teamTagSpendRows(toDailyData(data)), [data]); +const useTeamTagSpendPage = ({ + accessToken, + startTime, + endTime, + teamId, +}: TeamTagSpendCardProps): TeamTagSpendPage => { + const startTimestamp = startTime?.getTime(); + const endTimestamp = endTime?.getTime(); + const requestScope = useMemo( + () => ({ hasAccessToken: accessToken !== null, endTimestamp, startTimestamp, teamId }), + [accessToken, endTimestamp, startTimestamp, teamId], + ); + const [pageState, setPageState] = useState<{ scope: object; page: number }>({ scope: {}, page: 0 }); + const [tagPageState, setTagPageState] = useState(null); + const page = pageState.scope === requestScope ? pageState.page : 0; + const currentScopeState = tagPageState?.scope === requestScope ? tagPageState : null; + const currentPageState = currentScopeState?.page === page ? currentScopeState : null; + const enabled = Boolean(accessToken && startTimestamp !== undefined && endTimestamp !== undefined); + useEffect(() => { + if (!accessToken || startTimestamp === undefined || endTimestamp === undefined) return; + + const controller = new AbortController(); + const tagPageRequest = { + accessToken, + startTime: new Date(startTimestamp), + endTime: new Date(endTimestamp), + teamId, + offset: page * PAGE_SIZE, + limit: PAGE_SIZE, + signal: controller.signal, + }; + void ENTITY_API.team.tagPage(tagPageRequest) + .then((response) => { + if (controller.signal.aborted) return; + const nextPageState: TagPageState = { scope: requestScope, page, response, failed: false }; + setTagPageState(nextPageState); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + console.error("Team tag spend page request failed:", error); + setTagPageState((current) => ({ + scope: requestScope, + page, + response: current?.scope === requestScope ? current.response : null, + failed: true, + })); + }); + return () => controller.abort(); + }, [accessToken, endTimestamp, page, requestScope, startTimestamp, teamId]); + + const loading = enabled && currentPageState === null; + const failed = currentPageState?.failed ?? false; + const rows = failed ? [] : (currentPageState?.response?.tags ?? []); + const totalTags = currentScopeState?.response?.total_tags ?? 0; + const onPageChange = (nextPage: number) => setPageState({ scope: requestScope, page: nextPage }); + + return { page, loading, failed, rows, totalTags, onPageChange }; +}; + +const TeamTagSpendCard: React.FC = (props) => { + const { page, loading, failed, rows, totalTags, onPageChange } = useTeamTagSpendPage(props); return (

@@ -91,6 +156,17 @@ const TeamTagSpendCard: React.FC = ({ accessToken, startT } size="compact" /> + {totalTags > 0 && ( + undefined} + pageSizeOptions={[PAGE_SIZE]} + isLoading={loading} + /> + )} ); }; 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 index 6dc80ef4591..4cd5b89ae36 100644 --- 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 @@ -4,6 +4,7 @@ import { dailyActivityKeyPageCall, dailyActivityKeySearchCall, dailyActivityModelTopKeysCall, + dailyActivityTagPageCall, } from "@/components/networking"; import type { EntityType } from "@/components/EntityUsageExport/types"; import type { @@ -12,6 +13,8 @@ import type { DailyActivityKeySearchResponse, DailyActivityKeyPageResponse, DailyActivityRequest, + DailyActivityTagPageRequest, + DailyActivityTagPageResponse, ExportFormat, ExportType, ModelTopKeysResponse, @@ -30,6 +33,10 @@ export interface EntityApi { exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise; } +export interface TeamEntityApi extends EntityApi { + tagPage(req: DailyActivityTagPageRequest): Promise; +} + const entityApi = ( entity: DailyActivityEntity, defaults?: Pick, @@ -42,9 +49,12 @@ const entityApi = ( exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format), }); -export const ENTITY_API: Record = { +export const ENTITY_API: Record & { team: TeamEntityApi } = { tag: entityApi("tag"), - team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }), + team: { + ...entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }), + tagPage: dailyActivityTagPageCall, + }, organization: entityApi("organization"), customer: entityApi("customer"), agent: entityApi("agent"), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts index 2d497162287..329845b0f08 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts @@ -1,74 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; - -import { singleTeamId, teamTagSpendRows } from "./teamTagSpend"; - -const metrics = (spend: number, api_requests: number, failed_requests = 0): SpendMetrics => ({ - spend, - prompt_tokens: 10 * api_requests, - completion_tokens: 5 * api_requests, - total_tokens: 15 * api_requests, - api_requests, - successful_requests: api_requests - failed_requests, - failed_requests, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, -}); - -const entity = (spend: number, api_requests: number, failed_requests = 0): MetricWithMetadata => ({ - metrics: metrics(spend, api_requests, failed_requests), - metadata: {}, - api_key_breakdown: {}, -}); - -const day = (date: string, entities: Record): DailyData => ({ - date, - metrics: metrics(0, 0), - breakdown: { models: {}, model_groups: {}, mcp_servers: {}, providers: {}, api_keys: {}, entities }, -}); - -describe("teamTagSpendRows", () => { - it("sums each tag across days and orders by spend, highest first", () => { - const rows = teamTagSpendRows([ - day("2026-10-18", { "cost-center:cc-42": entity(0.5, 2), "project:rag-v2": entity(0.25, 1) }), - day("2026-10-19", { "cost-center:cc-42": entity(0.25, 1, 1), "env:prod": entity(1, 4) }), - ]); - - expect(rows).toEqual([ - { tag: "env:prod", spend: 1, api_requests: 4, successful_requests: 4, failed_requests: 0, total_tokens: 60 }, - { - tag: "cost-center:cc-42", - spend: 0.75, - api_requests: 3, - successful_requests: 2, - failed_requests: 1, - total_tokens: 45, - }, - { - tag: "project:rag-v2", - spend: 0.25, - api_requests: 1, - successful_requests: 1, - failed_requests: 0, - total_tokens: 15, - }, - ]); - }); - - it("drops tags with no spend and breaks spend ties by tag name", () => { - const rows = teamTagSpendRows([ - day("2026-10-19", { "b-tag": entity(0.5, 1), "a-tag": entity(0.5, 1), "free-tag": entity(0, 3) }), - ]); - - expect(rows.map((row) => row.tag)).toEqual(["a-tag", "b-tag"]); - }); - - it("returns no rows when the team sent no tagged spend", () => { - expect(teamTagSpendRows([day("2026-10-19", {})])).toEqual([]); - expect(teamTagSpendRows([])).toEqual([]); - }); -}); +import { singleTeamId } from "./teamTagSpend"; describe("singleTeamId", () => { it("is the team only when exactly one is selected", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts index 8a6ed8171cc..c160bf77d0a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts @@ -1,44 +1,2 @@ -import type { DailyData } from "@/components/UsagePage/types"; - -export interface TeamTagSpendRow { - tag: string; - spend: number; - api_requests: number; - successful_requests: number; - failed_requests: number; - total_tokens: number; -} - -const emptyRow = (tag: string): TeamTagSpendRow => ({ - tag, - spend: 0, - api_requests: 0, - successful_requests: 0, - failed_requests: 0, - total_tokens: 0, -}); - -/** One row per tag summed across days, zero-spend tags dropped, highest spend first (ties by tag name). */ -export const teamTagSpendRows = (days: readonly DailyData[]): TeamTagSpendRow[] => { - const byTag = new Map(); - for (const day of days) { - for (const [tag, { metrics }] of Object.entries(day.breakdown.entities ?? {})) { - const row = byTag.get(tag) ?? emptyRow(tag); - const summed: TeamTagSpendRow = { - tag, - spend: row.spend + metrics.spend, - api_requests: row.api_requests + metrics.api_requests, - successful_requests: row.successful_requests + metrics.successful_requests, - failed_requests: row.failed_requests + metrics.failed_requests, - total_tokens: row.total_tokens + metrics.total_tokens, - }; - byTag.set(tag, summed); - } - } - return [...byTag.values()] - .filter((row) => row.spend > 0) - .sort((a, b) => b.spend - a.spend || a.tag.localeCompare(b.tag)); -}; - export const singleTeamId = (selectedTeamIds: readonly string[]): string | null => selectedTeamIds.length === 1 ? selectedTeamIds[0] : null; diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index ab1d0ebbc6d..53fe474c88a 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -1,4 +1,4 @@ -import type { components, operations } from "@/lib/http/schema"; +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"; @@ -11,11 +11,23 @@ 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 DailyActivityTagPageResponse = components["schemas"]["DailyActivityTagPageResponse"]; +export type DailyActivityTagSpendRow = components["schemas"]["TagSpendRow"]; export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"]; export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"]; export type UserActivityRow = components["schemas"]["UserActivityRow"]; export type DailyActivityUserPageResponse = components["schemas"]["DailyActivityUserPageResponse"]; +export interface DailyActivityTagPageRequest { + accessToken: string; + startTime: Date; + endTime: Date; + teamId: string; + offset: number; + limit: number; + signal?: AbortSignal; +} + export interface DailyActivityRequest { accessToken: string; startTime: Date; @@ -26,14 +38,8 @@ export interface DailyActivityRequest { model?: string | null; includeCurrentUtcDay?: boolean; apiKeyLimit?: number; - groupBy?: TeamGroupBy; } -type TeamAggregatedQuery = NonNullable< - operations["get_team_daily_activity_aggregated_team_daily_activity_aggregated_get"]["parameters"]["query"] ->; -export type TeamGroupBy = NonNullable; - export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = { has_more: false, page: 1, diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts index 44f5b1d3017..2461e3caa03 100644 --- a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts +++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts @@ -6,6 +6,7 @@ import { dailyActivityExportCall, dailyActivityKeySearchCall, dailyActivityModelTopKeysCall, + dailyActivityTagPageCall, } from "./networking"; import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi"; @@ -62,6 +63,7 @@ describe("dailyActivityAggregatedCall", () => { expect(url.searchParams.has("timezone")).toBe(true); expect(url.searchParams.has("page")).toBe(false); expect(url.searchParams.has("page_size")).toBe(false); + expect(url.searchParams.has("group_by")).toBe(false); }); it("omits the entity filter when no ids are given", async () => { @@ -123,16 +125,27 @@ describe("dailyActivityAggregatedCall", () => { expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true"); }); - it("sends group_by on team reports only, and only when set", async () => { + it("GETs the team tags page with team, offset, and limit", async () => { const mockFetch = captureFetch(); - await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"], groupBy: "tag" })); - await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"] })); - await dailyActivityAggregatedCall("tag", req({ entityIds: ["tag-a"], groupBy: "tag" })); + const request = { + accessToken: "sk-key", + startTime: start, + endTime: end, + teamId: "team-a", + offset: 50, + limit: 50, + }; + await dailyActivityTagPageCall(request); - expect(requestedUrl(mockFetch, 0).searchParams.get("group_by")).toBe("tag"); - expect(requestedUrl(mockFetch, 1).searchParams.has("group_by")).toBe(false); - expect(requestedUrl(mockFetch, 2).searchParams.has("group_by")).toBe(false); + const url = requestedUrl(mockFetch); + expect(url.pathname).toBe("/team/daily/activity/aggregated/tags"); + expect(url.searchParams.get("team_ids")).toBe("team-a"); + expect(url.searchParams.get("start_date")).toBe("2025-01-05"); + expect(url.searchParams.get("end_date")).toBe("2025-01-31"); + expect(url.searchParams.get("offset")).toBe("50"); + expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.has("group_by")).toBe(false); }); it("sends api_key_limit only when provided", async () => { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 2920e6a79c3..2f500cb8902 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -141,6 +141,8 @@ import type { DailyActivityKeyPageResponse, DailyActivityKeySearchResponse, DailyActivityRequest, + DailyActivityTagPageRequest, + DailyActivityTagPageResponse, DailyActivityUserPageResponse, ExportFormat, ExportType, @@ -1379,7 +1381,6 @@ const dailyActivityQuery = ( : {}), timezone: new Date().getTimezoneOffset().toString(), include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined, - group_by: entity === "team" ? req.groupBy : undefined, ...extra, }; }; @@ -1432,6 +1433,20 @@ export const dailyActivityKeyPageCall = ( query: dailyActivityQuery(entity, req, { offset, limit }), }); +export const dailyActivityTagPageCall = (req: DailyActivityTagPageRequest): Promise => { + const dailyActivityRequest: DailyActivityRequest = { + accessToken: req.accessToken, + startTime: req.startTime, + endTime: req.endTime, + entityIds: [req.teamId], + }; + return apiClient.get(`/team/daily/activity/aggregated/tags`, { + accessToken: req.accessToken, + query: dailyActivityQuery("team", dailyActivityRequest, { offset: req.offset, limit: req.limit }), + ...(req.signal ? { signal: req.signal } : {}), + }); +}; + export const dailyActivityKeySearchCall = ( entity: DailyActivityEntity, req: DailyActivityRequest, From 1e3d98046a873e7de508de30f7c3b5c30e225139 Mon Sep 17 00:00:00 2001 From: mrinal Date: Wed, 21 Oct 2026 16:27:33 +0000 Subject: [PATCH 4/7] fix(ui): update tag spend formatting and mock Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/EntityUsage/TeamTagSpendCard.tsx | 17 +++++------------ .../components/UsagePageView.test.tsx | 1 + 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx index 44c2760c0f5..12f10da4d2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx @@ -4,10 +4,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { DataTable, DataTablePagination } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import type { - DailyActivityTagPageResponse, - DailyActivityTagSpendRow, -} from "@/components/UsagePage/dailyActivityApi"; +import type { DailyActivityTagPageResponse, DailyActivityTagSpendRow } from "@/components/UsagePage/dailyActivityApi"; import { Panel } from "../overview/Primitives"; @@ -78,12 +75,7 @@ const columns: ColumnDef[] = [ }, ]; -const useTeamTagSpendPage = ({ - accessToken, - startTime, - endTime, - teamId, -}: TeamTagSpendCardProps): TeamTagSpendPage => { +const useTeamTagSpendPage = ({ accessToken, startTime, endTime, teamId }: TeamTagSpendCardProps): TeamTagSpendPage => { const startTimestamp = startTime?.getTime(); const endTimestamp = endTime?.getTime(); const requestScope = useMemo( @@ -110,7 +102,8 @@ const useTeamTagSpendPage = ({ limit: PAGE_SIZE, signal: controller.signal, }; - void ENTITY_API.team.tagPage(tagPageRequest) + void ENTITY_API.team + .tagPage(tagPageRequest) .then((response) => { if (controller.signal.aborted) return; const nextPageState: TagPageState = { scope: requestScope, page, response, failed: false }; @@ -131,7 +124,7 @@ const useTeamTagSpendPage = ({ const loading = enabled && currentPageState === null; const failed = currentPageState?.failed ?? false; - const rows = failed ? [] : (currentPageState?.response?.tags ?? []); + const rows = failed ? [] : currentPageState?.response?.tags ?? []; const totalTags = currentScopeState?.response?.total_tags ?? 0; const onPageChange = (nextPage: number) => setPageState({ scope: requestScope, page: nextPage }); 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 f7ffd6d52cf..a1c77483eb9 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 @@ -81,6 +81,7 @@ vi.mock("@/components/networking", () => ({ subscriptionAccountDeleteCall: vi.fn(), dailyActivityAggregatedCall: vi.fn(), dailyActivityKeyPageCall: vi.fn(), + dailyActivityTagPageCall: vi.fn(), userDailyActivityUserPageCall: vi.fn(), dailyActivityKeySearchCall: vi.fn(), dailyActivityModelTopKeysCall: vi.fn(), From 266a115baaff12ceedb4ee8b5c786cebf7541121 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Sat, 24 Oct 2026 20:38:12 -0500 Subject: [PATCH 5/7] refactor(ui): inline the one-team check for the Spend Per Tag card singleTeamId had one caller and its unit test repeated what EntityUsage.test.tsx already checks through the page, so the module and its test are removed. --- .../components/EntityUsage/EntityUsage.tsx | 3 +-- .../components/EntityUsage/teamTagSpend.test.ts | 11 ----------- .../components/EntityUsage/teamTagSpend.ts | 2 -- 3 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts 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 c39efdb48dd..b647883e611 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 @@ -55,7 +55,6 @@ import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives"; import TopModelView from "./TopModelView"; import TeamTagSpendCard from "./TeamTagSpendCard"; import TeamUserSpendCard from "./TeamUserSpendCard"; -import { singleTeamId } from "./teamTagSpend"; import { ProviderSpendBreakdown } from "./SpendByProvider"; const BRAND = "#2b3fd6"; @@ -363,7 +362,7 @@ const EntityUsage: React.FC = ({ const filterSlot = entityFilterSlots[entityType]; const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); - const tagSpendTeamId = entityType === "team" ? singleTeamId(selectedTags) : null; + const tagSpendTeamId = entityType === "team" && selectedTags.length === 1 ? selectedTags[0] : null; const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata); const showPtuHours = entityType === "team" && hasPtuHours(spendData.metadata); const userSpendTeamIds = useMemo( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts deleted file mode 100644 index 329845b0f08..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { singleTeamId } from "./teamTagSpend"; - -describe("singleTeamId", () => { - it("is the team only when exactly one is selected", () => { - expect(singleTeamId(["team-a"])).toBe("team-a"); - expect(singleTeamId([])).toBeNull(); - expect(singleTeamId(["team-a", "team-b"])).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts deleted file mode 100644 index c160bf77d0a..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/teamTagSpend.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const singleTeamId = (selectedTeamIds: readonly string[]): string | null => - selectedTeamIds.length === 1 ? selectedTeamIds[0] : null; From 11defcff3d99932de13bd36f75ace3e21ece476f Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Sat, 24 Oct 2026 21:17:55 -0500 Subject: [PATCH 6/7] fix(ui): make rows per page work on the Spend Per Tag card The card showed a rows-per-page selector with one option and ignored changes. It now offers 25, 50 and 100 (the tags route's maximum), refetches the first page at the chosen size, and keeps the size when the team or date range changes. --- .../EntityUsage/EntityUsage.test.tsx | 17 ++++++++ .../EntityUsage/TeamTagSpendCard.tsx | 43 ++++++++++++------- 2 files changed, 44 insertions(+), 16 deletions(-) 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 36ee54fb490..1d068f659e9 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 @@ -680,6 +680,23 @@ describe("EntityUsage", () => { expect(await within(card).findByText("Showing 51-60 of 60")).toBeInTheDocument(); }); + it("refetches the first page at the chosen size when rows per page changes", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Team Multi Select" })); + const card = await screen.findByTestId("team-tag-spend-card"); + await user.click(within(card).getByRole("button", { name: "Go to next page" })); + expect(await within(card).findByText("Showing 51-60 of 60")).toBeInTheDocument(); + + await user.click(within(card).getByTestId("pagination-page-size")); + await user.click(await screen.findByRole("option", { name: "100" })); + + expect(await within(card).findByText("Showing 1-60 of 60")).toBeInTheDocument(); + expect(mockDailyActivityTagPageCall).toHaveBeenLastCalledWith( + expect.objectContaining({ teamId: "team-1", offset: 0, limit: 100 }), + ); + }); + it("asks for tag spend only for exactly one team in the team view", async () => { render(); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx index 12f10da4d2a..06bf6ec0170 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx @@ -2,7 +2,7 @@ import type { ColumnDef } from "@tanstack/react-table"; import { Tags } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; -import { DataTable, DataTablePagination } from "@/components/shared/DataTable"; +import { DataTable, DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import type { DailyActivityTagPageResponse, DailyActivityTagSpendRow } from "@/components/UsagePage/dailyActivityApi"; @@ -19,22 +19,24 @@ interface TeamTagSpendCardProps { interface TagPageState { scope: object; - page: number; + pageKey: string; response: DailyActivityTagPageResponse | null; failed: boolean; } interface TeamTagSpendPage { page: number; + pageSize: number; loading: boolean; failed: boolean; rows: DailyActivityTagSpendRow[]; totalTags: number; onPageChange: (nextPage: number) => void; + onPageSizeChange: (nextPageSize: number) => void; } const QUIET_HEADER = { headerClassName: "font-normal" }; -const PAGE_SIZE = 50; +const DEFAULT_PAGE_SIZE = 50; const columns: ColumnDef[] = [ { @@ -82,11 +84,17 @@ const useTeamTagSpendPage = ({ accessToken, startTime, endTime, teamId }: TeamTa () => ({ hasAccessToken: accessToken !== null, endTimestamp, startTimestamp, teamId }), [accessToken, endTimestamp, startTimestamp, teamId], ); - const [pageState, setPageState] = useState<{ scope: object; page: number }>({ scope: {}, page: 0 }); + const [pageState, setPageState] = useState<{ scope: object; pageSize: number; page: number }>({ + scope: {}, + pageSize: DEFAULT_PAGE_SIZE, + page: 0, + }); const [tagPageState, setTagPageState] = useState(null); + const pageSize = pageState.pageSize; const page = pageState.scope === requestScope ? pageState.page : 0; + const pageKey = `${page}:${pageSize}`; const currentScopeState = tagPageState?.scope === requestScope ? tagPageState : null; - const currentPageState = currentScopeState?.page === page ? currentScopeState : null; + const currentPageState = currentScopeState?.pageKey === pageKey ? currentScopeState : null; const enabled = Boolean(accessToken && startTimestamp !== undefined && endTimestamp !== undefined); useEffect(() => { @@ -98,15 +106,15 @@ const useTeamTagSpendPage = ({ accessToken, startTime, endTime, teamId }: TeamTa startTime: new Date(startTimestamp), endTime: new Date(endTimestamp), teamId, - offset: page * PAGE_SIZE, - limit: PAGE_SIZE, + offset: page * pageSize, + limit: pageSize, signal: controller.signal, }; void ENTITY_API.team .tagPage(tagPageRequest) .then((response) => { if (controller.signal.aborted) return; - const nextPageState: TagPageState = { scope: requestScope, page, response, failed: false }; + const nextPageState: TagPageState = { scope: requestScope, pageKey, response, failed: false }; setTagPageState(nextPageState); }) .catch((error: unknown) => { @@ -114,25 +122,28 @@ const useTeamTagSpendPage = ({ accessToken, startTime, endTime, teamId }: TeamTa console.error("Team tag spend page request failed:", error); setTagPageState((current) => ({ scope: requestScope, - page, + pageKey, response: current?.scope === requestScope ? current.response : null, failed: true, })); }); return () => controller.abort(); - }, [accessToken, endTimestamp, page, requestScope, startTimestamp, teamId]); + }, [accessToken, endTimestamp, page, pageKey, pageSize, requestScope, startTimestamp, teamId]); const loading = enabled && currentPageState === null; const failed = currentPageState?.failed ?? false; const rows = failed ? [] : currentPageState?.response?.tags ?? []; const totalTags = currentScopeState?.response?.total_tags ?? 0; - const onPageChange = (nextPage: number) => setPageState({ scope: requestScope, page: nextPage }); + const onPageChange = (nextPage: number) => setPageState({ scope: requestScope, pageSize, page: nextPage }); + const onPageSizeChange = (nextPageSize: number) => + setPageState({ scope: requestScope, pageSize: nextPageSize, page: 0 }); - return { page, loading, failed, rows, totalTags, onPageChange }; + return { page, pageSize, loading, failed, rows, totalTags, onPageChange, onPageSizeChange }; }; const TeamTagSpendCard: React.FC = (props) => { - const { page, loading, failed, rows, totalTags, onPageChange } = useTeamTagSpendPage(props); + const { page, pageSize, loading, failed, rows, totalTags, onPageChange, onPageSizeChange } = + useTeamTagSpendPage(props); return (

@@ -152,11 +163,11 @@ const TeamTagSpendCard: React.FC = (props) => { {totalTags > 0 && ( undefined} - pageSizeOptions={[PAGE_SIZE]} + onPageSizeChange={onPageSizeChange} + pageSizeOptions={DEFAULT_PAGE_SIZE_OPTIONS} isLoading={loading} /> )} From 702d14fc017284d9f3a7f2810d443e141721ea41 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Thu, 29 Oct 2026 10:15:23 -0700 Subject: [PATCH 7/7] fix(ui): say the Spend Per Tag card counts only the team's virtual keys Daily tag spend now records a team only for requests made with that team's virtual keys, so the card's note says JWT and SSO requests are not included --- .../_components/components/EntityUsage/TeamTagSpendCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx index 06bf6ec0170..c4f3693df53 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TeamTagSpendCard.tsx @@ -147,7 +147,8 @@ const TeamTagSpendCard: React.FC = (props) => { return (

- A request with several tags counts once per tag, so the rows can add up to more than the team total + Counts requests made with this team's virtual keys, so JWT and SSO requests are not included. A request + with several tags counts once per tag, so the rows can add up to more than the team total