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 45ffe39157c..197b1ab0229 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 c13052be245..61a890cbeb2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1389,6 +1389,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, }; };