diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index d7f71a5840d..042570aff0a 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1603,25 +1603,12 @@ "src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": { "local/no-complex-jsx-arrow": { "count": 2 }, - "no-restricted-imports": { - "count": 2 - } - }, - "src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx": { "no-restricted-imports": { "count": 1 } @@ -1630,9 +1617,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 1 } @@ -1648,7 +1632,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/purity": { "count": 1 @@ -1657,14 +1641,6 @@ "count": 3 } }, - "src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx": { - "local/no-complex-jsx-arrow": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { "react-hooks/refs": { "count": 1 @@ -1927,29 +1903,14 @@ "count": 1 } }, - "src/components/EntityUsageExport/EntityUsageExportModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/EntityUsageExport/ExportFormatSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/ExportSummary.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/EntityUsageExport/ExportTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/UsageExportHeader.tsx": { "no-restricted-imports": { - "count": 3 + "count": 1 } }, "src/components/EntityUsageExport/types.ts": { @@ -2234,9 +2195,6 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/add_model/AdaptiveRoutingConfig.tsx": { @@ -2721,9 +2679,6 @@ "src/components/common_components/team_multi_select.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/user_search_modal.tsx": { @@ -3090,9 +3045,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3625,7 +3577,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx index 7b6bd57e758..01a0ac7460a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Progress } from "antd"; import type { ColumnDef } from "@tanstack/react-table"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { MetricWithMetadata } from "@/components/UsagePage/types"; @@ -53,19 +53,14 @@ const EndpointUsageTable: React.FC = ({ endpointData }) 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 (
- + + 0 ? "bg-red-500" : undefined}> + + +
{record.successful_requests.toLocaleString()} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index c85a9fb71f6..1f3dd5642ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as networking from "@/components/networking"; import EntityUsage from "./EntityUsage"; @@ -500,18 +500,33 @@ describe("EntityUsage", () => { expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument(); }); - const selectedPanels = (container: HTMLElement) => - Array.from(container.querySelectorAll("div.tremor-TabPanel-root")).filter( - (panel) => panel.getAttribute("aria-selected") === "true", - ); + // An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the + // other, so treat either as "not on screen" and the assertion holds whichever one is rendering. + const isShowing = (element: HTMLElement): boolean => { + for (let node: HTMLElement | null = element; node; node = node.parentElement) { + if (node.hasAttribute("hidden")) return false; + if (node.getAttribute("aria-selected") === "false") return false; + } + return true; + }; - it.each([ + const showingCount = (marker: string): number => screen.queryAllByText(marker).filter(isShowing).length; + + const showingText = (text: string): HTMLElement => { + const [element] = screen.getAllByText(text).filter(isShowing); + expect(element).toBeDefined(); + return element; + }; + + const NON_TEAM_PANELS: [string, string][] = [ ["Cost", "Tag Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Key Activity", "metrics-source:api_keys"], ["Endpoint Activity", "Endpoint Usage Panel"], - ])("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { - const { container } = render(); + ]; + + it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -521,19 +536,23 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText(tabLabel)); }); - const selected = selectedPanels(container); - expect(selected).toHaveLength(1); - expect(selected[0].textContent).toContain(marker); + expect(showingCount(marker)).toBeGreaterThan(0); + for (const [otherLabel, otherMarker] of NON_TEAM_PANELS) { + if (otherLabel === tabLabel) continue; + expect(showingCount(otherMarker)).toBe(0); + } }); - it.each([ + const TEAM_PANELS: [string, string][] = [ ["Cost", "Team Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Agent Activity", "metrics-source:entities"], ["Key Activity", "metrics-source:api_keys"], ["Endpoint Activity", "Endpoint Usage Panel"], - ])("shows only the %s panel for the team entity type", async (tabLabel, marker) => { - const { container } = render(); + ]; + + it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => { + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -543,9 +562,11 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText(tabLabel)); }); - const selected = selectedPanels(container); - expect(selected).toHaveLength(1); - expect(selected[0].textContent).toContain(marker); + expect(showingCount(marker)).toBeGreaterThan(0); + for (const [otherLabel, otherMarker] of TEAM_PANELS) { + if (otherLabel === tabLabel) continue; + expect(showingCount(otherMarker)).toBe(0); + } }); it("should handle empty data gracefully", async () => { @@ -615,20 +636,19 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText("Model Activity")); }); - const modelActivityPanel = () => selectedPanels(container)[0] as HTMLElement; - expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups"); + expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0); act(() => { - fireEvent.click(within(modelActivityPanel()).getByText("Litellm Model Name")); + fireEvent.click(showingText("Litellm Model Name")); }); - expect(modelActivityPanel().textContent).toContain("metrics-source:models"); + expect(showingCount("metrics-source:models")).toBeGreaterThan(0); act(() => { - fireEvent.click(within(modelActivityPanel()).getByText("Public Model Name")); + fireEvent.click(showingText("Public Model Name")); }); - expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups"); + expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0); }); it("should display Top Agents title for agent entity type", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 3a22eb7fd10..2d6dbb823cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -14,23 +14,13 @@ import { MoneyCell } from "@/components/shared/table_cells"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { hasCapability, type Capability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { - Card, - Col, - DateRangePickerValue, - Grid, - Subtitle, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, - Title, -} from "@tremor/react"; -import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import type { DateRangePickerValue } from "@tremor/react"; +import { ChevronDown, ChevronRight, ExternalLink, Info, Loader2 } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Alert, Button, Tooltip } from "antd"; +import { Alert, AlertDescription } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; @@ -341,23 +331,29 @@ const EntityUsage: React.FC = ({ [], ); - const chev = "text-gray-400 text-xs"; - const expandIcon = showCostBreakdown ? : ; - const infoIcon = ; + const chev = "size-3 text-gray-400"; + const expandIcon = showCostBreakdown ? : ; const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => ( - setShowCostBreakdown(!showCostBreakdown) : undefined} > -
- {title} - {tooltip ? {infoIcon} : null} - {expandable ? expandIcon : null} -
- {value} -
+ +
+

{title}

+ {tooltip ? ( + + } /> + {tooltip} + + ) : null} + {expandable ? expandIcon : null} +
+

{value}

+
+ ); const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : []; @@ -366,18 +362,18 @@ const EntityUsage: React.FC = ({ const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"; const costPanel = ( - - - - {capitalizedEntityLabel} Spend Overview - - {summaryTiles.map(renderSummaryTile)} - - - +
+
+ + +

{capitalizedEntityLabel} Spend Overview

+
{summaryTiles.map(renderSummaryTile)}
+
+
+
{/* Daily Spend Chart */} - +
Daily Spend @@ -451,15 +447,15 @@ const EntityUsage: React.FC = ({ /> - +
{/* Entity Breakdown Section */} - - -
+
+ +
- Spend Per {capitalizedEntityLabel} - Showing Top 5 by Spend +

Spend Per {capitalizedEntityLabel}

+

Showing Top 5 by Spend

Get Started by Tracking cost per {capitalizedEntityLabel} = ({
- - +
+
= ({ ); }} /> - - +
+
entity.metrics.spend > 0)} @@ -509,61 +505,69 @@ const EntityUsage: React.FC = ({ noDataMessage={`No ${entityType} spend data`} size="compact" /> - - -
- - +
+
+ + +
{/* Top API Keys */} - - - Top Virtual Keys - - - +
+ + +

Top Virtual Keys

+ +
+
+
{/* Top Models */} - - -
- {entityType === "agent" ? "Top Agents" : modelViewTitle} - -
- -
- +
+ + +
+

+ {entityType === "agent" ? "Top Agents" : modelViewTitle} +

+ +
+ +
+
+
{showAgentBreakdown && ( - - - Top Agents Driving Spend - - - +
+ + +

Top Agents Driving Spend

+ +
+
+
)} {/* Spend by Provider */} - - -
- Provider Usage - - +
+ + +

Provider Usage

+
+
= ({ startAngle={90} endAngle={-270} /> - - +
+
= ({ noDataMessage="No provider usage data" size="compact" /> - - -
- - - +
+
+ + +
+
); const tabs: readonly { key: string; label: string; content: ReactNode }[] = [ @@ -620,80 +624,60 @@ const EntityUsage: React.FC = ({ return (
{isFetchingMore && ( - - - - Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will - update periodically as data loads. Moving off of this page will stop and reset this. To continue using - the UI in the meantime,{" "} - - open a new tab - - . - - -
- } - /> + + + + + Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will + update periodically as data loads. Moving off of this page will stop and reset this. To continue using the + UI in the meantime,{" "} + + open a new tab + + . + + + + )} {cancelled && ( - - Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) - - } - /> + + + Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) + + )} {agentIsFetchingMore && showAgentBreakdown && ( - - - - Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. - Charts will update periodically as data loads. Moving off of this page will stop and reset this. To - continue using the UI in the meantime,{" "} - - open a new tab - - . - - -
- } - /> + + + + + Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. + Charts will update periodically as data loads. Moving off of this page will stop and reset this. To + continue using the UI in the meantime,{" "} + + open a new tab + + . + + + + )} {agentCancelled && showAgentBreakdown && ( - - Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) - - } - /> + + + Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) + + )} {entityType === "team" && (
- Filter by team +

Filter by team

)} @@ -710,18 +694,20 @@ const EntityUsage: React.FC = ({ filterMode={entityType === "user" ? "single" : "multiple"} teams={teams || []} /> - - + + {tabs.map(({ key, label }) => ( - {label} + + {label} + ))} - - - {tabs.map(({ key, content }) => ( - {content} - ))} - - + + {tabs.map(({ key, content }) => ( + + {content} + + ))} +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index ac8ae0e67e1..38f055f65f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -2,10 +2,11 @@ import { DonutChart } from "@/components/shared/charts"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Card, Col, Grid, Switch, Title } from "@tremor/react"; -import { Tooltip } from "antd"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; import { ChartLoader } from "@/components/shared/chart_loader"; @@ -85,29 +86,30 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi return ( -
- Spend by Provider -
+ + Spend by Provider +
- +
- - + + } /> + Requests that failed to route to a provider
- +
-
-
- {loading ? ( - - ) : ( - - + + + + {loading ? ( + + ) : ( +
= ({ loading, isDateChangi startAngle={90} endAngle={-270} /> - - = ({ loading, isDateChangi noDataMessage="No provider usage data" size="compact" /> - - - )} +
+ )} +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx index 2770bac47d9..961b19ab27a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx @@ -10,6 +10,14 @@ describe("TopModelView", () => { mockSetTopModelsLimit.mockClear(); }); + // Which element a control library gives its label to is its own business, so drive the + // control by its visible text and judge the result by what the panel renders. + const clickControl = async (user: ReturnType, label: string) => { + await user.click(screen.getByText(label)); + }; + + const showsChart = (container: HTMLElement) => container.querySelector(".recharts-wrapper") !== null; + it("should render", () => { render(); expect(screen.getByText("Table View")).toBeInTheDocument(); @@ -17,12 +25,12 @@ describe("TopModelView", () => { it("should display table view button", () => { render(); - expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); + expect(screen.getByText("Table View")).toBeInTheDocument(); }); it("should display chart view button", () => { render(); - expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); + expect(screen.getByText("Chart View")).toBeInTheDocument(); }); it("should display all table column headers", () => { @@ -60,27 +68,32 @@ describe("TopModelView", () => { expect(screen.getByText("50,000")).toBeInTheDocument(); }); + const oneModel = [{ key: "gpt-4", spend: 150.5, successful_requests: 100, failed_requests: 5, tokens: 50000 }]; + it("should switch to chart view when chart view button is clicked", async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); - const chartViewButton = screen.getByRole("button", { name: "Chart View" }); - await user.click(chartViewButton); + expect(showsChart(container)).toBe(false); + await clickControl(user, "Chart View"); - expect(chartViewButton).toHaveClass("bg-blue-100"); + expect(showsChart(container)).toBe(true); + expect(screen.queryByText("Spend (USD)")).not.toBeInTheDocument(); }); it("should switch to table view when table view button is clicked", async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); - const chartViewButton = screen.getByRole("button", { name: "Chart View" }); - const tableViewButton = screen.getByRole("button", { name: "Table View" }); + await clickControl(user, "Chart View"); + await clickControl(user, "Table View"); - await user.click(chartViewButton); - await user.click(tableViewButton); - - expect(tableViewButton).toHaveClass("bg-blue-100"); + expect(showsChart(container)).toBe(false); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); it("renders one cyan bar per model with model names on the axis in chart view", async () => { @@ -108,7 +121,7 @@ describe("TopModelView", () => { />, ); - await user.click(screen.getByRole("button", { name: "Chart View" })); + await clickControl(user, "Chart View"); const bars = container.querySelectorAll("path.recharts-rectangle"); expect(bars).toHaveLength(2); @@ -118,19 +131,11 @@ describe("TopModelView", () => { expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0); }); - it("should call setTopModelsLimit when limit is changed via Segmented control", async () => { + it("should call setTopModelsLimit when the limit control is changed", 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); - } + await clickControl(user, "10"); expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx index 2e2880e8763..2f5b750c118 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx @@ -1,7 +1,7 @@ import { BarChart } from "@/components/shared/charts"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { Segmented } from "antd"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useState } from "react"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -19,6 +19,8 @@ interface TopModelViewProps { setTopModelsLimit: (limit: number) => void; } +export const TOP_MODEL_LIMITS = [5, 10, 25, 50]; + export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) { const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table"); @@ -58,30 +60,25 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi return ( <>
- setTopModelsLimit(value as number)} - /> -
- - -
+ setTopModelsLimit(Number(value))}> + + {TOP_MODEL_LIMITS.map((limit) => ( + + {limit} + + ))} + + + setModelViewMode(value as "chart" | "table")}> + + + Table View + + + Chart View + + +
{modelViewMode === "chart" ? (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx index 54bf4fc25ce..d971f5d0506 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx @@ -37,7 +37,10 @@ describe("UsageAIChatPanel", () => { it("should render model selector", () => { renderWithProviders(); - expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument(); + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to pick. + const prompt = "Select a model (optional, defaults to gpt-4o-mini)"; + expect(screen.queryAllByText(prompt).length + screen.queryAllByPlaceholderText(prompt).length).toBeGreaterThan(0); }); it("should render empty state message when no conversation", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx index 9d6b09353a2..21314594cb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx @@ -1,10 +1,18 @@ import React, { useEffect, useRef, useState } from "react"; -import { Button, Select, Input, Spin } from "antd"; import ReactMarkdown from "react-markdown"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "@/components/networking"; -const { TextArea } = Input; - interface ToolCallStep { tool_name: string; tool_label: string; @@ -41,7 +49,7 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
{step.status === "running" ? ( - + ) : step.status === "error" ? ( ✗ ) : ( @@ -259,18 +267,29 @@ const UsageAIChatPanel: React.FC = ({ open, onClose, acce {/* Model selector */}
-