diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/EndpointUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/EndpointUsage.test.tsx new file mode 100644 index 00000000000..69987d1481f --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/EndpointUsage.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import EndpointUsage from "./EndpointUsage"; + +vi.mock("./components/EndpointUsageBarChart", () => ({ + default: () =>
Endpoint Usage Bar Chart
, +})); + +vi.mock("./components/EndpointUsageLineChart", () => ({ + default: () =>
Endpoint Usage Line Chart
, +})); + +vi.mock("./components/EndpointUsageTable", () => ({ + default: () =>
Endpoint Usage Table
, +})); + +describe("EndpointUsage", () => { + it("should render", () => { + render(); + + expect(screen.getByText("Endpoint Usage Table")).toBeInTheDocument(); + expect(screen.getByText("Endpoint Usage Bar Chart")).toBeInTheDocument(); + expect(screen.getByText("Endpoint Usage Line Chart")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/EndpointUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/EndpointUsage.tsx new file mode 100644 index 00000000000..68959405a8f --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/EndpointUsage.tsx @@ -0,0 +1,67 @@ +import React, { useMemo } from "react"; +import EndpointUsageBarChart from "./components/EndpointUsageBarChart"; +import EndpointUsageLineChart from "./components/EndpointUsageLineChart"; +import EndpointUsageTable from "./components/EndpointUsageTable"; +import { DailyData, MetricWithMetadata } from "../../types"; + +interface EndpointUsageProps { + userSpendData?: { + results: DailyData[]; + metadata: any; + }; +} + +const EndpointUsage: React.FC = ({ userSpendData }) => { + // Aggregate endpoints data from all days + const endpointData = useMemo(() => { + const aggregatedEndpoints: Record = {}; + + if (!userSpendData?.results) { + return aggregatedEndpoints; + } + + userSpendData.results.forEach((day) => { + Object.entries(day.breakdown.endpoints || {}).forEach(([endpoint, metrics]) => { + if (!aggregatedEndpoints[endpoint]) { + aggregatedEndpoints[endpoint] = { + metrics: { + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: metrics.metadata || {}, + api_key_breakdown: {}, + }; + } + aggregatedEndpoints[endpoint].metrics.spend += metrics.metrics.spend; + aggregatedEndpoints[endpoint].metrics.prompt_tokens += metrics.metrics.prompt_tokens; + aggregatedEndpoints[endpoint].metrics.completion_tokens += metrics.metrics.completion_tokens; + aggregatedEndpoints[endpoint].metrics.total_tokens += metrics.metrics.total_tokens; + aggregatedEndpoints[endpoint].metrics.api_requests += metrics.metrics.api_requests; + aggregatedEndpoints[endpoint].metrics.successful_requests += metrics.metrics.successful_requests || 0; + aggregatedEndpoints[endpoint].metrics.failed_requests += metrics.metrics.failed_requests || 0; + aggregatedEndpoints[endpoint].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0; + aggregatedEndpoints[endpoint].metrics.cache_creation_input_tokens += + metrics.metrics.cache_creation_input_tokens || 0; + }); + }); + + return aggregatedEndpoints; + }, [userSpendData]); + + return ( +
+ + + +
+ ); +}; + +export default EndpointUsage; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx new file mode 100644 index 00000000000..96b66cbb3d2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import EndpointUsageBarChart from "./EndpointUsageBarChart"; + +vi.mock("@tremor/react", async () => { + const React = await import("react"); + + function Card({ children }: any) { + return React.createElement("div", { "data-testid": "tremor-card" }, children); + } + (Card as any).displayName = "Card"; + + function Title({ children }: any) { + return React.createElement("h2", { "data-testid": "tremor-title" }, children); + } + (Title as any).displayName = "Title"; + + function BarChart(_props: any) { + return React.createElement("div", { "data-testid": "tremor-bar-chart" }, "Bar Chart"); + } + (BarChart as any).displayName = "BarChart"; + + return { Card, Title, BarChart }; +}); + +vi.mock("@/components/common_components/chartUtils", () => ({ + CustomLegend: ({ categories }: any) => ( +
{categories.join(", ")}
+ ), + CustomTooltip: () =>
Tooltip
, +})); + +describe("EndpointUsageBarChart", () => { + it("should render", () => { + render(); + + expect(screen.getByTestId("tremor-card")).toBeInTheDocument(); + expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument(); + expect(screen.getByTestId("tremor-bar-chart")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageBarChart.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageBarChart.tsx new file mode 100644 index 00000000000..dd712fdb232 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageBarChart.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { BarChart, Card, Title } from "@tremor/react"; +import { CustomLegend, CustomTooltip } from "@/components/common_components/chartUtils"; +import { MetricWithMetadata } from "../../../types"; + +interface EndpointUsageBarChartProps { + endpointData?: Record; +} + +const EndpointUsageBarChart: React.FC = ({ endpointData }) => { + const dataToUse = endpointData || {}; + + // Transform endpoint data into chart format + const chartData = React.useMemo(() => { + return Object.entries(dataToUse).map(([endpoint, data]) => ({ + endpoint, + "metrics.successful_requests": data.metrics.successful_requests, + "metrics.failed_requests": data.metrics.failed_requests, + metrics: { + successful_requests: data.metrics.successful_requests, + failed_requests: data.metrics.failed_requests, + }, + })); + }, [dataToUse]); + + const valueFormatter = (value: number) => value.toLocaleString(); + + return ( + +
+ Success vs Failed Requests by Endpoint + +
+ +
+ ); +}; + +export default EndpointUsageBarChart; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx new file mode 100644 index 00000000000..ec12779a697 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import EndpointUsageLineChart from "./EndpointUsageLineChart"; + +vi.mock("@tremor/react", async () => { + const React = await import("react"); + + function Card({ children }: any) { + return React.createElement("div", { "data-testid": "tremor-card" }, children); + } + (Card as any).displayName = "Card"; + + function Title({ children }: any) { + return React.createElement("h2", { "data-testid": "tremor-title" }, children); + } + (Title as any).displayName = "Title"; + + function LineChart(_props: any) { + return React.createElement("div", { "data-testid": "tremor-line-chart" }, "Line Chart"); + } + (LineChart as any).displayName = "LineChart"; + + return { Card, Title, LineChart }; +}); + +vi.mock("antd", async () => { + const React = await import("react"); + + function Segmented({ options, value, onChange }: any) { + return React.createElement( + "div", + { "data-testid": "antd-segmented", "data-value": value }, + options?.map((opt: any) => + React.createElement( + "button", + { + key: opt.value, + onClick: () => onChange?.(opt.value), + "data-selected": value === opt.value, + }, + opt.label, + ), + ), + ); + } + (Segmented as any).displayName = "Segmented"; + + return { Segmented }; +}); + +describe("EndpointUsageLineChart", () => { + it("should render", () => { + render(); + + expect(screen.getByTestId("tremor-card")).toBeInTheDocument(); + expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument(); + expect(screen.getByTestId("antd-segmented")).toBeInTheDocument(); + expect(screen.getByTestId("tremor-line-chart")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageLineChart.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageLineChart.tsx new file mode 100644 index 00000000000..9c9236edaa2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageLineChart.tsx @@ -0,0 +1,116 @@ +import React, { useMemo, useState } from "react"; +import { Card, LineChart, Title } from "@tremor/react"; +import { Segmented } from "antd"; +import { DailyData } from "../../../types"; + +interface EndpointUsageLineChartProps { + dailyData?: { results: DailyData[] }; + endpointData?: Record; +} + +type TimePeriod = "7" | "30" | "90"; + + +// Transform daily data into chart format +function transformDailyDataToChart(dailyData: DailyData[], days: number): Array> { + const chartData: Array> = []; + + // Get all unique endpoint names + const endpointNames = new Set(); + dailyData.forEach((day) => { + if (day.breakdown.endpoints) { + Object.keys(day.breakdown.endpoints).forEach((name) => endpointNames.add(name)); + } + }); + + // Filter to the last N days based on time period + const filteredData = dailyData.slice(-days); + + filteredData.forEach((day) => { + const date = new Date(day.date); + const dateStr = date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + + const dataPoint: Record = { + date: dateStr, + }; + + endpointNames.forEach((endpointName) => { + const endpoint = day.breakdown.endpoints?.[endpointName]; + dataPoint[endpointName] = endpoint?.metrics.api_requests || 0; + }); + + chartData.push(dataPoint); + }); + + return chartData; +} + +export function EndpointUsageLineChart({ dailyData, endpointData }: EndpointUsageLineChartProps) { + const [timePeriod, setTimePeriod] = useState("7"); + const days = parseInt(timePeriod); + + const chartData = useMemo(() => { + if (!dailyData?.results || dailyData.results.length === 0) { + return []; + } + + return transformDailyDataToChart(dailyData.results, days); + }, [dailyData, days]); + + // Get endpoint names from chart data + const categories = useMemo(() => { + if (chartData.length === 0) return []; + const keys = Object.keys(chartData[0]).filter((key) => key !== "date"); + return keys; + }, [chartData]); + + // Tremor color palette for multiple lines + const colors = ["blue", "cyan", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "red", "orange"]; + + return ( + +
+ Endpoint Usage Trends + setTimePeriod(value as TimePeriod)} + style={{ + backgroundColor: "#f3f4f6", + }} + /> +
+ value.toLocaleString()} + showLegend={true} + showGridLines={true} + yAxisWidth={60} + connectNulls={true} + curveType="natural" + /> +
+ ); +} + +export default EndpointUsageLineChart; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx new file mode 100644 index 00000000000..793e4c6e3cf --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import EndpointUsageTable from "./EndpointUsageTable"; + +vi.mock("antd", async () => { + const React = await import("react"); + + function Table({ columns, dataSource }: any) { + return React.createElement( + "div", + { "data-testid": "antd-table" }, + columns?.map((col: any) => + React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title), + ), + dataSource?.map((row: any) => + React.createElement( + "div", + { key: row.key, "data-testid": `row-${row.key}` }, + React.createElement("div", null, row.endpoint), + ), + ), + ); + } + (Table as any).displayName = "Table"; + + function Progress({ percent }: any) { + return React.createElement("div", { "data-testid": "antd-progress", "data-percent": percent }); + } + (Progress as any).displayName = "Progress"; + + return { Table, Progress }; +}); + +vi.mock("@/utils/dataUtils", () => ({ + formatNumberWithCommas: (value: number, decimals?: number) => { + return value.toFixed(decimals || 0); + }, +})); + +describe("EndpointUsageTable", () => { + it("should render", () => { + const mockEndpointData = { + "endpoint-1": { + metrics: { + spend: 100.5, + prompt_tokens: 5000, + completion_tokens: 3000, + total_tokens: 8000, + api_requests: 100, + successful_requests: 95, + failed_requests: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: {}, + api_key_breakdown: {}, + }, + }; + + render(); + + expect(screen.getByTestId("antd-table")).toBeInTheDocument(); + expect(screen.getByTestId("column-endpoint")).toBeInTheDocument(); + expect(screen.getByTestId("column-requests")).toBeInTheDocument(); + expect(screen.getByTestId("column-api_requests")).toBeInTheDocument(); + expect(screen.getByTestId("column-successRate")).toBeInTheDocument(); + expect(screen.getByTestId("column-total_tokens")).toBeInTheDocument(); + expect(screen.getByTestId("column-spend")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx new file mode 100644 index 00000000000..f5cfb553370 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Table, Progress } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { MetricWithMetadata } from "../../../types"; + +interface EndpointUsageTableProps { + endpointData: Record; +} + +interface EndpointRow { + key: string; + endpoint: string; + successful_requests: number; + failed_requests: number; + api_requests: number; + total_tokens: number; + spend: number; + successRate: number; +} + +const EndpointUsageTable: React.FC = ({ endpointData }) => { + const calculateSuccessRate = (successful: number, total: number): number => { + if (total === 0) return 0; + return (successful / total) * 100; + }; + + const dataSource: EndpointRow[] = Object.entries(endpointData).map(([endpoint, data]) => ({ + key: endpoint, + endpoint, + successful_requests: data.metrics.successful_requests, + failed_requests: data.metrics.failed_requests, + api_requests: data.metrics.api_requests, + total_tokens: data.metrics.total_tokens, + spend: data.metrics.spend, + successRate: calculateSuccessRate(data.metrics.successful_requests, data.metrics.api_requests), + })); + + const columns: ColumnsType = [ + { + title: "Endpoint", + dataIndex: "endpoint", + key: "endpoint", + render: (text: string) => {text}, + }, + { + title: "Successful / Failed", + key: "requests", + render: (_: any, record: EndpointRow) => { + const successPercentage = + record.api_requests > 0 ? (record.successful_requests / record.api_requests) * 100 : 0; + const failurePercentage = record.api_requests > 0 ? (record.failed_requests / record.api_requests) * 100 : 0; + const totalPercentage = successPercentage + failurePercentage; + + const strokeColorConfig: Record = { + "0%": "#22c55e", + }; + if (successPercentage > 0 && successPercentage < 100) { + strokeColorConfig[`${successPercentage}%`] = "#22c55e"; + strokeColorConfig[`${successPercentage + 0.01}%`] = "#ef4444"; + } + strokeColorConfig["100%"] = failurePercentage > 0 ? "#ef4444" : "#22c55e"; + + return ( +
+
+ +
+
+ {record.successful_requests.toLocaleString()} + / + {record.failed_requests.toLocaleString()} +
+
+ ); + }, + }, + { + title: "Total Request", + dataIndex: "api_requests", + key: "api_requests", + render: (value: number) => value.toLocaleString(), + }, + { + title: "Success Rate", + dataIndex: "successRate", + key: "successRate", + render: (value: number) => { + const successRateStr = value.toFixed(2); + return ( + = 95 + ? "text-green-600 font-medium" + : value >= 80 + ? "text-yellow-600 font-medium" + : "text-red-600 font-medium" + } + > + {successRateStr}% + + ); + }, + }, + { + title: "Total Tokens", + dataIndex: "total_tokens", + key: "total_tokens", + render: (value: number) => value.toLocaleString(), + }, + { + title: "Spend", + dataIndex: "spend", + key: "spend", + render: (value: number) => `$${formatNumberWithCommas(value, 2)}`, + }, + ]; + + return ; +}; + +export default EndpointUsageTable; 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 75cf8c9292c..52e75005ccc 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -38,6 +38,8 @@ import { valueFormatterSpend } from "../../utils/value_formatters"; 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: { @@ -399,11 +401,14 @@ const EntityUsage: React.FC = ({ filterOptions={getAllTags() || undefined} /> - - Cost - {entityType === "agent" ? "Request / Token Consumption" : "Model Activity"} - Key Activity - + + + Cost + {entityType === "agent" ? "Request / Token Consumption" : "Model Activity"} + Key Activity + Endpoint Activity + + @@ -682,6 +687,9 @@ const EntityUsage: React.FC = ({ + + + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index 09415e87443..69a1bb4e305 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -70,8 +70,9 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn(), })); -vi.mock("antd", async () => { +vi.mock("antd", async (importOriginal) => { const React = await import("react"); + const actual = await importOriginal(); function Select(props: any) { const { value, onChange, options, ...rest } = props; @@ -111,7 +112,34 @@ vi.mock("antd", async () => { } (Badge as any).displayName = "AntdBadge"; - return { Select, Alert, Badge }; + function Table({ columns, dataSource, ...rest }: any) { + return React.createElement( + "div", + { ...rest, "data-testid": "antd-table" }, + columns?.map((col: any) => + React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title), + ), + dataSource?.map((row: any) => + React.createElement( + "div", + { key: row.key, "data-testid": `row-${row.key}` }, + columns?.map((col: any) => { + const value = col.render ? col.render(row[col.dataIndex], row) : row[col.dataIndex]; + return React.createElement("div", { key: col.key }, value); + }), + ), + ), + ); + } + (Table as any).displayName = "Table"; + + return { + ...actual, + Select, + Alert, + Badge, + Table, + }; }); vi.mock("@ant-design/icons", async () => { @@ -341,9 +369,11 @@ describe("NewUsage", () => { }); // Check that key metrics are displayed - expect(screen.getByText("Total Requests")).toBeInTheDocument(); + const totalRequestElements = screen.getAllByText("Total Requests"); + expect(totalRequestElements.length).toBeGreaterThan(0); expect(screen.getByText("1,500")).toBeInTheDocument(); - expect(screen.getByText("Successful Requests")).toBeInTheDocument(); + const successfulRequestLabelElements = screen.getAllByText("Successful Requests"); + expect(successfulRequestLabelElements.length).toBeGreaterThan(0); // Use getAllByText since this value appears in multiple places (metrics card + table) const successfulRequestElements = screen.getAllByText("1,450"); expect(successfulRequestElements.length).toBeGreaterThan(0); @@ -357,10 +387,14 @@ describe("NewUsage", () => { }); // Check for usage metrics cards - expect(screen.getByText("Total Requests")).toBeInTheDocument(); - expect(screen.getByText("Successful Requests")).toBeInTheDocument(); - expect(screen.getByText("Failed Requests")).toBeInTheDocument(); - expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + const totalRequestElements = screen.getAllByText("Total Requests"); + expect(totalRequestElements.length).toBeGreaterThan(0); + const successfulRequestElements = screen.getAllByText("Successful Requests"); + expect(successfulRequestElements.length).toBeGreaterThan(0); + const failedRequestElements = screen.getAllByText("Failed Requests"); + expect(failedRequestElements.length).toBeGreaterThan(0); + const totalTokensElements = screen.getAllByText("Total Tokens"); + expect(totalTokensElements.length).toBeGreaterThan(0); // Check for chart titles expect(screen.getByText("Daily Spend")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index a9c86c064ef..d891fcf604c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -53,6 +53,8 @@ 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[]; @@ -434,12 +436,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {usageView === "global" && (
- - Cost - Model Activity - Key Activity - MCP Server Activity - + + + Cost + Model Activity + Key Activity + MCP Server Activity + Endpoint Activity + +