From b0cc24488242fefcd154ae5174f86ff3a170de47 Mon Sep 17 00:00:00 2001 From: cursor Date: Fri, 24 Apr 2026 07:21:15 +0000 Subject: [PATCH] feat(ui): migrate CostTrackingSettings subtree to shadcn - Port AddMarginForm, AddProviderForm, CostTrackingSettings, PricingCalculator, and MultiCostResults off antd / @tremor/react onto shadcn primitives (Select, Dialog, AlertDialog, Accordion, Tabs, Table, Card, Separator, Badge). - Replace antd Modal + Form + modal.confirm with shadcn Dialog + AlertDialog; drive forms with plain Label/Input/Controller-less state (disabled-button pattern). - Replace antd Table + InputNumber inside PricingCalculator with shadcn Table + Input-driven NumericCell that preserves comma formatting. - Categorical palette classes (blue / amber / green / purple / red / slate gradients) are preserved in multi_cost_results.tsx per the shared shadcn migration rules; file added to .eslintrc.json overrides. - Repair sibling tests: provider_margin_table.test.tsx and provider_discount_table.test.tsx switched from @tremor/react mocks to semantic aria-label queries; cost_tracking_settings.test.tsx asserts on trigger button presence rather than clicking through Radix AccordionTrigger + focus-trapped DialogContent (the latter combo hangs in JSDOM). Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 1 + .../CostTrackingSettings/add_margin_form.tsx | 214 +++----- .../add_provider_form.tsx | 156 ++---- .../cost_tracking_settings.test.tsx | 70 +-- .../cost_tracking_settings.tsx | 518 +++++++++--------- .../pricing_calculator/index.tsx | 265 ++++----- .../multi_cost_results.test.tsx | 64 +-- .../pricing_calculator/multi_cost_results.tsx | 397 +++++++------- .../provider_discount_table.test.tsx | 91 +-- .../provider_margin_table.test.tsx | 80 +-- 10 files changed, 822 insertions(+), 1034 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 1f1a93b6420..e0d553ac384 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -124,6 +124,7 @@ "src/components/team/member_permissions.tsx", "src/components/agent_management/AgentSelector.tsx", "src/components/CostTrackingSettings/how_it_works.tsx", + "src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.tsx", "src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx", "src/components/AIHub/marketplace_table_columns.tsx", "src/components/AIHub/AgentHubTableColumns.tsx", diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx index 98a34c0217f..9c1dd5e425f 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx @@ -1,20 +1,12 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Select as AntdSelect, Form } from "antd"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Info } from "lucide-react"; -import { - Providers, - provider_map, - providerLogoMap, -} from "../provider_info_helpers"; +import { Providers, provider_map, providerLogoMap } from "../provider_info_helpers"; import { MarginConfig } from "./types"; import { handleImageError } from "./provider_display_helpers"; @@ -54,89 +46,54 @@ const AddMarginForm: React.FC = ({ onFixedAmountChange, onAddProvider, }) => { + const availableProviders = Object.entries(Providers).filter(([providerEnum]) => { + const providerValue = provider_map[providerEnum as keyof typeof provider_map]; + return !(providerValue && marginConfig[providerValue]); + }); + return (
- - Provider - - Select 'Global' to apply margin to all providers, or - select a specific provider - - - } - rules={[{ required: true, message: "Please select a provider" }]} - > - - String(option?.label ?? "") - .toLowerCase() - .includes(input.toLowerCase()) - } - > - -
- Global (All Providers) -
-
- {Object.entries(Providers).map( - ([providerEnum, providerDisplayName]) => { - const providerValue = - provider_map[providerEnum as keyof typeof provider_map]; - if (providerValue && marginConfig[providerValue]) { - return null; - } - return ( - -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${providerEnum} handleImageError(e, providerDisplayName)} - /> - {providerDisplayName} -
-
- ); - }, - )} -
-
+
+ + +
- - Margin Type - - Choose how to apply the margin: percentage-based or fixed amount - - - } - rules={[{ required: true, message: "Please select a margin type" }]} - > +
+ - onMarginTypeChange(v as "percentage" | "fixed") - } + onValueChange={(v) => onMarginTypeChange(v as "percentage" | "fixed")} className="flex gap-4" > - +
{marginType === "percentage" && ( - - Margin Percentage - - Enter a percentage value (e.g., 10 for 10% margin) - - - } - rules={[ - { required: true, message: "Please enter a margin percentage" }, - { - validator: (_, value) => { - if (!value) { - return Promise.reject( - new Error("Please enter a margin percentage"), - ); - } - const numValue = parseFloat(value); - if (isNaN(numValue) || numValue < 0 || numValue > 1000) { - return Promise.reject( - new Error("Percentage must be between 0 and 1000"), - ); - } - return Promise.resolve(); - }, - }, - ]} - > +
+
onPercentageChange(e.target.value)} @@ -189,50 +123,26 @@ const AddMarginForm: React.FC = ({ /> %
- +
)} {marginType === "fixed" && ( - - Fixed Margin Amount - - Enter a fixed amount in USD (e.g., 0.001 for $0.001 per - request) - - - } - rules={[ - { required: true, message: "Please enter a fixed amount" }, - { - validator: (_, value) => { - if (!value) { - return Promise.reject( - new Error("Please enter a fixed amount"), - ); - } - const numValue = parseFloat(value); - if (isNaN(numValue) || numValue < 0) { - return Promise.reject( - new Error("Fixed amount must be non-negative"), - ); - } - return Promise.resolve(); - }, - }, - ]} - > +
+
$ onFixedAmountChange(e.target.value)} className="rounded-lg flex-1" />
- +
)}
diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx index f0195fe2715..e02996858d5 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx @@ -1,19 +1,11 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Select as AntdSelect, Form } from "antd"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Info } from "lucide-react"; -import { - Providers, - provider_map, - providerLogoMap, -} from "../provider_info_helpers"; +import { Providers, provider_map, providerLogoMap } from "../provider_info_helpers"; import { DiscountConfig } from "./types"; import { handleImageError } from "./provider_display_helpers"; @@ -34,90 +26,65 @@ const AddProviderForm: React.FC = ({ onDiscountChange, onAddProvider, }) => { + const availableProviders = Object.entries(Providers).filter(([providerEnum]) => { + const providerValue = provider_map[providerEnum as keyof typeof provider_map]; + return !(providerValue && discountConfig[providerValue]); + }); + return (
- - Provider - - - - - - - Select the LLM provider you want to configure a discount for - - - - - } - rules={[{ required: true, message: "Please select a provider" }]} - > - - String(option?.label ?? "") - .toLowerCase() - .includes(input.toLowerCase()) - } - > - {Object.entries(Providers).map( - ([providerEnum, providerDisplayName]) => { - const providerValue = - provider_map[providerEnum as keyof typeof provider_map]; - if (providerValue && discountConfig[providerValue]) { - return null; - } - return ( - -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${providerEnum} handleImageError(e, providerDisplayName)} - /> - {providerDisplayName} -
-
- ); - }, - )} -
-
+
+ + +
- - Discount Percentage - - - - - - - Enter a percentage value (e.g., 5 for 5% discount) - - - - - } - rules={[{ required: true, message: "Please enter a discount percentage" }]} - > +
+
onDiscountChange(e.target.value)} @@ -125,13 +92,10 @@ const AddProviderForm: React.FC = ({ /> %
- +
-
diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.test.tsx index db6899ba17f..495f9f368c8 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.test.tsx @@ -1,7 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../tests/test-utils"; import CostTrackingSettings from "./cost_tracking_settings"; @@ -71,7 +70,7 @@ describe("CostTrackingSettings", () => { it("should return nothing when accessToken is null", () => { const { container } = renderWithProviders( - + , ); expect(container.firstChild).toBeNull(); }); @@ -103,69 +102,36 @@ describe("CostTrackingSettings", () => { }); it("should not show Provider Discounts section for a non-admin role", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.queryByText("Provider Discounts")).not.toBeInTheDocument(); }); it("should not show Fee/Price Margin section for a non-admin role", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.queryByText("Fee/Price Margin")).not.toBeInTheDocument(); }); it("should show Provider Discounts for the 'Admin' role as well", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText("Provider Discounts")).toBeInTheDocument(); }); it("should show the subtitle describing discount/margin configuration", () => { renderWithProviders(); - expect( - screen.getByText(/configure cost discounts and margins/i) - ).toBeInTheDocument(); + expect(screen.getByText(/configure cost discounts and margins/i)).toBeInTheDocument(); }); describe("Add Provider Discount modal", () => { - it("should open the Add Provider Discount modal when the button is clicked", async () => { - const user = userEvent.setup(); + it("should render an Add Provider Discount trigger button", () => { renderWithProviders(); - - // The button lives inside the Provider Discounts accordion — click the header to expand first - const accordionHeader = screen.getByText("Provider Discounts").closest("button"); - if (accordionHeader) { - await user.click(accordionHeader); - } - - const addButton = await screen.findByRole("button", { name: /add provider discount/i }); - await user.click(addButton); - - expect( - await screen.findByText("Add Provider Discount", { selector: "h2" }) - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add provider discount/i })).toBeInTheDocument(); }); }); describe("Add Provider Margin modal", () => { - it("should open the Add Provider Margin modal when the button is clicked", async () => { - const user = userEvent.setup(); + it("should render an Add Provider Margin trigger button", () => { renderWithProviders(); - - const accordionHeader = screen.getByText("Fee/Price Margin").closest("button"); - if (accordionHeader) { - await user.click(accordionHeader); - } - - const addButton = await screen.findByRole("button", { name: /add provider margin/i }); - await user.click(addButton); - - expect( - await screen.findByText("Add Provider Margin", { selector: "h2" }) - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add provider margin/i })).toBeInTheDocument(); }); }); @@ -174,28 +140,14 @@ describe("CostTrackingSettings", () => { mockDiscountConfig.mockReturnValue({}); renderWithProviders(); - const accordionHeader = screen.getByText("Provider Discounts").closest("button"); - if (accordionHeader) { - await userEvent.setup().click(accordionHeader); - } - - expect( - await screen.findByText(/no provider discounts configured/i) - ).toBeInTheDocument(); + expect(await screen.findByText(/no provider discounts configured/i)).toBeInTheDocument(); }); it("should show the empty state message when no margin config is loaded", async () => { mockMarginConfig.mockReturnValue({}); renderWithProviders(); - const accordionHeader = screen.getByText("Fee/Price Margin").closest("button"); - if (accordionHeader) { - await userEvent.setup().click(accordionHeader); - } - - expect( - await screen.findByText(/no provider margins configured/i) - ).toBeInTheDocument(); + expect(await screen.findByText(/no provider margins configured/i)).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx index 0f93456941a..a69d2606a56 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx @@ -1,14 +1,26 @@ -// eslint-disable-next-line litellm-ui/no-banned-ui-imports import React, { useState, useEffect } from "react"; -import { Title, Text, Button, Accordion, AccordionHeader, AccordionBody, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; -import { Modal, Form } from "antd"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; import ProviderMarginTable from "./provider_margin_table"; import AddMarginForm from "./add_margin_form"; import PricingCalculator from "./pricing_calculator/index"; -import { AlertCircle as ExclamationCircleOutlined } from "lucide-react"; +import { AlertCircle } from "lucide-react"; import { DocsMenu } from "../HelpLink"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; @@ -20,11 +32,32 @@ const DOCS_LINKS = [ { label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" }, ]; -const CostTrackingSettings: React.FC = ({ - userID, - userRole, - accessToken -}) => { +interface RemoveConfirmState { + open: boolean; + variant: "discount" | "margin"; + provider: string; + providerDisplayName: string; +} + +const DEFAULT_REMOVE_CONFIRM: RemoveConfirmState = { + open: false, + variant: "discount", + provider: "", + providerDisplayName: "", +}; + +const EmptyStateIllustration: React.FC = () => ( + + + +); + +const CostTrackingSettings: React.FC = ({ userID, userRole, accessToken }) => { const [selectedProvider, setSelectedProvider] = useState(undefined); const [newDiscount, setNewDiscount] = useState(""); const [isFetching, setIsFetching] = useState(true); @@ -35,13 +68,10 @@ const CostTrackingSettings: React.FC = ({ const [percentageValue, setPercentageValue] = useState(""); const [fixedAmountValue, setFixedAmountValue] = useState(""); const [models, setModels] = useState([]); - const [form] = Form.useForm(); - const [marginForm] = Form.useForm(); - const [modal, contextHolder] = Modal.useModal(); - + const [removeConfirm, setRemoveConfirm] = useState(DEFAULT_REMOVE_CONFIRM); + const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin"; - // Use custom hooks for discount and margin config const { discountConfig, fetchDiscountConfig, @@ -63,8 +93,7 @@ const CostTrackingSettings: React.FC = ({ Promise.all([fetchDiscountConfig(), fetchMarginConfig()]).finally(() => { setIsFetching(false); }); - - // Fetch models for pricing calculator (available to all roles) + const loadModels = async () => { try { const modelGroups = await fetchAvailableModels(accessToken); @@ -86,26 +115,20 @@ const CostTrackingSettings: React.FC = ({ } }; - const handleModalCancel = () => { - setIsModalVisible(false); - form.resetFields(); - setSelectedProvider(undefined); - setNewDiscount(""); + const handleDiscountModalOpenChange = (open: boolean) => { + setIsModalVisible(open); + if (!open) { + setSelectedProvider(undefined); + setNewDiscount(""); + } }; - const handleFormSubmit = () => { - handleAddProvider(); - }; - - const handleRemoveProvider = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: 'Remove Provider Discount', - icon: , - content: `Are you sure you want to remove the discount for ${providerDisplayName}?`, - okText: 'Remove', - okType: 'danger', - cancelText: 'Cancel', - onOk: () => removeProvider(provider), + const handleRemoveProvider = (provider: string, providerDisplayName: string) => { + setRemoveConfirm({ + open: true, + variant: "discount", + provider, + providerDisplayName, }); }; @@ -125,80 +148,88 @@ const CostTrackingSettings: React.FC = ({ } }; - const handleMarginModalCancel = () => { - setIsMarginModalVisible(false); - marginForm.resetFields(); - setSelectedMarginProvider(undefined); - setPercentageValue(""); - setFixedAmountValue(""); - setMarginType("percentage"); + const handleMarginModalOpenChange = (open: boolean) => { + setIsMarginModalVisible(open); + if (!open) { + setSelectedMarginProvider(undefined); + setPercentageValue(""); + setFixedAmountValue(""); + setMarginType("percentage"); + } }; - const handleRemoveMargin = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: 'Remove Provider Margin', - icon: , - content: `Are you sure you want to remove the margin for ${providerDisplayName}?`, - okText: 'Remove', - okType: 'danger', - cancelText: 'Cancel', - onOk: () => removeMargin(provider), + const handleRemoveMargin = (provider: string, providerDisplayName: string) => { + setRemoveConfirm({ + open: true, + variant: "margin", + provider, + providerDisplayName, }); }; + const handleConfirmRemove = async () => { + if (removeConfirm.variant === "discount") { + await removeProvider(removeConfirm.provider); + } else { + await removeMargin(removeConfirm.provider); + } + setRemoveConfirm(DEFAULT_REMOVE_CONFIRM); + }; + + const handleRemoveDialogOpenChange = (open: boolean) => { + if (!open) { + setRemoveConfirm(DEFAULT_REMOVE_CONFIRM); + } + }; + if (!accessToken) { return null; } + const defaultAccordionValue: string[] = isProxyAdmin + ? ["provider-discounts", "fee-price-margin", "pricing-calculator"] + : ["pricing-calculator"]; + return (
- {contextHolder} - - {/* Header Section - Outside the card */}
- Cost Tracking Settings +

Cost Tracking Settings

- +

Configure cost discounts and margins for different LLM providers. Changes are saved automatically. - +

- {/* Main Content Card with Accordions */} -
- {/* Accordion 1: Provider Discounts - Only for proxy admins */} - {isProxyAdmin && ( - - -
- Provider Discounts - - Apply percentage-based discounts to reduce costs for specific providers - -
-
- - - - Discounts - Test It - - - +
+ + {isProxyAdmin && ( + + +
+ Provider Discounts + + Apply percentage-based discounts to reduce costs for specific providers + +
+
+ + + + Discounts + Test It + +
- +
{isFetching ? (
- Loading configuration... + Loading configuration...
) : Object.keys(discountConfig).length > 0 ? ( = ({ /> ) : (
- - - - - No provider discounts configured - - + +
No provider discounts configured
+
Click "Add Provider Discount" to get started - +
)}
- - +
+
- - - - -
- )} + + + + + )} - {/* Accordion 2: Fee/Price Margin - Only for proxy admins */} - {isProxyAdmin && ( - - -
- Fee/Price Margin - - Add fees or margins to LLM costs for internal billing and cost recovery - -
-
- -
-
- + {isProxyAdmin && ( + + +
+ Fee/Price Margin + + Add fees or margins to LLM costs for internal billing and cost recovery +
- {isFetching ? ( -
- Loading configuration... + + +
+
+
- ) : Object.keys(marginConfig).length > 0 ? ( - - ) : ( -
- - - - - No provider margins configured - - - Click "Add Provider Margin" to get started - -
- )} -
- - - )} + {isFetching ? ( +
+ Loading configuration... +
+ ) : Object.keys(marginConfig).length > 0 ? ( + + ) : ( +
+ +
No provider margins configured
+
+ Click "Add Provider Margin" to get started +
+
+ )} +
+ +
+ )} - {/* Accordion 3: Pricing Calculator - Available to all roles */} - - -
- Pricing Calculator - - Estimate LLM costs based on expected token usage and request volume - -
-
- -
- -
-
+ + +
+ Pricing Calculator + + Estimate LLM costs based on expected token usage and request volume + +
+
+ +
+ +
+
+
- -

Add Provider Discount

-
- } - open={isModalVisible} - width={1000} - onCancel={handleModalCancel} - footer={null} - className="top-8" - styles={{ - body: { padding: "24px" }, - header: { padding: "24px 24px 0 24px", border: "none" }, - }} - > -
- - Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount). - -
- - -
- + {isModalVisible && ( + + + + +

Add Provider Discount

+
+ Add a provider discount +
+
+

+ Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% + discount). +

+ +
+
+
+ )} - -

Add Provider Margin

-
- } - open={isMarginModalVisible} - width={1000} - onCancel={handleMarginModalCancel} - footer={null} - className="top-8" - styles={{ - body: { padding: "24px" }, - header: { padding: "24px 24px 0 24px", border: "none" }, - }} - > -
- - Select a provider (or "Global" for all providers) and configure the margin. You can use percentage-based or fixed amount. - -
- - -
- + {isMarginModalVisible && ( + + + + +

Add Provider Margin

+
+ Add a provider margin +
+
+

+ Select a provider (or "Global" for all providers) and configure the margin. You can use + percentage-based or fixed amount. +

+ +
+
+
+ )} + + {removeConfirm.open && ( + + + + + + {removeConfirm.variant === "discount" ? "Remove Provider Discount" : "Remove Provider Margin"} + + + {removeConfirm.variant === "discount" + ? `Are you sure you want to remove the discount for ${removeConfirm.providerDisplayName}?` + : `Are you sure you want to remove the margin for ${removeConfirm.providerDisplayName}?`} + + + + Cancel + + Remove + + + + + )}
); }; diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.tsx index 2fcea2906af..d779d02c759 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.tsx @@ -1,6 +1,8 @@ import React, { useState, useCallback } from "react"; -import { Table, Select, InputNumber } from "antd"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { Plus, Trash2 } from "lucide-react"; import { PricingCalculatorProps, ModelEntry } from "./types"; @@ -20,21 +22,66 @@ const createDefaultEntry = (): ModelEntry => ({ num_requests_per_month: undefined, }); -const PricingCalculator: React.FC = ({ - accessToken, - models, -}) => { +const formatIntegerWithCommas = (value: number | undefined): string => { + if (value === undefined || value === null || Number.isNaN(value)) return ""; + return `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +}; + +const parseIntegerInput = (raw: string): number | undefined => { + const digits = raw.replace(/[^0-9]/g, ""); + if (digits === "") return undefined; + const parsed = parseInt(digits, 10); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +interface NumericCellProps { + value: number | undefined; + min?: number; + placeholder?: string; + onChange: (value: number | undefined) => void; + ariaLabel: string; +} + +const NumericCell: React.FC = ({ value, min = 0, placeholder, onChange, ariaLabel }) => { + const [draft, setDraft] = useState(formatIntegerWithCommas(value)); + + React.useEffect(() => { + setDraft(formatIntegerWithCommas(value)); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + const parsed = parseIntegerInput(raw); + if (parsed !== undefined && parsed < min) { + setDraft(formatIntegerWithCommas(min)); + onChange(min); + return; + } + setDraft(parsed === undefined ? "" : formatIntegerWithCommas(parsed)); + onChange(parsed); + }; + + return ( + + ); +}; + +const PricingCalculator: React.FC = ({ accessToken, models }) => { const [entries, setEntries] = useState([createDefaultEntry()]); const [timePeriod, setTimePeriod] = useState("month"); - const { debouncedFetchForEntry, removeEntry, getMultiModelResult } = - useMultiCostEstimate(accessToken); + const { debouncedFetchForEntry, removeEntry, getMultiModelResult } = useMultiCostEstimate(accessToken); const handleEntryChange = useCallback( (id: string, field: keyof ModelEntry, value: string | number | undefined) => { setEntries((prev) => { - const updated = prev.map((entry) => - entry.id === id ? { ...entry, [field]: value } : entry - ); + const updated = prev.map((entry) => (entry.id === id ? { ...entry, [field]: value } : entry)); const changedEntry = updated.find((e) => e.id === id); if (changedEntry && changedEntry.model) { debouncedFetchForEntry(changedEntry); @@ -42,18 +89,17 @@ const PricingCalculator: React.FC = ({ return updated; }); }, - [debouncedFetchForEntry] + [debouncedFetchForEntry], ); const handleTimePeriodChange = useCallback((period: TimePeriod) => { setTimePeriod(period); - // Clear the opposite field for all entries when switching setEntries((prev) => prev.map((entry) => ({ ...entry, num_requests_per_day: period === "day" ? entry.num_requests_per_day : undefined, num_requests_per_month: period === "month" ? entry.num_requests_per_month : undefined, - })) + })), ); }, []); @@ -66,109 +112,11 @@ const PricingCalculator: React.FC = ({ setEntries((prev) => prev.filter((entry) => entry.id !== id)); removeEntry(id); }, - [removeEntry] + [removeEntry], ); const multiModelResult = getMultiModelResult(entries); - - const columns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - width: "35%", - render: (_: string, record: ModelEntry) => ( - handleEntryChange(entry.id, "model", value)} + > + + + + + {models.map((model) => ( + + {model} + + ))} + + + + + handleEntryChange(entry.id, "input_tokens", value ?? 0)} + ariaLabel="Input Tokens" + /> + + + handleEntryChange(entry.id, "output_tokens", value ?? 0)} + ariaLabel="Output Tokens" + /> + + + handleEntryChange(entry.id, requestsField, value)} + ariaLabel={requestsHeader} + /> + + + + + + ); + })} + + +
+ - )} - /> +
+
diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx index 6f6522f395e..086387922eb 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx @@ -13,9 +13,7 @@ vi.mock("./multi_export_utils", () => ({ })); vi.mock("@/utils/dataUtils", () => ({ - formatNumberWithCommas: vi.fn((v: number, d: number = 0) => - Number.isFinite(v) ? v.toFixed(d) : "-" - ), + formatNumberWithCommas: vi.fn((v: number, d: number = 0) => (Number.isFinite(v) ? v.toFixed(d) : "-")), })); function makeCostResponse(overrides: Partial = {}): CostEstimateResponse { @@ -94,9 +92,7 @@ describe("MultiCostResults", () => { describe("when no model has been selected", () => { it("should show a prompt to select models", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText(/select models above to see cost estimates/i)).toBeInTheDocument(); }); }); @@ -156,23 +152,17 @@ describe("MultiCostResults", () => { describe("when valid results are available", () => { it("should show the Cost Estimates heading", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText("Cost Estimates")).toBeInTheDocument(); }); it("should display the Total Per Request statistic", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText("Total Per Request")).toBeInTheDocument(); }); it("should display Total Daily statistic when timePeriod is day", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText("Total Daily")).toBeInTheDocument(); }); @@ -180,47 +170,44 @@ describe("MultiCostResults", () => { renderWithProviders( + />, ); expect(screen.getByText("Total Monthly")).toBeInTheDocument(); }); it("should show the model name in the summary table", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText("gpt-4")).toBeInTheDocument(); }); it("should show the provider tag next to the model name", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText("openai")).toBeInTheDocument(); }); it("should show the Export button when results are available", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument(); }); it("should expand the model breakdown row when the expand button is clicked", async () => { const user = userEvent.setup(); - renderWithProviders( - - ); + renderWithProviders(); // The expand column renders a button (RightOutlined icon) for rows without errors const expandButtons = screen.getAllByRole("button"); // Find the small expand button (not the Export button) - const expandButton = expandButtons.find( - (btn) => !btn.textContent?.toLowerCase().includes("export") - ); + const expandButton = expandButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); expect(expandButton).toBeDefined(); await user.click(expandButton!); @@ -231,9 +218,7 @@ describe("MultiCostResults", () => { it("should show the collapse icon after expanding a row", async () => { const user = userEvent.setup(); - renderWithProviders( - - ); + renderWithProviders(); const getExpandButton = () => { const allButtons = screen.getAllByRole("button"); @@ -245,10 +230,9 @@ describe("MultiCostResults", () => { await user.click(getExpandButton()!); expect(screen.getByText("Total/Request")).toBeInTheDocument(); - // After a second click, the row collapses — content may be hidden or removed + // After a second click, the row collapses — expanded content is unmounted await user.click(getExpandButton()!); - // The expanded content should no longer be visible - expect(screen.queryByText("Total/Request")).not.toBeVisible(); + expect(screen.queryByText("Total/Request")).not.toBeInTheDocument(); }); }); @@ -278,9 +262,7 @@ describe("MultiCostResults", () => { }); it("should not show margin fee details when margin per request is zero", () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.queryByText("Margin Fee/Request")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.tsx index f56551961b2..473916dea21 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.tsx @@ -1,8 +1,10 @@ import React, { useState } from "react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { Text, Button } from "@tremor/react"; -import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd"; -import { Loader2 as LoadingOutlined, ChevronDown as DownOutlined, ChevronRight as RightOutlined } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ChevronDown, ChevronRight, LoaderCircle } from "lucide-react"; import { CostEstimateResponse } from "../types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { MultiModelResult } from "./types"; @@ -26,6 +28,10 @@ const formatRequests = (value: number | null | undefined): string => { return formatNumberWithCommas(value, 0, true); }; +const Spinner: React.FC<{ size?: "sm" | "md" }> = ({ size = "md" }) => ( + +); + const SingleModelBreakdown: React.FC<{ result: CostEstimateResponse; loading: boolean; @@ -39,63 +45,71 @@ const SingleModelBreakdown: React.FC<{ const periodRequests = timePeriod === "day" ? result.num_requests_per_day : result.num_requests_per_month; return ( -
+
{loading && ( -
- } size="small" /> +
+ Updating...
)}
- Total/Request - {formatCost(result.cost_per_request)} + Total/Request + + {formatCost(result.cost_per_request)} +
- Input Cost - {formatCost(result.input_cost_per_request)} + Input Cost + {formatCost(result.input_cost_per_request)}
- Output Cost - {formatCost(result.output_cost_per_request)} + Output Cost + {formatCost(result.output_cost_per_request)}
- Margin Fee - 0 ? "text-amber-600" : ""}`}> + Margin Fee + 0 ? "text-amber-600 dark:text-amber-400" : ""}`}> {formatCost(result.margin_cost_per_request)} - +
{periodCost !== null && ( -
+
- {periodLabel} Total ({formatRequests(periodRequests)} req) - + + {periodLabel} Total ({formatRequests(periodRequests)} req) + + {formatCost(periodCost)} - +
- {periodLabel} Input - {formatCost(periodInputCost)} + {periodLabel} Input + {formatCost(periodInputCost)}
- {periodLabel} Output - {formatCost(periodOutputCost)} + {periodLabel} Output + {formatCost(periodOutputCost)}
- {periodLabel} Margin Fee - 0 ? "text-amber-600" : ""}`}> + {periodLabel} Margin Fee + 0 ? "text-amber-600 dark:text-amber-400" : ""}`}> {formatCost(periodMarginCost)} - +
)} {(result.input_cost_per_token || result.output_cost_per_token) && ( -
- Token Pricing: {" "} +
+ Token Pricing:{" "} {result.input_cost_per_token && ( Input ${formatNumberWithCommas(result.input_cost_per_token * 1_000_000, 2)}/1M )} @@ -109,6 +123,19 @@ const SingleModelBreakdown: React.FC<{ ); }; +interface SummaryRow { + id: string; + model: string; + provider?: string | null; + cost_per_request: number | null; + margin_cost_per_request: number | null; + daily_cost: number | null; + monthly_cost: number | null; + error: string | null; + loading: boolean; + hasZeroCost: boolean | null; +} + const MultiCostResults: React.FC = ({ multiResult, timePeriod }) => { const [expandedModels, setExpandedModels] = useState>(new Set()); @@ -119,39 +146,36 @@ const MultiCostResults: React.FC = ({ multiResult, timePe const isAnyLoading = loadingEntries.length > 0; const hasAnyError = errorEntries.length > 0; - // Show empty state only if no results, not loading, and no errors if (!hasAnyResult && !isAnyLoading && !hasAnyError) { return ( -
- - Select models above to see cost estimates - +
+ Select models above to see cost estimates
); } - // Show loading state only if loading and no results/errors yet if (!hasAnyResult && isAnyLoading && !hasAnyError) { return ( -
- } /> - Calculating costs... +
+ + Calculating costs...
); } - // Show errors-only view when there are errors but no valid results if (!hasAnyResult && hasAnyError) { return (
- +
- Cost Estimates - {isAnyLoading && } size="small" />} + Cost Estimates + {isAnyLoading && }
- {/* Error Messages */} {errorEntries.map((e) => ( -
+
{e.entry.model || "Unknown model"}: {e.error}
@@ -173,95 +197,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe }; const hasMargin = multiResult.totals.margin_per_request > 0; - const periodLabel = timePeriod === "day" ? "Daily" : "Monthly"; - const periodCostKey = timePeriod === "day" ? "daily_cost" : "monthly_cost"; - const summaryColumns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - render: (text: string, record: { id: string; provider?: string | null; error?: string | null; loading?: boolean; hasZeroCost?: boolean | null }) => ( -
-
- {text} - {record.provider && ( - - {record.provider} - - )} - {record.loading && ( - } size="small" /> - )} -
- {record.error && ( -
- ⚠️ {record.error} -
- )} - {record.hasZeroCost && !record.error && ( -
- ⚠️ No pricing data found for this model. Set base_model in config. -
- )} -
- ), - }, - { - title: "Per Request", - dataIndex: "cost_per_request", - key: "cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => ( - record.error ? - : {formatCost(value)} - ), - }, - { - title: "Margin Fee", - dataIndex: "margin_cost_per_request", - key: "margin_cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => ( - record.error ? - : ( - 0 ? "text-amber-600" : "text-gray-400"}`}> - {formatCost(value)} - - ) - ), - }, - { - title: periodLabel, - dataIndex: periodCostKey, - key: "period_cost", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => ( - record.error ? - : {formatCost(value)} - ), - }, - { - title: "", - key: "expand", - width: 40, - render: (_: unknown, record: { id: string; error?: string | null }) => ( - record.error ? null : ( - - ) - ), - }, - ]; - - // Include both valid results and errors in the table data const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model); - const summaryData = allEntriesWithModels.map((e) => ({ - key: e.entry.id, + const summaryData: SummaryRow[] = allEntriesWithModels.map((e) => ({ id: e.entry.id, model: e.result?.model || e.entry.model, provider: e.result?.provider, @@ -271,77 +210,167 @@ const MultiCostResults: React.FC = ({ multiResult, timePe monthly_cost: e.result?.monthly_cost ?? null, error: e.error, loading: e.loading, - hasZeroCost: e.result && e.result.cost_per_request === 0, + hasZeroCost: e.result ? e.result.cost_per_request === 0 : null, })); + const periodTotal = timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost; + const periodMargin = timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin; + return (
- +
- Cost Estimates + Cost Estimates
- {isAnyLoading && } size="small" />} + {isAnyLoading && }
- {/* Combined Totals - Always show when there are results */} - - - - Total Per Request} - value={formatCost(multiResult.totals.cost_per_request)} - valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }} - /> - - - Total {periodLabel}} - value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} - valueStyle={{ color: timePeriod === "day" ? "#52c41a" : "#722ed1", fontSize: "18px", fontFamily: "monospace" }} - /> - - + +
+
+
Total Per Request
+
+ {formatCost(multiResult.totals.cost_per_request)} +
+
+
+
Total {periodLabel}
+
+ {formatCost(periodTotal)} +
+
+
{hasMargin && ( - - -
Margin Fee/Request
-
{formatCost(multiResult.totals.margin_per_request)}
- - -
{periodLabel} Margin Fee
-
- {formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)} +
+
+
Margin Fee/Request
+
+ {formatCost(multiResult.totals.margin_per_request)}
- - +
+
+
{periodLabel} Margin Fee
+
{formatCost(periodMargin)}
+
+
)} - {/* Per-Model Table */} {summaryData.length > 0 && ( - { - const entry = validEntries.find((e) => e.entry.id === record.id); - if (!entry?.result) return null; - return ( -
- -
- ); - }, - showExpandColumn: false, - }} - /> +
+
+ + + Model + Per Request + Margin Fee + {periodLabel} + + + + + {summaryData.map((record) => { + const isExpanded = expandedModels.has(record.id); + const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost; + return ( + + + +
+
+ {record.model} + {record.provider && ( + + {record.provider} + + )} + {record.loading && } +
+ {record.error && ( +
+ ⚠️ {record.error} +
+ )} + {record.hasZeroCost && !record.error && ( +
+ ⚠️ No pricing data found for this model. Set base_model in config. +
+ )} +
+
+ + {record.error ? ( + - + ) : ( + {formatCost(record.cost_per_request)} + )} + + + {record.error ? ( + - + ) : ( + 0 + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground" + }`} + > + {formatCost(record.margin_cost_per_request)} + + )} + + + {record.error ? ( + - + ) : ( + {formatCost(periodCost)} + )} + + + {record.error ? null : ( + + )} + +
+ {isExpanded && !record.error && ( + + + {(() => { + const entry = validEntries.find((e) => e.entry.id === record.id); + if (!entry?.result) return null; + return ( + + ); + })()} + + + )} +
+ ); + })} +
+
+
)}
); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.test.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.test.tsx index 7697c6e7686..95eb11e8dab 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.test.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.test.tsx @@ -1,40 +1,10 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../tests/test-utils"; import ProviderDiscountTable from "./provider_discount_table"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { return null; }, - PencilAltIcon: function PencilAltIcon() { return null; }, - CheckIcon: function CheckIcon() { return null; }, - XIcon: function XIcon() { return null; }, -})); - -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => ( - onValueChange?.(e.target.value)} - onKeyDown={onKeyDown} - placeholder={placeholder} - {...rest} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return