diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx
index 46714857c1f..60674a7c332 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx
@@ -36,7 +36,11 @@ vi.mock("./TopModelView", () => ({
default: () =>
Top Models
,
}));
-vi.mock("./EntityUsageExport", () => ({
+vi.mock("../../../EntityUsageExport/EntityUsageExportModal", () => ({
+ default: () =>
Entity Usage Export Modal
,
+}));
+
+vi.mock("../../../EntityUsageExport", () => ({
UsageExportHeader: () =>
Usage Export Header
,
}));
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx
index 52e75005ccc..7b8fcc4896f 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx
@@ -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
= ({
- accessToken,
- entityType,
- entityId,
- userID,
- userRole,
- entityList,
- premiumUser,
- dateValue,
-}) => {
+const EntityUsage: React.FC = ({ accessToken, entityType, entityId, entityList, dateValue }) => {
const [spendData, setSpendData] = useState({
results: [],
metadata: {
@@ -112,6 +103,8 @@ const EntityUsage: React.FC = ({
const modelMetrics = processActivityData(spendData, "models", teams || []);
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
const [selectedTags, setSelectedTags] = useState([]);
+ const [topKeysLimit, setTopKeysLimit] = useState(5);
+ const [topModelsLimit, setTopModelsLimit] = useState(5);
const fetchSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
@@ -204,7 +197,7 @@ const EntityUsage: React.FC = ({
...metrics,
}))
.sort((a, b) => b.spend - a.spend)
- .slice(0, 5);
+ .slice(0, topModelsLimit);
};
const getTopAPIKeys = () => {
@@ -269,7 +262,7 @@ const EntityUsage: React.FC = ({
spend: metrics.metrics.spend,
}))
.sort((a, b) => b.spend - a.spend)
- .slice(0, 5);
+ .slice(0, topKeysLimit);
};
const getProviderSpend = () => {
@@ -399,6 +392,7 @@ const EntityUsage: React.FC = ({
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
+ teams={teams || []}
/>
@@ -597,7 +591,13 @@ const EntityUsage: React.FC = ({
Top Virtual Keys
-
+
@@ -605,7 +605,11 @@ const EntityUsage: React.FC = ({
{entityType === "agent" ? "Top Agents" : "Top Models"}
-
+
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
index 6c074623139..126bf51bd36 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx
@@ -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 }) => (
+
+
Key Info View for {keyId}
+
+
+ ),
+}));
+
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();
- 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();
- 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();
- 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();
- expect(screen.getByText(header)).toBeInTheDocument();
- });
+ it("should display base table column headers", () => {
+ render();
+ 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();
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();
+ expect(screen.queryByText("Tags")).not.toBeInTheDocument();
+ });
+
+ it("should display key information in table view", () => {
render(
{
],
},
]}
- 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();
+
+ 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();
+
+ 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();
+
+ 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(
+ ,
+ );
+ expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument();
+ });
+
+ it("should display dash for missing key alias", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("-")).toBeInTheDocument();
+ });
+
+ it("should format spend values with two decimal places", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("$123.46")).toBeInTheDocument();
+ });
+
+ it("should display less than 0.01 spend as <$0.01", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("<$0.01")).toBeInTheDocument();
+ });
+
+ it("should display zero spend correctly", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("$0.00")).toBeInTheDocument();
+ });
+
+ it("should display dash for empty tags", () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByText("-").length).toBeGreaterThan(0);
+ });
+
+ it("should display dash for missing tags", () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByText("-").length).toBeGreaterThan(0);
+ });
+
+ it("should display first two tags by default and show expand button", () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+ expect(screen.getByText("Key ID")).toBeInTheDocument();
+ expect(screen.getByText("Key Alias")).toBeInTheDocument();
+ });
+
+ it("should display full key alias in table view", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("This is a very long key alias")).toBeInTheDocument();
+ });
+
+ it("should handle keys with no alias", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("-")).toBeInTheDocument();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
index db268286007..8903f74f232 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx
@@ -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 = ({ topKeys, teams, showTags = false }) => {
+const TopKeyView: React.FC = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedKey, setSelectedKey] = useState(null);
@@ -178,7 +179,17 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals
return (
<>
-
+
+
setTopKeysLimit(value as number)}
+ />
{viewMode === "chart" ? (
-
+
= ({ topKeys, teams, showTags = fals
/>
) : (
-
+
{
+ const mockSetTopModelsLimit = vi.fn();
+
+ beforeEach(() => {
+ mockSetTopModelsLimit.mockClear();
+ });
+
it("should render", () => {
- const { container } = render();
- expect(container).toBeTruthy();
+ render();
+ expect(screen.getByText("Table View")).toBeInTheDocument();
});
- it("should have a table view button", () => {
- const { getByText } = render();
- expect(getByText("Table View")).toBeInTheDocument();
+ it("should display table view button", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
});
- it("should have a chart view", () => {
- const { getByText } = render();
- expect(getByText("Chart View")).toBeInTheDocument();
+ it("should display chart view button", () => {
+ render();
+ 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();
- expect(getByText(header)).toBeInTheDocument();
- });
+ it("should display all table column headers", () => {
+ render();
+ 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(
{
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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText("model-1")).toBeInTheDocument();
+ expect(screen.getByText("model-2")).toBeInTheDocument();
+ });
+
+ it("should format spend values with two decimal places", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("$123.46")).toBeInTheDocument();
+ });
+
+ it("should display zero values correctly", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("$0.00")).toBeInTheDocument();
+ expect(screen.getAllByText("0").length).toBeGreaterThan(0);
+ });
+
+ it("should display successful requests with green styling", () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.getByText("1,234,567")).toBeInTheDocument();
+ });
+
+ it("should handle empty model list", () => {
+ render();
+ expect(screen.getByText("Model")).toBeInTheDocument();
+ expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
+ });
+
+ it("should display dash for missing model key", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("-")).toBeInTheDocument();
});
});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx
index 15ae660d4be..c69ba42f182 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx
@@ -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 (
<>
-
+
+
setTopModelsLimit(value as number)}
+ />
{modelViewMode === "chart" ? (
-
+
`$${formatNumberWithCommas(value, 2)}`}
layout="vertical"
yAxisWidth={200}
+ tickGap={5}
showLegend={false}
/>
) : (
-
+
<>>}
getRowCanExpand={() => false}
+ isLoading={false}
/>
)}
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx
index d891fcf604c..aaecbd063b5 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx
@@ -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
= ({ teams, organizations }) => {
const [showCustomerBanner, setShowCustomerBanner] = useState(true);
const [usageView, setUsageView] = useState("global");
const [showAgentBanner, setShowAgentBanner] = useState(true);
+ const [topKeysLimit, setTopKeysLimit] = useState(5);
+ const [topModelsLimit, setTopModelsLimit] = useState(5);
const getAllTags = async () => {
if (!accessToken) {
return;
@@ -116,7 +118,7 @@ const UsagePage: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ teams, organizations }) => {
Top Virtual Keys
-
+
{/* Top Models */}
+ {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
-
{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
+
setTopModelsLimit(value as number)}
+ />