From aa75bec100e7eb0f9817e28c202e24bd97d834d5 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 29 Jan 2026 18:27:10 -0800 Subject: [PATCH 1/2] Model Usage per key --- .../components/KeyModelUsageView.tsx | 108 ++++++++++++++++++ .../src/components/UsagePage/types.ts | 10 ++ .../src/components/activity_metrics.tsx | 82 ++++++++++--- 3 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx new file mode 100644 index 00000000000..a4292de4802 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx @@ -0,0 +1,108 @@ +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { BarChart, Card, Title } from "@tremor/react"; +import { Table } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import React, { useState } from "react"; +import { TopModelData } from "../types"; + +interface KeyModelUsageViewProps { + topModels: TopModelData[]; +} + +const VISIBLE_ROWS = 5; +// antd Table with size="small" has a row height of ~39px +const ANTD_SMALL_TABLE_ROW_HEIGHT = 39; + +const columns: ColumnsType = [ + { + title: "Model", + dataIndex: "model", + key: "model", + render: (value) => value || "-", + }, + { + title: "Spend (USD)", + dataIndex: "spend", + key: "spend", + render: (value) => `$${formatNumberWithCommas(value, 2)}`, + }, + { + title: "Successful", + dataIndex: "successful_requests", + key: "successful_requests", + render: (value) => {value?.toLocaleString() || 0}, + }, + { + title: "Failed", + dataIndex: "failed_requests", + key: "failed_requests", + render: (value) => {value?.toLocaleString() || 0}, + }, + { + title: "Tokens", + dataIndex: "tokens", + key: "tokens", + render: (value) => value?.toLocaleString() || 0, + }, +]; + +const KeyModelUsageView: React.FC = ({ topModels }) => { + const [viewMode, setViewMode] = useState<"chart" | "table">("table"); + + if (topModels.length === 0) { + return null; + } + + return ( + +
+ Model Usage +
+ + +
+
+ {viewMode === "chart" ? ( +
+ ({ key: m.model, spend: m.spend }))} + index="key" + categories={["spend"]} + colors={["cyan"]} + valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`} + layout="vertical" + yAxisWidth={180} + tickGap={5} + showLegend={false} + /> +
+ ) : ( + VISIBLE_ROWS + ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } + : undefined + } + /> + )} + + ); +}; + +export default KeyModelUsageView; diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index 2539dfaec16..c70a6a0ed92 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -52,6 +52,15 @@ export interface TopApiKeyData { tokens: number; } +export interface TopModelData { + model: string; + spend: number; + requests: number; + successful_requests: number; + failed_requests: number; + tokens: number; +} + export interface ModelActivityData { label: string; total_requests: number; @@ -64,6 +73,7 @@ export interface ModelActivityData { completion_tokens: number; total_spend: number; top_api_keys: TopApiKeyData[]; + top_models: TopModelData[]; daily_data: { date: string; metrics: { diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index be12ae2a888..61006fa6147 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -5,7 +5,8 @@ import { Collapse } from "antd"; import React from "react"; import { CustomLegend, CustomTooltip } from "./common_components/chartUtils"; import { Team } from "./key_team_helpers/key_list"; -import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData } from "./UsagePage/types"; +import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView"; +import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types"; import { valueFormatter } from "./UsagePage/utils/value_formatters"; interface ActivityMetricsProps { @@ -72,8 +73,29 @@ const ModelSection = ({ )} + {metrics.top_models && metrics.top_models.length > 0 && ( + + )} + + {/* Spend per day - Full width card */} + +
+ Spend per day + +
+ `$${formatNumberWithCommas(value, 2, true)}`} + yAxisWidth={72} + /> +
+ {/* Charts */} - +
Total Tokens @@ -111,22 +133,6 @@ const ModelSection = ({ /> - -
- Spend per day - -
- `$${formatNumberWithCommas(value, 2, true)}`} - yAxisWidth={72} - /> -
-
Success vs Failed Requests @@ -377,6 +383,7 @@ export const processActivityData = ( total_cache_read_input_tokens: 0, total_cache_creation_input_tokens: 0, top_api_keys: [], + top_models: [], daily_data: [], }; } @@ -444,6 +451,45 @@ export const processActivityData = ( }); } + // Process Model breakdowns for each API key (only when key is 'api_keys') + if (key === "api_keys") { + Object.entries(modelMetrics).forEach(([apiKeyHash, _]) => { + const modelBreakdown: Record = {}; + + // Aggregate Model data for this key across all days + // We need to look in breakdown.models[model].api_key_breakdown[apiKeyHash] + dailyActivity.results.forEach((day) => { + Object.entries(day.breakdown.models || {}).forEach(([modelName, modelData]) => { + if (modelData && "api_key_breakdown" in modelData) { + const keyDataForModel = modelData.api_key_breakdown?.[apiKeyHash]; + if (keyDataForModel) { + if (!modelBreakdown[modelName]) { + modelBreakdown[modelName] = { + model: modelName, + spend: 0, + requests: 0, + successful_requests: 0, + failed_requests: 0, + tokens: 0, + }; + } + + modelBreakdown[modelName].spend += keyDataForModel.metrics.spend; + modelBreakdown[modelName].requests += keyDataForModel.metrics.api_requests; + modelBreakdown[modelName].successful_requests += keyDataForModel.metrics.successful_requests || 0; + modelBreakdown[modelName].failed_requests += keyDataForModel.metrics.failed_requests || 0; + modelBreakdown[modelName].tokens += keyDataForModel.metrics.total_tokens; + } + } + }); + }); + + // Sort by spend + modelMetrics[apiKeyHash].top_models = Object.values(modelBreakdown) + .sort((a, b) => b.spend - a.spend); + }); + } + // Sort daily data Object.values(modelMetrics).forEach((metrics) => { metrics.daily_data.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); From 19b6c446a83687272c8cfda49fd0175b00280690 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 29 Jan 2026 18:29:15 -0800 Subject: [PATCH 2/2] adding tests --- .../components/KeyModelUsageView.test.tsx | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx new file mode 100644 index 00000000000..61968294e18 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx @@ -0,0 +1,298 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import KeyModelUsageView from "./KeyModelUsageView"; +import { TopModelData } from "../types"; + +describe("KeyModelUsageView", () => { + const mockTopModels: TopModelData[] = [ + { + model: "gpt-4", + spend: 150.5, + requests: 105, + successful_requests: 100, + failed_requests: 5, + tokens: 50000, + }, + { + model: "gpt-3.5-turbo", + spend: 75.25, + requests: 200, + successful_requests: 195, + failed_requests: 5, + tokens: 100000, + }, + ]; + + it("should render", () => { + render(); + expect(screen.getByText("Model Usage")).toBeInTheDocument(); + }); + + it("should return null when topModels is empty", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("should display Model Usage title", () => { + render(); + expect(screen.getByText("Model Usage")).toBeInTheDocument(); + }); + + it("should display Table view button", () => { + render(); + expect(screen.getByRole("button", { name: "Table" })).toBeInTheDocument(); + }); + + it("should display Chart view button", () => { + render(); + expect(screen.getByRole("button", { name: "Chart" })).toBeInTheDocument(); + }); + + it("should default to table view", () => { + render(); + const tableButton = screen.getByRole("button", { name: "Table" }); + expect(tableButton).toHaveClass("bg-blue-100"); + }); + + 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 display model data in table view", () => { + render(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + expect(screen.getByText("$150.50")).toBeInTheDocument(); + expect(screen.getByText("$75.25")).toBeInTheDocument(); + }); + + it("should format spend values with two decimal places", () => { + const modelsWithDecimalSpend: TopModelData[] = [ + { + model: "test-model", + spend: 123.456, + requests: 10, + successful_requests: 10, + failed_requests: 0, + tokens: 1000, + }, + ]; + render(); + expect(screen.getByText("$123.46")).toBeInTheDocument(); + }); + + it("should format large spend values with commas", () => { + const modelsWithLargeSpend: TopModelData[] = [ + { + model: "test-model", + spend: 1234567.89, + requests: 10, + successful_requests: 10, + failed_requests: 0, + tokens: 1000, + }, + ]; + render(); + expect(screen.getByText("$1,234,567.89")).toBeInTheDocument(); + }); + + it("should display successful requests with green styling", () => { + render(); + const successfulElements = screen.getAllByText("100"); + const greenElement = successfulElements.find((el) => el.closest("span")?.classList.contains("text-green-600")); + expect(greenElement).toBeDefined(); + }); + + it("should display failed requests with red styling", () => { + render(); + const failedElements = screen.getAllByText("5"); + const redElement = failedElements.find((el) => el.closest("span")?.classList.contains("text-red-600")); + expect(redElement).toBeDefined(); + }); + + it("should format token numbers with commas", () => { + const modelsWithLargeTokens: TopModelData[] = [ + { + model: "test-model", + spend: 100, + requests: 10, + successful_requests: 10, + failed_requests: 0, + tokens: 1234567, + }, + ]; + render(); + expect(screen.getByText("1,234,567")).toBeInTheDocument(); + }); + + it("should display dash for missing model value", () => { + const modelsWithMissingModel: TopModelData[] = [ + { + model: "", + spend: 100, + requests: 10, + successful_requests: 10, + failed_requests: 0, + tokens: 1000, + }, + ]; + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should display zero values correctly", () => { + const modelsWithZeros: TopModelData[] = [ + { + model: "test-model", + spend: 0, + requests: 0, + successful_requests: 0, + failed_requests: 0, + tokens: 0, + }, + ]; + render(); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getAllByText("0").length).toBeGreaterThan(0); + }); + + it("should switch to chart view when chart button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartButton = screen.getByRole("button", { name: "Chart" }); + await user.click(chartButton); + + expect(chartButton).toHaveClass("bg-blue-100"); + const tableButton = screen.getByRole("button", { name: "Table" }); + expect(tableButton).not.toHaveClass("bg-blue-100"); + }); + + it("should switch back to table view when table button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartButton = screen.getByRole("button", { name: "Chart" }); + const tableButton = screen.getByRole("button", { name: "Table" }); + + await user.click(chartButton); + await user.click(tableButton); + + expect(tableButton).toHaveClass("bg-blue-100"); + expect(chartButton).not.toHaveClass("bg-blue-100"); + }); + + it("should display chart when chart view is selected", async () => { + const user = userEvent.setup(); + render(); + + const chartButton = screen.getByRole("button", { name: "Chart" }); + await user.click(chartButton); + + expect(screen.queryByText("Model")).not.toBeInTheDocument(); + }); + + it("should display table when table view is selected", () => { + render(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + + it("should handle multiple model entries", () => { + const manyModels: TopModelData[] = Array.from({ length: 10 }, (_, i) => ({ + model: `model-${i + 1}`, + spend: 100 + i, + requests: 50 + i, + successful_requests: 45 + i, + failed_requests: 5, + tokens: 10000 + i * 1000, + })); + + render(); + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-10")).toBeInTheDocument(); + }); + + it("should format successful requests with toLocaleString", () => { + const modelsWithLargeNumbers: TopModelData[] = [ + { + model: "test-model", + spend: 100, + requests: 1000, + successful_requests: 999999, + failed_requests: 1, + tokens: 1000, + }, + ]; + render(); + expect(screen.getByText("999,999")).toBeInTheDocument(); + }); + + it("should format failed requests with toLocaleString", () => { + const modelsWithLargeNumbers: TopModelData[] = [ + { + model: "test-model", + spend: 100, + requests: 1000, + successful_requests: 1, + failed_requests: 999999, + tokens: 1000, + }, + ]; + render(); + expect(screen.getByText("999,999")).toBeInTheDocument(); + }); + + it("should display zero for missing successful_requests", () => { + const modelsWithMissingFields: TopModelData[] = [ + { + model: "test-model", + spend: 100, + requests: 10, + successful_requests: undefined as any, + failed_requests: 0, + tokens: 1000, + }, + ]; + render(); + const zeroElements = screen.getAllByText("0"); + const successfulZero = zeroElements.find((el) => el.closest("span")?.classList.contains("text-green-600")); + expect(successfulZero).toBeDefined(); + }); + + it("should display zero for missing failed_requests", () => { + const modelsWithMissingFields: TopModelData[] = [ + { + model: "test-model", + spend: 100, + requests: 10, + successful_requests: 10, + failed_requests: undefined as any, + tokens: 1000, + }, + ]; + render(); + expect(screen.getAllByText("0").length).toBeGreaterThan(0); + }); + + it("should display zero for missing tokens", () => { + const modelsWithMissingFields: TopModelData[] = [ + { + model: "test-model", + spend: 100, + requests: 10, + successful_requests: 10, + failed_requests: 0, + tokens: undefined as any, + }, + ]; + render(); + expect(screen.getAllByText("0").length).toBeGreaterThan(0); + }); +});