Merge pull request #19050 from BerriAI/litellm_ui_usage_top_x

[Feature] UI - Usage: Allow Top Virtual Keys and Models to Show More Entries
This commit is contained in:
yuneng-jiang 2026-01-13 17:06:07 -08:00 • committed by GitHub
commit 2ea6fcb584
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 951 additions and 117 deletions

View file

@ -1,3 +1,4 @@
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import {
BarChart,
@ -23,6 +24,7 @@ import {
} from "@tremor/react";
import React, { useEffect, useState } from "react";
import { ActivityMetrics, processActivityData } from "../../../activity_metrics";
import NewBadge from "../../../common_components/NewBadge";
import { UsageExportHeader } from "../../../EntityUsageExport";
import type { EntityType } from "../../../EntityUsageExport/types";
import {
@ -35,11 +37,9 @@ import {
import { getProviderLogoAndName } from "../../../provider_info_helpers";
import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types";
import { valueFormatterSpend } from "../../utils/value_formatters";
import EndpointUsage from "../EndpointUsage/EndpointUsage";
import TopKeyView from "./TopKeyView";
import TopModelView from "./TopModelView";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import EndpointUsage from "../EndpointUsage/EndpointUsage";
import NewBadge from "../../../common_components/NewBadge";
interface EntityMetrics {
metrics: {
@ -87,16 +87,7 @@ interface EntityUsageProps {
dateValue: DateRangePickerValue;
}
const EntityUsage: React.FC<EntityUsageProps> = ({
accessToken,
entityType,
entityId,
userID,
userRole,
entityList,
premiumUser,
dateValue,
}) => {
const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, entityId, entityList, dateValue }) => {
const [spendData, setSpendData] = useState<EntitySpendData>({
results: [],
metadata: {
@ -112,6 +103,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const modelMetrics = processActivityData(spendData, "models", teams || []);
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const fetchSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
@ -204,7 +197,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
...metrics,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, 5);
.slice(0, topModelsLimit);
};
const getTopAPIKeys = () => {
@ -269,7 +262,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
spend: metrics.metrics.spend,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, 5);
.slice(0, topKeysLimit);
};
const getProviderSpend = () => {
@ -598,7 +591,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<Col numColSpan={1}>
<Card>
<Title>Top Virtual Keys</Title>
<TopKeyView topKeys={getTopAPIKeys()} teams={null} showTags={entityType === "tag"} />
<TopKeyView
topKeys={getTopAPIKeys()}
teams={null}
showTags={entityType === "tag"}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Card>
</Col>
@ -606,7 +605,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<Col numColSpan={1}>
<Card>
<Title>{entityType === "agent" ? "Top Agents" : "Top Models"}</Title>
<TopModelView topModels={getTopModels()} />
<TopModelView
topModels={getTopModels()}
topModelsLimit={topModelsLimit}
setTopModelsLimit={setTopModelsLimit}
/>
</Card>
</Col>

View file

@ -1,15 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import TopKeyView from "./TopKeyView";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse } from "../../../key_team_helpers/key_list";
import * as transformKeyInfo from "../../../key_team_helpers/transform_key_info";
import * as networking from "../../../networking";
import TopKeyView from "./TopKeyView";
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
__esModule: true,
default: vi.fn(),
}));
vi.mock("../../../networking", () => ({
keyInfoV1Call: vi.fn(),
}));
vi.mock("../../../key_team_helpers/transform_key_info", () => ({
transformKeyInfo: vi.fn(),
}));
vi.mock("../../../templates/key_info_view", () => ({
default: ({ keyId, onClose }: { keyId: string; onClose: () => void }) => (
<div data-testid="key-info-view">
<div>Key Info View for {keyId}</div>
<button onClick={onClose}>Close</button>
</div>
),
}));
describe("TopKeyView", () => {
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockKeyInfoV1Call = vi.mocked(networking.keyInfoV1Call);
const mockTransformKeyInfo = vi.mocked(transformKeyInfo.transformKeyInfo);
const mockAuth = {
token: "mock-token",
accessToken: "test-token",
@ -20,46 +44,60 @@ describe("TopKeyView", () => {
disabledPersonalKeyCreation: false,
showSSOBanner: false,
};
const mockSetTopKeysLimit = vi.fn();
const baseProps = {
topKeys: [],
teams: null,
showTags: false,
topKeysLimit: 5,
setTopKeysLimit: mockSetTopKeysLimit,
};
beforeEach(() => {
mockUseAuthorized.mockReturnValue(mockAuth);
mockSetTopKeysLimit.mockClear();
mockKeyInfoV1Call.mockClear();
mockTransformKeyInfo.mockClear();
});
it("should render", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByText("Table View")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
});
it("should have a table view button", () => {
it("should display table view button", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByText("Table View")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
});
it("should have a chart view", () => {
it("should display chart view button", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByText("Chart View")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument();
});
["Key ID", "Key Alias", "Spend (USD)"].forEach((header) => {
it(`should have a ${header} column`, () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByText(header)).toBeInTheDocument();
});
it("should display base table column headers", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByText("Key ID")).toBeInTheDocument();
expect(screen.getByText("Key Alias")).toBeInTheDocument();
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
});
it("should have a Tags column when showTags is true", () => {
it("should display Tags column when showTags is true", () => {
render(<TopKeyView {...baseProps} showTags={true} />);
expect(screen.getByText("Tags")).toBeInTheDocument();
});
it("should show the key's information on the table", () => {
it("should not display Tags column when showTags is false", () => {
render(<TopKeyView {...baseProps} showTags={false} />);
expect(screen.queryByText("Tags")).not.toBeInTheDocument();
});
it("should display key information in table view", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
@ -71,7 +109,6 @@ describe("TopKeyView", () => {
],
},
]}
teams={null}
showTags={true}
/>,
);
@ -80,4 +117,519 @@ describe("TopKeyView", () => {
expect(screen.getByText(/tag-2/)).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
});
it("should switch to chart view when chart view button is clicked", async () => {
const user = userEvent.setup();
render(<TopKeyView {...baseProps} />);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
await user.click(chartViewButton);
expect(chartViewButton).toHaveClass("bg-blue-100");
});
it("should switch to table view when table view button is clicked", async () => {
const user = userEvent.setup();
render(<TopKeyView {...baseProps} />);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
const tableViewButton = screen.getByRole("button", { name: "Table View" });
await user.click(chartViewButton);
await user.click(tableViewButton);
expect(tableViewButton).toHaveClass("bg-blue-100");
});
it("should call setTopKeysLimit when limit is changed via Segmented control", async () => {
const user = userEvent.setup();
render(<TopKeyView {...baseProps} />);
const limit10Radio = screen.getByRole("radio", { name: "10" });
const limit10Label = limit10Radio.closest("label");
if (limit10Label) {
await user.click(limit10Label);
} else {
// Fallback: click the div with title="10"
const limit10Div = screen.getByTitle("10");
await user.click(limit10Div);
}
expect(mockSetTopKeysLimit).toHaveBeenCalledWith(10);
});
it("should display truncated key ID in table", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "sk-1234567890abcdef",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument();
});
it("should display dash for missing key alias", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "",
spend: 100,
},
]}
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("should format spend values with two decimal places", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 123.456,
},
]}
/>,
);
expect(screen.getByText("$123.46")).toBeInTheDocument();
});
it("should display less than 0.01 spend as <$0.01", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 0.005,
},
]}
/>,
);
expect(screen.getByText("<$0.01")).toBeInTheDocument();
});
it("should display zero spend correctly", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 0,
},
]}
/>,
);
expect(screen.getByText("$0.00")).toBeInTheDocument();
});
it("should display dash for empty tags", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
tags: [],
},
]}
showTags={true}
/>,
);
expect(screen.getAllByText("-").length).toBeGreaterThan(0);
});
it("should display dash for missing tags", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
showTags={true}
/>,
);
expect(screen.getAllByText("-").length).toBeGreaterThan(0);
});
it("should display first two tags by default and show expand button", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
{ tag: "tag-2", usage: 30 },
{ tag: "tag-3", usage: 20 },
],
},
]}
showTags={true}
/>,
);
expect(screen.getByText(/tag-1/)).toBeInTheDocument();
expect(screen.getByText(/tag-2/)).toBeInTheDocument();
expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument();
});
it("should expand tags when expand button is clicked", async () => {
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
{ tag: "tag-2", usage: 30 },
{ tag: "tag-3", usage: 20 },
],
},
]}
showTags={true}
/>,
);
const expandButton = screen.getByTitle("Show all tags");
await user.click(expandButton);
expect(screen.getByText(/tag-3/)).toBeInTheDocument();
});
it("should collapse tags when collapse button is clicked", async () => {
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
tags: [
{ tag: "tag-1", usage: 50 },
{ tag: "tag-2", usage: 30 },
{ tag: "tag-3", usage: 20 },
],
},
]}
showTags={true}
/>,
);
const expandButton = screen.getByTitle("Show all tags");
await user.click(expandButton);
expect(screen.getByText(/tag-3/)).toBeInTheDocument();
const collapseButton = screen.getByTitle("Show fewer tags");
await user.click(collapseButton);
expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument();
});
it("should open modal when key ID is clicked", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
await waitFor(() => {
expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123");
});
await waitFor(() => {
expect(screen.getByTestId("key-info-view")).toBeInTheDocument();
});
});
it("should close modal when close button is clicked", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
await waitFor(() => {
expect(screen.getByTestId("key-info-view")).toBeInTheDocument();
});
const closeButton = screen.getByLabelText("Close");
await user.click(closeButton);
await waitFor(() => {
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
});
});
it("should close modal when escape key is pressed", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
await waitFor(() => {
expect(screen.getByTestId("key-info-view")).toBeInTheDocument();
});
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
});
});
it("should close modal when clicking outside modal", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
const user = userEvent.setup();
const { container } = render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
await waitFor(() => {
expect(screen.getByTestId("key-info-view")).toBeInTheDocument();
});
const modalBackdrop = container.querySelector(".fixed.inset-0");
if (modalBackdrop) {
await user.click(modalBackdrop);
}
await waitFor(() => {
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
});
});
it("should not open modal when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({
...mockAuth,
accessToken: "",
});
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
await waitFor(() => {
expect(mockKeyInfoV1Call).not.toHaveBeenCalled();
});
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
});
it("should handle error when fetching key info", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockKeyInfoV1Call.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
},
]}
/>,
);
const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button");
if (keyIdButton) {
await user.click(keyIdButton);
}
await waitFor(() => {
expect(mockKeyInfoV1Call).toHaveBeenCalled();
});
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled();
});
consoleErrorSpy.mockRestore();
});
it("should sort tags by usage descending", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "Test Key",
spend: 100,
tags: [
{ tag: "tag-low", usage: 10 },
{ tag: "tag-high", usage: 50 },
{ tag: "tag-medium", usage: 30 },
],
},
]}
showTags={true}
/>,
);
const tagElements = screen.getAllByText(/tag-/);
const tagTexts = tagElements.map((el) => el.textContent);
// Tags are truncated to 7 characters + "...", so "tag-high" becomes "tag-hig..."
expect(tagTexts[0]).toMatch(/^tag-hig/);
expect(tagTexts[1]).toMatch(/^tag-med/);
});
it("should handle empty key list", () => {
render(<TopKeyView {...baseProps} topKeys={[]} />);
expect(screen.getByText("Key ID")).toBeInTheDocument();
expect(screen.getByText("Key Alias")).toBeInTheDocument();
});
it("should display full key alias in table view", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "This is a very long key alias",
spend: 100,
},
]}
/>,
);
expect(screen.getByText("This is a very long key alias")).toBeInTheDocument();
});
it("should handle keys with no alias", () => {
render(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: null,
spend: 100,
},
]}
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
});
});

View file

@ -1,23 +1,24 @@
import React, { useState } from "react";
import { BarChart } from "@tremor/react";
import KeyInfoView from "../../../templates/key_info_view";
import { keyInfoV1Call } from "../../../networking";
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
import { DataTable } from "../../../view_logs/table";
import { Tooltip } from "antd";
import { Button } from "@tremor/react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { TagUsage } from "../../types";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
import { BarChart, Button } from "@tremor/react";
import { Segmented, Tooltip } from "antd";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
import { keyInfoV1Call } from "../../../networking";
import KeyInfoView from "../../../templates/key_info_view";
import { DataTable } from "../../../view_logs/table";
import { TagUsage } from "../../types";
interface TopKeyViewProps {
topKeys: any[];
teams: any[] | null;
showTags?: boolean;
topKeysLimit: number;
setTopKeysLimit: (limit: number) => void;
}
const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = false }) => {
const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
@ -178,7 +179,17 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
return (
<>
<div className="mb-4 flex justify-end items-center">
<div className="mb-4 flex justify-between items-center">
<Segmented
options={[
{ label: "5", value: 5 },
{ label: "10", value: 10 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
]}
value={topKeysLimit}
onChange={(value) => setTopKeysLimit(value as number)}
/>
<div className="flex space-x-2">
<button
onClick={() => setViewMode("table")}
@ -196,9 +207,10 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
</div>
{viewMode === "chart" ? (
<div className="relative">
<div className="relative max-h-[600px] overflow-y-auto">
<BarChart
className="mt-4 h-40 cursor-pointer hover:opacity-90"
className="mt-4 cursor-pointer hover:opacity-90"
style={{ height: Math.min(processedTopKeys.length, topKeysLimit) * 52 }}
data={processedTopKeys}
index="display_key_alias"
categories={["spend"]}
@ -234,7 +246,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
/>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
<DataTable
columns={columns}
data={topKeys}

View file

@ -1,32 +1,41 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import TopModelView from "./TopModelView";
describe("TopModelView", () => {
const mockSetTopModelsLimit = vi.fn();
beforeEach(() => {
mockSetTopModelsLimit.mockClear();
});
it("should render", () => {
const { container } = render(<TopModelView topModels={[]} />);
expect(container).toBeTruthy();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("Table View")).toBeInTheDocument();
});
it("should have a table view button", () => {
const { getByText } = render(<TopModelView topModels={[]} />);
expect(getByText("Table View")).toBeInTheDocument();
it("should display table view button", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
});
it("should have a chart view", () => {
const { getByText } = render(<TopModelView topModels={[]} />);
expect(getByText("Chart View")).toBeInTheDocument();
it("should display chart view button", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument();
});
["Model", "Spend (USD)", "Successful", "Failed", "Tokens"].forEach((header) => {
it(`should have a ${header} column`, () => {
const { getByText } = render(<TopModelView topModels={[]} />);
expect(getByText(header)).toBeInTheDocument();
});
it("should display all table column headers", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
expect(screen.getByText("Successful")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.getByText("Tokens")).toBeInTheDocument();
});
it("should show the model's information on the table", () => {
const { getByText } = render(
it("should display model data in table view", () => {
render(
<TopModelView
topModels={[
{
@ -37,12 +46,222 @@ describe("TopModelView", () => {
tokens: 50000,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
expect(getByText("gpt-4")).toBeInTheDocument();
expect(getByText("$150.50")).toBeInTheDocument();
expect(getByText("100")).toBeInTheDocument();
expect(getByText("5")).toBeInTheDocument();
expect(getByText("50,000")).toBeInTheDocument();
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("$150.50")).toBeInTheDocument();
expect(screen.getByText("100")).toBeInTheDocument();
const failedRequestsCell = screen
.getAllByText("5")
.find((el) => el.closest("span")?.classList.contains("text-red-600"));
expect(failedRequestsCell).toBeDefined();
expect(screen.getByText("50,000")).toBeInTheDocument();
});
it("should switch to chart view when chart view button is clicked", async () => {
const user = userEvent.setup();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
await user.click(chartViewButton);
expect(chartViewButton).toHaveClass("bg-blue-100");
});
it("should switch to table view when table view button is clicked", async () => {
const user = userEvent.setup();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
const tableViewButton = screen.getByRole("button", { name: "Table View" });
await user.click(chartViewButton);
await user.click(tableViewButton);
expect(tableViewButton).toHaveClass("bg-blue-100");
});
it("should call setTopModelsLimit when limit is changed via Segmented control", async () => {
const user = userEvent.setup();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const limit10Radio = screen.getByRole("radio", { name: "10" });
const limit10Label = limit10Radio.closest("label");
if (limit10Label) {
await user.click(limit10Label);
} else {
// Fallback: click the div with title="10"
const limit10Div = screen.getByTitle("10");
await user.click(limit10Div);
}
expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10);
});
it("should display only top N models based on limit", () => {
const manyModels = Array.from({ length: 10 }, (_, i) => ({
key: `model-${i + 1}`,
spend: 100 + i,
successful_requests: 50 + i,
failed_requests: 5 + i,
tokens: 10000 + i * 1000,
}));
render(<TopModelView topModels={manyModels} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("model-1")).toBeInTheDocument();
expect(screen.getByText("model-5")).toBeInTheDocument();
expect(screen.queryByText("model-6")).not.toBeInTheDocument();
});
it("should display all models when limit is greater than model count", () => {
const models = [
{
key: "model-1",
spend: 100,
successful_requests: 50,
failed_requests: 5,
tokens: 10000,
},
{
key: "model-2",
spend: 200,
successful_requests: 60,
failed_requests: 6,
tokens: 20000,
},
];
render(<TopModelView topModels={models} topModelsLimit={10} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("model-1")).toBeInTheDocument();
expect(screen.getByText("model-2")).toBeInTheDocument();
});
it("should format spend values with two decimal places", () => {
render(
<TopModelView
topModels={[
{
key: "model-1",
spend: 123.456,
successful_requests: 100,
failed_requests: 5,
tokens: 50000,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
expect(screen.getByText("$123.46")).toBeInTheDocument();
});
it("should display zero values correctly", () => {
render(
<TopModelView
topModels={[
{
key: "model-1",
spend: 0,
successful_requests: 0,
failed_requests: 0,
tokens: 0,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
});
it("should display successful requests with green styling", () => {
render(
<TopModelView
topModels={[
{
key: "model-1",
spend: 100,
successful_requests: 50,
failed_requests: 5,
tokens: 10000,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
const successfulCell = screen
.getAllByText("50")
.find((el) => el.closest("span")?.classList.contains("text-green-600"));
expect(successfulCell).toBeDefined();
});
it("should display failed requests with red styling", () => {
render(
<TopModelView
topModels={[
{
key: "model-1",
spend: 100,
successful_requests: 50,
failed_requests: 5,
tokens: 10000,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
const failedCell = screen.getAllByText("5").find((el) => el.closest("span")?.classList.contains("text-red-600"));
expect(failedCell).toBeDefined();
});
it("should format large token numbers with commas", () => {
render(
<TopModelView
topModels={[
{
key: "model-1",
spend: 100,
successful_requests: 50,
failed_requests: 5,
tokens: 1234567,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
expect(screen.getByText("1,234,567")).toBeInTheDocument();
});
it("should handle empty model list", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
});
it("should display dash for missing model key", () => {
render(
<TopModelView
topModels={[
{
key: "",
spend: 100,
successful_requests: 50,
failed_requests: 5,
tokens: 10000,
},
]}
topModelsLimit={5}
setTopModelsLimit={mockSetTopModelsLimit}
/>,
);
expect(screen.getByText("-")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,7 @@
import { BarChart } from "@tremor/react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { Segmented } from "antd";
import { useState } from "react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
import { DataTable } from "../../../view_logs/table";
interface TopModel {
@ -13,9 +14,11 @@ interface TopModel {
interface TopModelViewProps {
topModels: TopModel[];
topModelsLimit: number;
setTopModelsLimit: (limit: number) => void;
}
export default function TopModelView({ topModels }: TopModelViewProps) {
export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) {
const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table");
const columns = [
@ -48,9 +51,21 @@ export default function TopModelView({ topModels }: TopModelViewProps) {
cell: (info: any) => info.getValue()?.toLocaleString() || 0,
},
];
const processedTopModels = topModels.slice(0, topModelsLimit);
return (
<>
<div className="mb-4 flex justify-end items-center">
<div className="mb-4 flex justify-between items-center">
<Segmented
options={[
{ label: "5", value: 5 },
{ label: "10", value: 10 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
]}
value={topModelsLimit}
onChange={(value) => setTopModelsLimit(value as number)}
/>
<div className="flex space-x-2">
<button
onClick={() => setModelViewMode("table")}
@ -67,26 +82,29 @@ export default function TopModelView({ topModels }: TopModelViewProps) {
</div>
</div>
{modelViewMode === "chart" ? (
<div className="relative">
<div className="relative max-h-[600px] overflow-y-auto">
<BarChart
className="mt-4 h-40"
data={topModels}
className="mt-4 cursor-pointer hover:opacity-90"
style={{ height: Math.min(processedTopModels.length, topModelsLimit) * 52 }}
data={processedTopModels}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
layout="vertical"
yAxisWidth={200}
tickGap={5}
showLegend={false}
/>
</div>
) : (
<div className="border rounded-lg overflow-auto">
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
<DataTable
columns={columns}
data={topModels}
data={processedTopModels}
renderSubComponent={() => <></>}
getRowCanExpand={() => false}
isLoading={false}
/>
</div>
)}

View file

@ -27,17 +27,19 @@ import {
Text,
Title,
} from "@tremor/react";
import { Alert } from "antd";
import { Alert, Segmented } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Button } from "@tremor/react";
import { all_admin_roles } from "../../../utils/roles";
import { ActivityMetrics, processActivityData } from "../../activity_metrics";
import CloudZeroExportModal from "../../cloudzero_export_modal";
import NewBadge from "../../common_components/NewBadge";
import EntityUsageExportModal from "../../EntityUsageExport";
import { Team } from "../../key_team_helpers/key_list";
import { Organization, tagListCall, userDailyActivityAggregatedCall, userDailyActivityCall } from "../../networking";
@ -49,12 +51,10 @@ import UserAgentActivity from "../../user_agent_activity";
import ViewUserSpend from "../../view_user_spend";
import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types";
import { valueFormatterSpend } from "../utils/value_formatters";
import EndpointUsage from "./EndpointUsage/EndpointUsage";
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import TopKeyView from "./EntityUsage/TopKeyView";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import EndpointUsage from "./EndpointUsage/EndpointUsage";
import NewBadge from "../../common_components/NewBadge";
interface UsagePageProps {
teams: Team[];
@ -95,6 +95,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [showCustomerBanner, setShowCustomerBanner] = useState(true);
const [usageView, setUsageView] = useState<UsageOption>("global");
const [showAgentBanner, setShowAgentBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const getAllTags = async () => {
if (!accessToken) {
return;
@ -116,7 +118,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const totalSpend = userSpendData.metadata?.total_spend || 0;
// Calculate top models from the breakdown data
const getTopModels = () => {
const getTopModels = (limit: number = 5) => {
const modelSpend: { [key: string]: MetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.models || {}).forEach(([model, metrics]) => {
@ -159,10 +161,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
tokens: metrics.metrics.total_tokens,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, 5);
.slice(0, limit);
};
const getTopModelGroups = () => {
const getTopModelGroups = (limit: number = 5) => {
const modelGroupSpend: { [key: string]: MetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.model_groups || {}).forEach(([modelGroup, metrics]) => {
@ -206,7 +208,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
tokens: metrics.metrics.total_tokens,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, 5);
.slice(0, limit);
};
// Calculate provider spend from the breakdown data
@ -254,7 +256,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
};
// Calculate top API keys from the breakdown data
const getTopKeys = () => {
const getTopKeys = (limit: number = 5) => {
const keySpend: { [key: string]: KeyMetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => {
@ -300,7 +302,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
spend: metrics.metrics.spend,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, 5);
.slice(0, limit);
};
const fetchUserSpendData = useCallback(async () => {
@ -576,15 +578,30 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top Virtual Keys</Title>
<TopKeyView topKeys={getTopKeys()} teams={null} />
<TopKeyView
topKeys={getTopKeys(topKeysLimit)}
teams={null}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Card>
</Col>
{/* Top Models */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
<div className="flex justify-between items-center mb-4">
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
<Segmented
options={[
{ label: "5", value: 5 },
{ label: "10", value: 10 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
]}
value={topModelsLimit}
onChange={(value) => setTopModelsLimit(value as number)}
/>
<div className="flex bg-gray-100 rounded-lg p-1">
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
@ -611,33 +628,46 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<BarChart
className="mt-4 h-40"
data={modelViewType === "groups" ? getTopModelGroups() : getTopModels()}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
<div className="relative max-h-[600px] overflow-y-auto">
{(() => {
const modelData =
modelViewType === "groups"
? getTopModelGroups(topModelsLimit)
: getTopModels(topModelsLimit);
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.key}</p>
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
<p className="text-gray-600">Total Requests: {data.requests.toLocaleString()}</p>
<p className="text-green-600">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
</div>
<BarChart
className="mt-4"
style={{ height: Math.min(modelData.length, topModelsLimit) * 52 }}
data={modelData}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.key}</p>
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
<p className="text-gray-600">
Total Requests: {data.requests.toLocaleString()}
</p>
<p className="text-green-600">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
</div>
);
}}
/>
);
}}
/>
})()}
</div>
)}
</Card>
</Col>

View file

@ -609,7 +609,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top Virtual Keys</Title>
<TopKeyView topKeys={topKeys} teams={null} />
<TopKeyView topKeys={topKeys} teams={null} topKeysLimit={5} setTopKeysLimit={() => {}} />
</Card>
</Col>
<Col numColSpan={1}>