From e7d251cd8b7df9c736fe7897e7a402bcda827295 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Mon, 19 Oct 2026 20:08:18 -0700 Subject: [PATCH 1/6] 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 cdee86ed559..912483ecbc7 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 @@ -610,6 +610,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 8518f52e165..768328ef44d 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 @@ -52,7 +52,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"; @@ -352,6 +354,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( @@ -564,6 +567,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 0f7d76a7156..df609dd43b5 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1381,6 +1381,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 958335cdf07679bd9ce65fc5f67d9d3b9e43e2fc Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Tue, 20 Oct 2026 14:40:16 -0700 Subject: [PATCH 2/6] 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 c8eddb7af6a8d4b339d4066f7900b9c75f55fcea Mon Sep 17 00:00:00 2001 From: mrinal Date: Wed, 21 Oct 2026 16:04:11 +0000 Subject: [PATCH 3/6] 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 912483ecbc7..3713dd1ea1e 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(), })); @@ -165,6 +198,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(); @@ -476,6 +510,7 @@ describe("EntityUsage", () => { beforeEach(() => { mockDailyActivityAggregatedCall.mockReset(); mockDailyActivityKeyPageCall.mockReset(); + mockDailyActivityTagPageCall.mockReset(); const emptyKeyPage = { api_keys: [], total_api_keys: 0, @@ -483,6 +518,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 ( @@ -610,44 +646,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(); @@ -657,14 +656,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 () => { @@ -702,7 +709,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 df609dd43b5..6bb6db14a05 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -144,6 +144,8 @@ import type { DailyActivityKeyPageResponse, DailyActivityKeySearchResponse, DailyActivityRequest, + DailyActivityTagPageRequest, + DailyActivityTagPageResponse, DailyActivityUserPageResponse, ExportFormat, ExportType, @@ -1381,7 +1383,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, }; }; @@ -1406,6 +1407,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 b35edcd0ab0a502ce167cab04b1621e02bf8745c Mon Sep 17 00:00:00 2001 From: mrinal Date: Wed, 21 Oct 2026 16:27:33 +0000 Subject: [PATCH 4/6] 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 c37e8e3306b..531eb513175 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 @@ -61,6 +61,7 @@ beforeAll(() => { vi.mock("@/components/networking", () => ({ dailyActivityAggregatedCall: vi.fn(), dailyActivityKeyPageCall: vi.fn(), + dailyActivityTagPageCall: vi.fn(), userDailyActivityUserPageCall: vi.fn(), dailyActivityKeySearchCall: vi.fn(), dailyActivityModelTopKeysCall: vi.fn(), From dc5a0afdd65f98e7396663861756e3b94a88cb4a Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Sat, 24 Oct 2026 20:38:12 -0500 Subject: [PATCH 5/6] 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 b6f42549a85..58bb8396bb4 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 @@ -54,7 +54,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"; @@ -358,7 +357,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 f8ebd284cc50ab5b913ee283d601076bed4a6422 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Sat, 24 Oct 2026 21:17:55 -0500 Subject: [PATCH 6/6] 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 e1afef84d48..eb6f3c45ad6 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 @@ -674,6 +674,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} /> )}