mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
feat: Add project usage view to the Usage page (BerriAI/litellm/issues/40386)
This commit is contained in:
parent
2ea7d86469
commit
e88364d888
12 changed files with 660 additions and 8 deletions
|
|
@ -0,0 +1,66 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
|
||||
import type { ProjectSpendRow } from "./projectUsageAggregations";
|
||||
|
||||
vi.mock("@/components/shared/chart_loader", () => ({
|
||||
ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => (
|
||||
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockProjectSpend: ProjectSpendRow[] = [
|
||||
{
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 150.5,
|
||||
requests: 100,
|
||||
successful_requests: 95,
|
||||
failed_requests: 5,
|
||||
tokens: 50000,
|
||||
},
|
||||
{
|
||||
project_id: "project-beta",
|
||||
project_alias: "Project Beta",
|
||||
spend: 200.75,
|
||||
requests: 120,
|
||||
successful_requests: 115,
|
||||
failed_requests: 5,
|
||||
tokens: 75000,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProjectSpendBreakdown", () => {
|
||||
it("displays the title", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={[]} />);
|
||||
expect(screen.getByText("Spend by Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loader instead of chart content while loading", () => {
|
||||
render(<ProjectSpendBreakdown loading isDateChanging={false} projectSpend={[]} />);
|
||||
expect(screen.getByTestId("chart-loader")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No project usage data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays table headers", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={mockProjectSpend} />);
|
||||
expect(screen.getByText("Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend")).toBeInTheDocument();
|
||||
expect(screen.getByText("Successful")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tokens")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays each project's alias and formatted spend in the table", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={mockProjectSpend} />);
|
||||
expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Project Beta").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("$150.50")).toBeInTheDocument();
|
||||
expect(screen.getByText("$200.75")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty message when no project has usage", () => {
|
||||
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={[]} />);
|
||||
expect(screen.getByText("No project usage data")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import React from "react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DonutChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
import type { ProjectSpendRow } from "./projectUsageAggregations";
|
||||
|
||||
interface ProjectSpendBreakdownProps {
|
||||
loading: boolean;
|
||||
isDateChanging: boolean;
|
||||
projectSpend: ProjectSpendRow[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ProjectSpendRow>[] = [
|
||||
{
|
||||
header: "Project",
|
||||
accessorKey: "project_alias",
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-success" },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-destructive" },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.tokens.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const ProjectSpendBreakdown: React.FC<ProjectSpendBreakdownProps> = ({ loading, isDateChanging, projectSpend }) => (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by Project</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={projectSpend}
|
||||
index="project_alias"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={projectSpend}
|
||||
getRowId={(row) => row.project_id}
|
||||
noDataMessage="No project usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default ProjectSpendBreakdown;
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
|
||||
import * as networking from "@/components/networking";
|
||||
|
||||
import ProjectUsage from "./ProjectUsage";
|
||||
import type { EntityList } from "../EntityUsage/EntityUsage";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
projectDailyActivityCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const PROJECT_LIST: EntityList[] = [
|
||||
{ label: "Project Alpha", value: "project-alpha" },
|
||||
{ label: "Project Beta", value: "project-beta" },
|
||||
];
|
||||
|
||||
const DATE_VALUE = { from: new Date("2026-09-01"), to: new Date("2026-09-08") };
|
||||
|
||||
describe("ProjectUsage", () => {
|
||||
const mockProjectDailyActivityCall = vi.mocked(networking.projectDailyActivityCall);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("shows an enterprise upsell and never calls the API when the caller is not a premium user", () => {
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project Usage is an Enterprise feature")).toBeInTheDocument();
|
||||
expect(mockProjectDailyActivityCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for a project before fetching anything", () => {
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select at least one project above to view its usage.")).toBeInTheDocument();
|
||||
expect(mockProjectDailyActivityCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and renders the picked project's daily spend", async () => {
|
||||
mockProjectDailyActivityCall.mockResolvedValue({
|
||||
start_date: "2026-09-01",
|
||||
end_date: "2026-09-08",
|
||||
results: [
|
||||
{
|
||||
date: "2026-09-01",
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 12.5,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
api_requests: 4,
|
||||
successful_requests: 3,
|
||||
failed_requests: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
|
||||
|
||||
await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1));
|
||||
expect(mockProjectDailyActivityCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
DATE_VALUE.from,
|
||||
DATE_VALUE.to,
|
||||
["project-alpha"],
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0));
|
||||
expect(screen.getAllByText("4").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { projectDailyActivityCall } from "@/components/networking";
|
||||
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
|
||||
|
||||
import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary";
|
||||
import type { EntityList } from "../EntityUsage/EntityUsage";
|
||||
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
|
||||
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
|
||||
|
||||
interface ProjectUsageProps {
|
||||
accessToken: string | null;
|
||||
projectList: EntityList[] | null;
|
||||
dateValue: DateRangePickerValue;
|
||||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
const renderSummaryTile = ({ title, value, className }: SummaryTile) => (
|
||||
<ShadcnCard key={title}>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
);
|
||||
|
||||
const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, dateValue, premiumUser }) => {
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
|
||||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
||||
const projectOptions = useMemo<MultiSelectOption[]>(
|
||||
() => (projectList ?? []).map((project) => ({ label: project.label || project.value, value: project.value })),
|
||||
[projectList],
|
||||
);
|
||||
|
||||
const hasSelection = selectedProjectIds.length > 0;
|
||||
const hasDateRange = !!startTime && !!endTime;
|
||||
const hasRequestWindow = premiumUser && !!accessToken && hasDateRange;
|
||||
const enabled = hasRequestWindow && hasSelection;
|
||||
|
||||
const queryOptions = {
|
||||
queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()],
|
||||
queryFn: () => projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
};
|
||||
const { data, isFetching, isPlaceholderData } = useQuery(queryOptions);
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Project Usage is an Enterprise feature</AlertTitle>
|
||||
<AlertDescription>
|
||||
Filtering usage by project requires a LiteLLM Enterprise license. Get a 7 day trial at{" "}
|
||||
<a href="https://www.litellm.ai/enterprise#trial" target="_blank" rel="noreferrer" className="underline">
|
||||
litellm.ai/enterprise
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data?.results ?? [];
|
||||
const summary = summarizeProjectUsage(rows);
|
||||
const dailySpend = buildDailySpendSeries(rows);
|
||||
const projectBreakdown = buildProjectSpendBreakdown(rows);
|
||||
const isLoadingRows = isFetching && !isPlaceholderData;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Projects</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>Select one or more projects to compare their usage</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<MultiSelect
|
||||
options={projectOptions}
|
||||
value={selectedProjectIds}
|
||||
onValueChange={setSelectedProjectIds}
|
||||
placeholder="Search or select projects..."
|
||||
emptyText="No projects found"
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{!hasSelection ? (
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select at least one project above to view its usage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Project Spend Overview</h3>
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">
|
||||
{buildSummaryTiles(summary, false).map(renderSummaryTile)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingRows ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={dailySpend}
|
||||
index="date"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
yAxisWidth={100}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ProjectSpendBreakdown loading={isLoadingRows} isDateChanging={false} projectSpend={projectBreakdown} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectUsage;
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProjectDailySpendRow } from "@/components/networking";
|
||||
|
||||
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
|
||||
|
||||
const row = (overrides: Partial<ProjectDailySpendRow> = {}): ProjectDailySpendRow => ({
|
||||
date: "2026-09-01",
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 1,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
api_requests: 3,
|
||||
successful_requests: 2,
|
||||
failed_requests: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("summarizeProjectUsage", () => {
|
||||
it("returns all-zero totals for no rows", () => {
|
||||
expect(summarizeProjectUsage([])).toEqual({
|
||||
total_spend: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("sums spend, requests, and tokens across every row regardless of project or date", () => {
|
||||
const projectBetaRow: Partial<ProjectDailySpendRow> = {
|
||||
project_id: "project-beta",
|
||||
date: "2026-09-02",
|
||||
spend: 2.25,
|
||||
api_requests: 4,
|
||||
successful_requests: 4,
|
||||
failed_requests: 0,
|
||||
total_tokens: 40,
|
||||
};
|
||||
const rows = [row({ spend: 1.5 }), row(projectBetaRow)];
|
||||
|
||||
expect(summarizeProjectUsage(rows)).toEqual({
|
||||
total_spend: 3.75,
|
||||
total_api_requests: 7,
|
||||
total_successful_requests: 6,
|
||||
total_failed_requests: 1,
|
||||
total_tokens: 55,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailySpendSeries", () => {
|
||||
it("sums same-day spend across projects into a single point", () => {
|
||||
const rows = [row(), row({ project_id: "project-beta", spend: 2 })];
|
||||
|
||||
expect(buildDailySpendSeries(rows)).toEqual([{ date: "2026-09-01", spend: 3 }]);
|
||||
});
|
||||
|
||||
it("sorts distinct dates oldest first regardless of input order", () => {
|
||||
const rows = [row({ date: "2026-09-03", spend: 3 }), row(), row({ date: "2026-09-02", spend: 2 })];
|
||||
|
||||
expect(buildDailySpendSeries(rows).map((point) => point.date)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProjectSpendBreakdown", () => {
|
||||
it("sums a project's spend and tokens across every day into a single row", () => {
|
||||
const secondDayRow: Partial<ProjectDailySpendRow> = {
|
||||
date: "2026-09-02",
|
||||
spend: 2,
|
||||
total_tokens: 20,
|
||||
successful_requests: 1,
|
||||
failed_requests: 2,
|
||||
};
|
||||
const rows = [row({ total_tokens: 10, api_requests: 2, failed_requests: 0 }), row(secondDayRow)];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows)).toEqual([
|
||||
{
|
||||
project_id: "project-alpha",
|
||||
project_alias: "Project Alpha",
|
||||
spend: 3,
|
||||
requests: 5,
|
||||
successful_requests: 3,
|
||||
failed_requests: 2,
|
||||
tokens: 30,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts projects by spend descending", () => {
|
||||
const rows = [
|
||||
row({ project_id: "project-cheap", project_alias: "Cheap" }),
|
||||
row({ project_id: "project-costly", project_alias: "Costly", spend: 9 }),
|
||||
];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows).map((r) => r.project_id)).toEqual(["project-costly", "project-cheap"]);
|
||||
});
|
||||
|
||||
it("falls back to the project id when the project has no alias", () => {
|
||||
const rows = [row({ project_id: "project-untitled", project_alias: null })];
|
||||
|
||||
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { ProjectDailySpendRow } from "@/components/networking";
|
||||
|
||||
/** One row per project, summed across the whole range: the shape the breakdown table and donut chart render. */
|
||||
export interface ProjectSpendRow extends Record<string, unknown> {
|
||||
project_id: string;
|
||||
project_alias: string;
|
||||
spend: number;
|
||||
requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/** One point per day, spend summed across every selected project: the shape the daily spend chart renders. */
|
||||
export interface DailyProjectSpendPoint extends Record<string, unknown> {
|
||||
date: string;
|
||||
spend: number;
|
||||
}
|
||||
|
||||
export interface ProjectUsageSummary {
|
||||
total_spend: number;
|
||||
total_api_requests: number;
|
||||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
const EMPTY_SUMMARY: ProjectUsageSummary = {
|
||||
total_spend: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
};
|
||||
|
||||
/** Aggregate totals across every row, for the summary tiles. */
|
||||
export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary =>
|
||||
rows.reduce(
|
||||
(totals, row) => ({
|
||||
total_spend: totals.total_spend + row.spend,
|
||||
total_api_requests: totals.total_api_requests + row.api_requests,
|
||||
total_successful_requests: totals.total_successful_requests + row.successful_requests,
|
||||
total_failed_requests: totals.total_failed_requests + row.failed_requests,
|
||||
total_tokens: totals.total_tokens + row.total_tokens,
|
||||
}),
|
||||
EMPTY_SUMMARY,
|
||||
);
|
||||
|
||||
/** One point per day, spend summed across every selected project, sorted oldest first. */
|
||||
export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => {
|
||||
const spendByDate = new Map<string, number>();
|
||||
rows.forEach((row) => {
|
||||
spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend);
|
||||
});
|
||||
return Array.from(spendByDate, ([date, spend]) => ({ date, spend })).sort((a, b) => a.date.localeCompare(b.date));
|
||||
};
|
||||
|
||||
/**
|
||||
* One row per project, spend/tokens/requests summed across the whole range, sorted by
|
||||
* spend descending like every other "top X" breakdown on the usage page.
|
||||
*/
|
||||
export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => {
|
||||
const byProject = new Map<string, ProjectSpendRow>();
|
||||
rows.forEach((row) => {
|
||||
const existing = byProject.get(row.project_id);
|
||||
if (existing) {
|
||||
existing.spend += row.spend;
|
||||
existing.requests += row.api_requests;
|
||||
existing.successful_requests += row.successful_requests;
|
||||
existing.failed_requests += row.failed_requests;
|
||||
existing.tokens += row.total_tokens;
|
||||
return;
|
||||
}
|
||||
const newRow: ProjectSpendRow = {
|
||||
project_id: row.project_id,
|
||||
project_alias: row.project_alias || row.project_id,
|
||||
spend: row.spend,
|
||||
requests: row.api_requests,
|
||||
successful_requests: row.successful_requests,
|
||||
failed_requests: row.failed_requests,
|
||||
tokens: row.total_tokens,
|
||||
};
|
||||
byProject.set(row.project_id, newRow);
|
||||
});
|
||||
return Array.from(byProject.values()).sort((a, b) => b.spend - a.spend);
|
||||
};
|
||||
|
|
@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
|
|
@ -59,6 +60,7 @@ import {
|
|||
import EndpointUsage from "./EndpointUsage/EndpointUsage";
|
||||
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
|
||||
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
|
||||
import ProjectUsage from "./ProjectUsage/ProjectUsage";
|
||||
import SpendByProvider from "./EntityUsage/SpendByProvider";
|
||||
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
|
|
@ -102,12 +104,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
// filter reads as loading rather than as a range with no customers.
|
||||
const { data: customers } = useCustomers();
|
||||
const { data: agentsResponse } = useAgents();
|
||||
const { data: projectsResponse } = useProjects();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const isAdmin = all_admin_roles.includes(userRole || "");
|
||||
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage");
|
||||
|
||||
// For admins: null means global view (all users), a string means filter by that user
|
||||
// For non-admins: always set to their own user ID
|
||||
|
|
@ -935,6 +939,21 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Project Usage Panel */}
|
||||
{usageView === "project" && canViewProjectUsage && (
|
||||
<ProjectUsage
|
||||
accessToken={accessToken}
|
||||
projectList={
|
||||
projectsResponse?.map((project) => ({
|
||||
label: project.project_alias || project.project_id,
|
||||
value: project.project_id,
|
||||
})) || null
|
||||
}
|
||||
dateValue={dateValue}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Customer Usage Panel */}
|
||||
{usageView === "customer" && (
|
||||
<EntityUsage
|
||||
|
|
|
|||
|
|
@ -60,15 +60,18 @@ describe("UsageViewSelect", () => {
|
|||
expect(offers(container, "Tag Usage")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])(
|
||||
"should show %s to an admin",
|
||||
async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
});
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => {
|
||||
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])("should hide %s from an internal user", async (optionName) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
|
|
@ -86,6 +89,7 @@ describe("UsageViewSelect", () => {
|
|||
it.each([
|
||||
["Organization Usage", true],
|
||||
["Agent Usage (A2A)", false],
|
||||
["Project Usage", false],
|
||||
] as const)("should offer %s to an org admin: %s", async (optionName, expected) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
|
||||
import { BarChart3, Bot, Building2, Folder, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -9,6 +9,7 @@ export type UsageOption =
|
|||
| "my-usage"
|
||||
| "organization"
|
||||
| "team"
|
||||
| "project"
|
||||
| "customer"
|
||||
| "tag"
|
||||
| "agent"
|
||||
|
|
@ -68,6 +69,13 @@ const OPTIONS: OptionConfig[] = [
|
|||
description: "View usage by team",
|
||||
icon: <Users className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: "project",
|
||||
label: "Project Usage",
|
||||
description: "View usage by project",
|
||||
icon: <Folder className="size-4" />,
|
||||
capability: "viewProjectUsage",
|
||||
},
|
||||
{
|
||||
value: "customer",
|
||||
label: "Customer Usage",
|
||||
|
|
|
|||
|
|
@ -1623,6 +1623,38 @@ export const agentDailyActivityCall = async (
|
|||
});
|
||||
};
|
||||
|
||||
export type ProjectDailySpendResponse = components["schemas"]["ProjectDailySpendResponse"];
|
||||
export type ProjectDailySpendRow = components["schemas"]["ProjectDailySpendRow"];
|
||||
|
||||
export const projectDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
projectIds: string[],
|
||||
): Promise<ProjectDailySpendResponse> => {
|
||||
/**
|
||||
* Get daily spend per project from /project/daily/activity.
|
||||
*
|
||||
* Unlike team/user/tag/agent, there is no daily-aggregated project spend
|
||||
* table, so this scans spend logs directly and returns the whole range in
|
||||
* one response instead of paginating. project_ids is comma-joined because
|
||||
* the endpoint takes a single string, not repeated query params.
|
||||
*/
|
||||
try {
|
||||
return await apiClient.get<ProjectDailySpendResponse>(`/project/daily/activity`, {
|
||||
accessToken,
|
||||
query: {
|
||||
project_ids: projectIds.join(","),
|
||||
start_date: formatDate(startTime),
|
||||
end_date: formatDate(endTime),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project daily activity:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getOnboardingCredentials = async (inviteUUID: string) => {
|
||||
/**
|
||||
* Get all models on proxy
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [
|
|||
"viewPrompts",
|
||||
"viewOrganizationUsage",
|
||||
"viewAgentUsage",
|
||||
"viewProjectUsage",
|
||||
];
|
||||
|
||||
const PROXY_ADMIN_ONLY_PAGE_CAPABILITIES: Capability[] = [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const CAPABILITY_ROLES = {
|
|||
viewPrompts: all_admin_roles,
|
||||
viewOrganizationUsage: all_admin_roles,
|
||||
viewAgentUsage: all_admin_roles,
|
||||
viewProjectUsage: all_admin_roles,
|
||||
viewGlobalSpend: proxyAdminOnlyRoles,
|
||||
viewWorkflowRuns: proxyAdminOnlyRoles,
|
||||
viewMemory: proxyAdminOnlyRoles,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue