mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
Merge pull request #42367 from BerriAI/litellm_team_usage_export_per_user
feat(ui): add per-user breakdown to team usage export
This commit is contained in:
commit
a59ecfb7c5
5 changed files with 652 additions and 2 deletions
|
|
@ -33,4 +33,33 @@ describe("ExportTypeSelector", () => {
|
|||
renderWithProviders(<ExportTypeSelector value="daily_with_models" onChange={vi.fn()} entityType="team" />);
|
||||
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(<ExportTypeSelector value="daily" onChange={onChange} entityType="team" />);
|
||||
|
||||
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(<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="user" />);
|
||||
|
||||
expect(screen.queryByRole("radio", { name: /and user/i })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /Day-by-day breakdown by user Daily metrics for each 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(<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="tag" />);
|
||||
|
||||
expect(screen.getByRole("radio", { name: /Day-by-day breakdown by tag and user/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface ExportTypeSelectorProps {
|
|||
}
|
||||
|
||||
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
|
||||
const scopes: { value: ExportScope; title: string; description: string }[] = [
|
||||
const allScopes: { value: ExportScope; title: string; description: string }[] = [
|
||||
{
|
||||
value: "daily",
|
||||
title: `Day-by-day breakdown by ${entityType}`,
|
||||
|
|
@ -25,7 +25,13 @@ const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange
|
|||
title: `Day-by-day by ${entityType} and model`,
|
||||
description: "Daily metrics split by model",
|
||||
},
|
||||
{
|
||||
value: "daily_with_users",
|
||||
title: `Day-by-day breakdown by ${entityType} and user`,
|
||||
description: `Daily metrics for each ${entityType}, split by key owner`,
|
||||
},
|
||||
];
|
||||
const scopes = allScopes.filter((scope) => scope.value !== "daily_with_users" || entityType !== "user");
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types
|
|||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
export type ExportFormat = "csv" | "json";
|
||||
export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models";
|
||||
export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users";
|
||||
export type EntityType = "tag" | "team" | "organization" | "customer" | "agent" | "user";
|
||||
|
||||
export interface EntitySpendData {
|
||||
|
|
|
|||
|
|
@ -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,21 @@ 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);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-03-01T12:00:00Z"));
|
||||
|
||||
handleExportCSV(usersFixture, "daily_with_users", "Team", "team", mockTeamAliasMap);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(anchorElement.download).toBe("team_usage_daily_with_users_2025-03-01.csv");
|
||||
|
||||
const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record<string, unknown>[];
|
||||
expect(unparsedRows[0]).toHaveProperty("User ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExportJSON", () => {
|
||||
|
|
@ -2462,4 +2697,312 @@ 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<string, number> => {
|
||||
const totals: Record<string, number> = {};
|
||||
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 keep owners separate when entity and user ids contain underscores", () => {
|
||||
const collisionFixture: EntitySpendData = {
|
||||
results: [
|
||||
{
|
||||
date: "2025-03-01",
|
||||
breakdown: {
|
||||
entities: {
|
||||
team_1: {
|
||||
metrics: { spend: 1, api_requests: 1, total_tokens: 10 },
|
||||
api_key_breakdown: {
|
||||
kX: {
|
||||
metrics: { spend: 1, api_requests: 1, total_tokens: 10 },
|
||||
metadata: { team_id: "team_1", user_id: "u1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
metrics: { spend: 2, api_requests: 2, total_tokens: 20 },
|
||||
api_key_breakdown: {
|
||||
kY: {
|
||||
metrics: { spend: 2, api_requests: 2, total_tokens: 20 },
|
||||
metadata: { team_id: "team", user_id: "1_u1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: usersFixture.metadata,
|
||||
};
|
||||
|
||||
const rows = generateDailyWithUsersData(collisionFixture, "Team");
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
const team1Row = rows.find((r) => r["Team ID"] === "team_1");
|
||||
expect(team1Row?.["User ID"]).toBe("u1");
|
||||
expect(team1Row?.Keys).toBe(1);
|
||||
expect(team1Row?.["Spend ($)"]).toBe("1.0000");
|
||||
const teamRow = rows.find((r) => r["Team ID"] === "team");
|
||||
expect(teamRow?.["User ID"]).toBe("1_u1");
|
||||
expect(teamRow?.Keys).toBe(1);
|
||||
expect(teamRow?.["Spend ($)"]).toBe("2.0000");
|
||||
});
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ export const generateDailyWithKeysData = (
|
|||
entityAlias: string;
|
||||
keyId: string;
|
||||
keyAlias: string | null;
|
||||
userId: string | null;
|
||||
userEmail: string | null;
|
||||
metrics: {
|
||||
spend: number;
|
||||
api_requests: number;
|
||||
|
|
@ -200,6 +202,8 @@ export const generateDailyWithKeysData = (
|
|||
entityAlias,
|
||||
keyId,
|
||||
keyAlias,
|
||||
userId: keyData?.metadata?.user_id || null,
|
||||
userEmail: keyData?.metadata?.user_email || null,
|
||||
metrics: {
|
||||
spend: keyData.metrics?.spend || 0,
|
||||
api_requests: keyData.metrics?.api_requests || 0,
|
||||
|
|
@ -236,6 +240,7 @@ export const generateDailyWithKeysData = (
|
|||
[`${entityLabel} ID`]: item.entityId,
|
||||
"Key Alias": item.keyAlias || "-",
|
||||
"Key ID": item.keyId,
|
||||
...(entityLabel === "User" ? {} : { "User ID": item.userId || "-", "User Email": item.userEmail || "-" }),
|
||||
"Spend ($)": formatNumberWithCommas(item.metrics.spend, 4),
|
||||
Requests: item.metrics.api_requests,
|
||||
"Successful Requests": item.metrics.successful_requests,
|
||||
|
|
@ -250,6 +255,71 @@ export const generateDailyWithKeysData = (
|
|||
return dailyKeyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
|
||||
};
|
||||
|
||||
export const generateDailyWithUsersData = (
|
||||
spendData: EntitySpendData,
|
||||
entityLabel: string,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): any[] => {
|
||||
const aggregatedData: {
|
||||
[key: string]: {
|
||||
Date: string;
|
||||
entityId: string;
|
||||
entityAlias: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
keyIds: Set<string>;
|
||||
metrics: Record<(typeof METRIC_KEYS)[number], number>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
|
||||
Object.entries(data.api_key_breakdown || {}).forEach(([keyId, keyData]: [string, any]) => {
|
||||
const userId = keyData?.metadata?.user_id || "Unassigned";
|
||||
const uniqueKey = JSON.stringify([day.date, entityId, userId]);
|
||||
if (!aggregatedData[uniqueKey]) {
|
||||
aggregatedData[uniqueKey] = {
|
||||
Date: day.date,
|
||||
entityId,
|
||||
entityAlias,
|
||||
userId,
|
||||
userEmail: null,
|
||||
keyIds: new Set(),
|
||||
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])) as Record<(typeof METRIC_KEYS)[number], number>,
|
||||
};
|
||||
}
|
||||
const bucket = aggregatedData[uniqueKey];
|
||||
bucket.userEmail = bucket.userEmail || keyData?.metadata?.user_email || null;
|
||||
bucket.keyIds.add(keyId);
|
||||
for (const k of METRIC_KEYS) {
|
||||
bucket.metrics[k] += keyData?.metrics?.[k] || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(aggregatedData)
|
||||
.map((item) => ({
|
||||
Date: item.Date,
|
||||
[entityLabel]: item.entityAlias,
|
||||
[`${entityLabel} ID`]: item.entityId,
|
||||
"User ID": item.userId,
|
||||
"User Email": item.userEmail || "-",
|
||||
Keys: item.keyIds.size,
|
||||
"Spend ($)": formatNumberWithCommas(item.metrics.spend, 4),
|
||||
Requests: item.metrics.api_requests,
|
||||
"Successful Requests": item.metrics.successful_requests,
|
||||
"Failed Requests": item.metrics.failed_requests,
|
||||
"Total Tokens": item.metrics.total_tokens,
|
||||
"Prompt Tokens": item.metrics.prompt_tokens,
|
||||
"Completion Tokens": item.metrics.completion_tokens,
|
||||
"Cache Read Input Tokens": item.metrics.cache_read_input_tokens,
|
||||
"Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
|
||||
};
|
||||
|
||||
export const generateDailyWithModelsData = (
|
||||
spendData: EntitySpendData,
|
||||
entityLabel: string,
|
||||
|
|
@ -340,6 +410,8 @@ export const generateExportData = (
|
|||
return generateDailyWithKeysData(spendData, entityLabel, teamAliasMap);
|
||||
case "daily_with_models":
|
||||
return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap);
|
||||
case "daily_with_users":
|
||||
return generateDailyWithUsersData(spendData, entityLabel, teamAliasMap);
|
||||
default:
|
||||
return generateDailyData(spendData, entityLabel, teamAliasMap);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue