mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #20039 from BerriAI/litellm_ui_key_model_bd
[Feature] UI - Usage: Model Breakdown Per Key
This commit is contained in:
commit
c301339412
4 changed files with 480 additions and 18 deletions
|
|
@ -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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
expect(screen.getByText("Model Usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return null when topModels is empty", () => {
|
||||
const { container } = render(<KeyModelUsageView topModels={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should display Model Usage title", () => {
|
||||
render(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
expect(screen.getByText("Model Usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Table view button", () => {
|
||||
render(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
expect(screen.getByRole("button", { name: "Table" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Chart view button", () => {
|
||||
render(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
expect(screen.getByRole("button", { name: "Chart" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should default to table view", () => {
|
||||
render(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
const tableButton = screen.getByRole("button", { name: "Table" });
|
||||
expect(tableButton).toHaveClass("bg-blue-100");
|
||||
});
|
||||
|
||||
it("should display all table column headers", () => {
|
||||
render(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithDecimalSpend} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithLargeSpend} />);
|
||||
expect(screen.getByText("$1,234,567.89")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display successful requests with green styling", () => {
|
||||
render(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithLargeTokens} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithMissingModel} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithZeros} />);
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
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(<KeyModelUsageView topModels={manyModels} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithLargeNumbers} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithLargeNumbers} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithMissingFields} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithMissingFields} />);
|
||||
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(<KeyModelUsageView topModels={modelsWithMissingFields} />);
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TopModelData> = [
|
||||
{
|
||||
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) => <span className="text-green-600">{value?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
title: "Failed",
|
||||
dataIndex: "failed_requests",
|
||||
key: "failed_requests",
|
||||
render: (value) => <span className="text-red-600">{value?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
title: "Tokens",
|
||||
dataIndex: "tokens",
|
||||
key: "tokens",
|
||||
render: (value) => value?.toLocaleString() || 0,
|
||||
},
|
||||
];
|
||||
|
||||
const KeyModelUsageView: React.FC<KeyModelUsageViewProps> = ({ topModels }) => {
|
||||
const [viewMode, setViewMode] = useState<"chart" | "table">("table");
|
||||
|
||||
if (topModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Title>Model Usage</Title>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "table" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("chart")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "chart" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Chart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "chart" ? (
|
||||
<div className="max-h-[234px] overflow-y-auto">
|
||||
<BarChart
|
||||
style={{ height: topModels.length * 40 }}
|
||||
data={topModels.map((m) => ({ 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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={topModels}
|
||||
rowKey="model"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={
|
||||
topModels.length > VISIBLE_ROWS
|
||||
? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyModelUsageView;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{metrics.top_models && metrics.top_models.length > 0 && (
|
||||
<KeyModelUsageView topModels={metrics.top_models} />
|
||||
)}
|
||||
|
||||
{/* Spend per day - Full width card */}
|
||||
<Card className="mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Spend per day</Title>
|
||||
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.spend"]}
|
||||
colors={["green"]}
|
||||
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
|
||||
yAxisWidth={72}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Charts */}
|
||||
<Grid numItems={2} className="gap-4">
|
||||
<Grid numItems={2} className="gap-4 mt-4">
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Total Tokens</Title>
|
||||
|
|
@ -111,22 +133,6 @@ const ModelSection = ({
|
|||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Spend per day</Title>
|
||||
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
data={metrics.daily_data}
|
||||
index="date"
|
||||
categories={["metrics.spend"]}
|
||||
colors={["green"]}
|
||||
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
|
||||
yAxisWidth={72}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex justify-between items-center">
|
||||
<Title>Success vs Failed Requests</Title>
|
||||
|
|
@ -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<string, TopModelData> = {};
|
||||
|
||||
// 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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue