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 04b4dcf9be1..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 @@ -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,6 +646,89 @@ describe("EntityUsage", () => { expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument(); }); + 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(); + + 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.slice(0, 2).map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([ + "project:rag-v2", + "cost-center:cc-42", + ]); + 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("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(() => { + 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(mockDailyActivityTagPageCall).not.toHaveBeenCalled(); + }); + 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 a75afd63633..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 @@ -52,6 +52,7 @@ 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 { ProviderSpendBreakdown } from "./SpendByProvider"; @@ -356,6 +357,7 @@ const EntityUsage: React.FC = ({ const filterSlot = entityFilterSlots[entityType]; const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); + 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( @@ -568,6 +570,10 @@ const EntityUsage: React.FC = ({ /> + {tagSpendTeamId !== null && ( + + )} + {entityType === "team" && ( void; + onPageSizeChange: (nextPageSize: number) => void; +} + +const QUIET_HEADER = { headerClassName: "font-normal" }; +const DEFAULT_PAGE_SIZE = 50; + +const columns: ColumnDef[] = [ + { + 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 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; 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?.pageKey === pageKey ? 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 * pageSize, + limit: pageSize, + signal: controller.signal, + }; + void ENTITY_API.team + .tagPage(tagPageRequest) + .then((response) => { + if (controller.signal.aborted) return; + const nextPageState: TagPageState = { scope: requestScope, pageKey, 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, + pageKey, + response: current?.scope === requestScope ? current.response : null, + failed: true, + })); + }); + return () => controller.abort(); + }, [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, pageSize, page: nextPage }); + const onPageSizeChange = (nextPageSize: number) => + setPageState({ scope: requestScope, pageSize: nextPageSize, page: 0 }); + + return { page, pageSize, loading, failed, rows, totalTags, onPageChange, onPageSizeChange }; +}; + +const TeamTagSpendCard: React.FC = (props) => { + const { page, pageSize, loading, failed, rows, totalTags, onPageChange, onPageSizeChange } = + useTeamTagSpendPage(props); + 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" + /> + {totalTags > 0 && ( + + )} +
+ ); +}; + +export default TeamTagSpendCard; 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/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index adb6d8791a7..0ad1ecc3f86 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 @@ -77,6 +77,7 @@ beforeAll(() => { vi.mock("@/components/networking", () => ({ dailyActivityAggregatedCall: vi.fn(), dailyActivityKeyPageCall: vi.fn(), + dailyActivityTagPageCall: vi.fn(), userDailyActivityUserPageCall: vi.fn(), dailyActivityKeySearchCall: vi.fn(), dailyActivityModelTopKeysCall: vi.fn(), diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts index c3201b563b1..53fe474c88a 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -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; diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts index 6821d26fb33..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,6 +125,29 @@ describe("dailyActivityAggregatedCall", () => { expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true"); }); + it("GETs the team tags page with team, offset, and limit", async () => { + const mockFetch = captureFetch(); + + const request = { + accessToken: "sk-key", + startTime: start, + endTime: end, + teamId: "team-a", + offset: 50, + limit: 50, + }; + await dailyActivityTagPageCall(request); + + 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 () => { const mockFetch = captureFetch(); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index fd25242ec34..69256192a32 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, @@ -1405,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,