From beceb1bedbd9b60b5c4b76eadfa9e93e8b2a92b7 Mon Sep 17 00:00:00 2001 From: yuneng Date: Mon, 21 Sep 2026 23:23:15 +0000 Subject: [PATCH] test(ui): cover per-user team usage export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ExportTypeSelector.test.tsx | 27 + .../EntityUsageExport/utils.test.ts | 485 ++++++++++++++++++ 2 files changed, 512 insertions(+) diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx index 6c039b106bf..06e4f2d79fa 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx @@ -33,4 +33,31 @@ describe("ExportTypeSelector", () => { renderWithProviders(); expect(screen.getByRole("radio", { name: /Day-by-day by team and model/i })).toBeChecked(); }); + + it("should offer the per-user scope for teams and call onChange with daily_with_users", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders(); + + const option = screen.getByRole("radio", { name: /Day-by-day breakdown by team and user/i }); + await user.click(option); + + expect(onChange).toHaveBeenCalledWith("daily_with_users"); + expect(screen.getByText("Daily metrics for each team, split by key owner")).toBeInTheDocument(); + }); + + it("should hide the per-user scope for user exports while keeping the other scopes", () => { + renderWithProviders(); + + expect(screen.queryByRole("radio", { name: /and user/i })).toBeNull(); + expect(screen.getByRole("radio", { name: /Day-by-day breakdown by user$/i })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /Day-by-day breakdown by user and key/i })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /Day-by-day by user and model/i })).toBeInTheDocument(); + }); + + it("should offer the per-user scope for tags", () => { + renderWithProviders(); + + expect(screen.getByRole("radio", { name: /Day-by-day breakdown by tag and user/i })).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 7ed014d43b2..980d2b4d4c4 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -8,6 +8,7 @@ import { generateDailyData, generateDailyWithKeysData, generateDailyWithModelsData, + generateDailyWithUsersData, generateExportData, generateMetadata, getEntityBreakdown, @@ -151,6 +152,204 @@ describe("EntityUsageExport utils", () => { "team-2": "Team Two", }; + const usersFixture: EntitySpendData = { + results: [ + { + date: "2025-03-01", + breakdown: { + entities: { + "team-1": { + metrics: { + spend: 16.5, + api_requests: 165, + successful_requests: 156, + failed_requests: 9, + total_tokens: 1650, + prompt_tokens: 940, + completion_tokens: 710, + cache_read_input_tokens: 90, + cache_creation_input_tokens: 60, + }, + api_key_breakdown: { + kA: { + metrics: { + spend: 1.1, + api_requests: 11, + successful_requests: 10, + failed_requests: 1, + total_tokens: 110, + prompt_tokens: 60, + completion_tokens: 50, + cache_read_input_tokens: 6, + cache_creation_input_tokens: 4, + }, + metadata: { + team_id: "team-1", + key_alias: "alice-key", + user_id: "u1", + user_email: "a@x", + }, + }, + kB: { + metrics: { + spend: 2.2, + api_requests: 22, + successful_requests: 20, + failed_requests: 2, + total_tokens: 220, + prompt_tokens: 130, + completion_tokens: 90, + cache_read_input_tokens: 12, + cache_creation_input_tokens: 8, + }, + metadata: { + team_id: "team-1", + user_id: "u1", + user_email: "a@x", + }, + }, + kC: { + metrics: { + spend: 3.3, + api_requests: 33, + successful_requests: 31, + failed_requests: 2, + total_tokens: 330, + prompt_tokens: 190, + completion_tokens: 140, + cache_read_input_tokens: 18, + cache_creation_input_tokens: 12, + }, + metadata: { + team_id: "team-1", + user_id: "u2", + user_email: null, + }, + }, + kD: { + metrics: { + spend: 4.4, + api_requests: 44, + successful_requests: 42, + failed_requests: 2, + total_tokens: 440, + prompt_tokens: 250, + completion_tokens: 190, + cache_read_input_tokens: 24, + cache_creation_input_tokens: 16, + }, + metadata: { + team_id: "team-1", + user_id: null, + }, + }, + kE: { + metrics: { + spend: 5.5, + api_requests: 55, + successful_requests: 53, + failed_requests: 2, + total_tokens: 550, + prompt_tokens: 310, + completion_tokens: 240, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 20, + }, + metadata: { + team_id: "team-1", + user_id: "u3", + key_exists: false, + }, + }, + }, + }, + "team-2": { + metrics: { + spend: 6.6, + api_requests: 66, + successful_requests: 64, + failed_requests: 2, + total_tokens: 660, + prompt_tokens: 370, + completion_tokens: 290, + cache_read_input_tokens: 36, + cache_creation_input_tokens: 24, + }, + api_key_breakdown: { + kF: { + metrics: { + spend: 6.6, + api_requests: 66, + successful_requests: 64, + failed_requests: 2, + total_tokens: 660, + prompt_tokens: 370, + completion_tokens: 290, + cache_read_input_tokens: 36, + cache_creation_input_tokens: 24, + }, + metadata: { + team_id: "team-2", + user_id: "u1", + user_email: "a@x", + }, + }, + }, + }, + }, + }, + }, + { + date: "2025-03-02", + breakdown: { + entities: { + "team-1": { + metrics: { + spend: 7.7, + api_requests: 77, + successful_requests: 75, + failed_requests: 2, + total_tokens: 770, + prompt_tokens: 430, + completion_tokens: 340, + cache_read_input_tokens: 42, + cache_creation_input_tokens: 28, + }, + api_key_breakdown: { + kA: { + metrics: { + spend: 7.7, + api_requests: 77, + successful_requests: 75, + failed_requests: 2, + total_tokens: 770, + prompt_tokens: 430, + completion_tokens: 340, + cache_read_input_tokens: 42, + cache_creation_input_tokens: 28, + }, + metadata: { + team_id: "team-1", + key_alias: "alice-key", + user_id: "u1", + user_email: "a@x", + }, + }, + }, + }, + }, + }, + }, + ], + metadata: { + total_spend: 30.8, + total_api_requests: 308, + total_successful_requests: 295, + total_failed_requests: 13, + total_tokens: 3080, + }, + }; + beforeEach(() => { vi.clearAllMocks(); }); @@ -1056,6 +1255,27 @@ describe("EntityUsageExport utils", () => { expect(keyIds).toContain("key1"); expect(keyIds).toContain("key2"); }); + + it("should emit key owner columns right after Key ID", () => { + const result = generateDailyWithKeysData(usersFixture, "Team"); + + const columnNames = Object.keys(result[0]); + expect(columnNames[columnNames.indexOf("Key ID") + 1]).toBe("User ID"); + expect(columnNames[columnNames.indexOf("User ID") + 1]).toBe("User Email"); + + const kARow = result.find((r) => r["Key ID"] === "kA" && r.Date === "2025-03-01"); + expect(kARow?.["User ID"]).toBe("u1"); + expect(kARow?.["User Email"]).toBe("a@x"); + expect(kARow?.["Key Alias"]).toBe("alice-key"); + + const kCRow = result.find((r) => r["Key ID"] === "kC"); + expect(kCRow?.["User ID"]).toBe("u2"); + expect(kCRow?.["User Email"]).toBe("-"); + + const kDRow = result.find((r) => r["Key ID"] === "kD"); + expect(kDRow?.["User ID"]).toBe("-"); + expect(kDRow?.["User Email"]).toBe("-"); + }); }); describe("generateDailyWithModelsData", () => { @@ -2010,6 +2230,20 @@ describe("EntityUsageExport utils", () => { window.Blob = originalBlob; }); + + it("should generate the daily_with_users filename and include User ID in the rows", () => { + const anchorElement = document.createElement("a"); + vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + + const today = new Date().toISOString().split("T")[0]; + + handleExportCSV(usersFixture, "daily_with_users", "Team", "team", mockTeamAliasMap); + + expect(anchorElement.download).toBe(`team_usage_daily_with_users_${today}.csv`); + + const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; + expect(unparsedRows[0]).toHaveProperty("User ID"); + }); }); describe("handleExportJSON", () => { @@ -2462,4 +2696,255 @@ describe("EntityUsageExport utils", () => { expect(result.find((r) => r["User ID"] === "user-b")?.["User"]).toBe("Grace"); }); }); + + describe("generateDailyWithUsersData", () => { + it("should reconcile spend with daily_with_keys and daily per date and team", () => { + const byUser = generateDailyWithUsersData(usersFixture, "Team"); + const byKey = generateDailyWithKeysData(usersFixture, "Team"); + const daily = generateDailyData(usersFixture, "Team"); + + expect(byUser.length).toBeGreaterThan(0); + expect(byKey.length).toBeGreaterThan(0); + expect(daily.length).toBeGreaterThan(0); + + const sumSpend = (rows: any[]): Record => { + const totals: Record = {}; + rows.forEach((r) => { + const bucket = `${r.Date}|${r["Team ID"]}`; + totals[bucket] = (totals[bucket] || 0) + Number(r["Spend ($)"]); + }); + return totals; + }; + + const userTotals = sumSpend(byUser); + const keyTotals = sumSpend(byKey); + + daily.forEach((row) => { + const bucket = `${row.Date}|${row["Team ID"]}`; + expect(userTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); + expect(keyTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); + }); + }); + + it("should roll multiple keys owned by one user in a team into a single row", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + const matches = rows.filter( + (r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u1", + ); + + expect(matches).toHaveLength(1); + const row = matches[0]; + expect(row.Keys).toBe(2); + expect(row["User Email"]).toBe("a@x"); + expect(row["Spend ($)"]).toBe("3.3000"); + expect(row.Requests).toBe(33); + expect(row["Successful Requests"]).toBe(30); + expect(row["Failed Requests"]).toBe(3); + expect(row["Total Tokens"]).toBe(330); + expect(row["Prompt Tokens"]).toBe(190); + expect(row["Completion Tokens"]).toBe(140); + expect(row["Cache Read Input Tokens"]).toBe(18); + expect(row["Cache Creation Input Tokens"]).toBe(12); + }); + + it("should bucket keys with no owner into an Unassigned row without dropping spend", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "Unassigned"); + + expect(row).toBeDefined(); + expect(row?.["User Email"]).toBe("-"); + expect(Number(row?.["Spend ($)"])).toBeCloseTo(4.4, 4); + }); + + it("should keep different users in the same team as separate rows", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + const teamRows = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1"); + + const u1Rows = teamRows.filter((r) => r["User ID"] === "u1"); + const u2Rows = teamRows.filter((r) => r["User ID"] === "u2"); + expect(u1Rows).toHaveLength(1); + expect(u2Rows).toHaveLength(1); + expect(Number(u2Rows[0]["Spend ($)"])).toBeCloseTo(3.3, 4); + }); + + it("should keep the same user in different teams as separate rows", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + const u1Rows = rows.filter((r) => r.Date === "2025-03-01" && r["User ID"] === "u1"); + + expect(u1Rows).toHaveLength(2); + const team1Row = u1Rows.find((r) => r["Team ID"] === "team-1"); + const team2Row = u1Rows.find((r) => r["Team ID"] === "team-2"); + expect(team1Row?.Keys).toBe(2); + expect(team2Row?.Keys).toBe(1); + expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); + }); + + it("should show a dash email when the user has none", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); + + expect(row).toBeDefined(); + expect(row?.["User Email"]).toBe("-"); + }); + + it("should still attribute a deleted key to its user", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); + + expect(row).toBeDefined(); + expect(Number(row?.["Spend ($)"])).toBeCloseTo(5.5, 4); + expect(row?.Requests).toBe(55); + }); + + it("should group rows under team_id on the aggregated endpoint shape", () => { + const aggregatedFixture: EntitySpendData = { + results: [ + { + date: "2025-03-01", + breakdown: { + entities: {}, + api_keys: { + kA: { + metrics: { + spend: 1.1, + api_requests: 11, + successful_requests: 10, + failed_requests: 1, + total_tokens: 110, + prompt_tokens: 60, + completion_tokens: 50, + cache_read_input_tokens: 6, + cache_creation_input_tokens: 4, + }, + metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, + }, + kF: { + metrics: { + spend: 6.6, + api_requests: 66, + successful_requests: 64, + failed_requests: 2, + total_tokens: 660, + prompt_tokens: 370, + completion_tokens: 290, + cache_read_input_tokens: 36, + cache_creation_input_tokens: 24, + }, + metadata: { team_id: "team-2", user_id: "u2" }, + }, + }, + }, + }, + ], + metadata: usersFixture.metadata, + }; + + const rows = generateDailyWithUsersData(aggregatedFixture, "Team"); + + expect(rows).toHaveLength(2); + const team1Row = rows.find((r) => r["Team ID"] === "team-1"); + const team2Row = rows.find((r) => r["Team ID"] === "team-2"); + expect(team1Row?.["User ID"]).toBe("u1"); + expect(team2Row?.["User ID"]).toBe("u2"); + expect(Number(team1Row?.["Spend ($)"])).toBeCloseTo(1.1, 4); + expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); + }); + + it("should emit the exact column order and sort by date ascending", () => { + const rows = generateDailyWithUsersData(usersFixture, "Team"); + + expect(Object.keys(rows[0])).toEqual([ + "Date", + "Team", + "Team ID", + "User ID", + "User Email", + "Keys", + "Spend ($)", + "Requests", + "Successful Requests", + "Failed Requests", + "Total Tokens", + "Prompt Tokens", + "Completion Tokens", + "Cache Read Input Tokens", + "Cache Creation Input Tokens", + ]); + + const dates = rows.map((r) => new Date(r.Date).getTime()); + for (let i = 0; i < dates.length - 1; i++) { + expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); + } + }); + + it("should dispatch daily_with_users through generateExportData", () => { + expect(generateExportData(usersFixture, "daily_with_users", "Team")).toEqual( + generateDailyWithUsersData(usersFixture, "Team"), + ); + }); + + it("should leave daily and daily_with_models output without user columns", () => { + const daily = generateDailyData(usersFixture, "Team"); + expect(daily[0]).not.toHaveProperty("User ID"); + + const modelsFixture: EntitySpendData = { + results: [ + { + date: "2025-03-01", + breakdown: { + entities: { + "team-1": { + metrics: { + spend: 1.1, + api_requests: 11, + successful_requests: 10, + failed_requests: 1, + total_tokens: 110, + prompt_tokens: 60, + completion_tokens: 50, + }, + api_key_breakdown: { + kA: { + metrics: { spend: 1.1, api_requests: 11, successful_requests: 10, failed_requests: 1, total_tokens: 110 }, + metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, + }, + }, + }, + }, + models: { + "gpt-4o": { + metrics: { spend: 1.1, api_requests: 11, total_tokens: 110 }, + api_key_breakdown: { + kA: { + metrics: { spend: 1.1, api_requests: 11, successful_requests: 10, failed_requests: 1, total_tokens: 110 }, + metadata: {}, + }, + }, + }, + }, + }, + }, + ], + metadata: usersFixture.metadata, + }; + + const modelRows = generateDailyWithModelsData(modelsFixture, "Team"); + expect(modelRows).toHaveLength(1); + expect(Object.keys(modelRows[0])).toEqual([ + "Date", + "Team", + "Team ID", + "Model", + "Spend ($)", + "Requests", + "Successful", + "Failed", + "Total Tokens", + "Prompt Tokens", + "Completion Tokens", + "Cache Read Input Tokens", + "Cache Creation Input Tokens", + ]); + }); + }); });