mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): move the cost tracking components onto shadcn primitives
Rebuilds the provider discount and margin tables, the pricing calculator and its multi-cost results on the in-repo shadcn layer, and swaps the imperative antd modal.confirm removals for AlertDialog. Row actions gained accessible names, which replace the Tremor stub mocks the tests used to drive. cost_tracking_settings keeps its two antd Modals and Forms, since they wrap the two add forms that stay on antd for now.
This commit is contained in:
parent
423b791ee0
commit
035f8d5f8a
11 changed files with 982 additions and 702 deletions
|
|
@ -228,7 +228,7 @@
|
|||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": {
|
||||
|
|
@ -239,9 +239,6 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": {
|
||||
|
|
@ -252,9 +249,6 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": {
|
||||
|
|
@ -275,17 +269,11 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": {
|
||||
|
|
|
|||
|
|
@ -8,25 +8,29 @@ import CostTrackingSettings from "./cost_tracking_settings";
|
|||
// Mock sub-hooks so we can control their state without network calls
|
||||
const mockDiscountConfig = vi.fn(() => ({}));
|
||||
const mockMarginConfig = vi.fn(() => ({}));
|
||||
const mockRemoveDiscount = vi.fn();
|
||||
const mockRemoveMargin = vi.fn();
|
||||
|
||||
const stableDiscountCallbacks = {
|
||||
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddProvider: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveProvider: mockRemoveDiscount,
|
||||
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const stableMarginCallbacks = {
|
||||
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddMargin: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveMargin: mockRemoveMargin,
|
||||
handleMarginChange: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock("./use_discount_config", () => ({
|
||||
useDiscountConfig: () => ({
|
||||
discountConfig: mockDiscountConfig(),
|
||||
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddProvider: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveProvider: vi.fn().mockResolvedValue(undefined),
|
||||
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
useDiscountConfig: () => ({ discountConfig: mockDiscountConfig(), ...stableDiscountCallbacks }),
|
||||
}));
|
||||
|
||||
vi.mock("./use_margin_config", () => ({
|
||||
useMarginConfig: () => ({
|
||||
marginConfig: mockMarginConfig(),
|
||||
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddMargin: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveMargin: vi.fn().mockResolvedValue(undefined),
|
||||
handleMarginChange: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
useMarginConfig: () => ({ marginConfig: mockMarginConfig(), ...stableMarginCallbacks }),
|
||||
}));
|
||||
|
||||
vi.mock("./pricing_calculator/index", () => ({
|
||||
|
|
@ -153,6 +157,57 @@ describe("CostTrackingSettings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("removing a configured provider", () => {
|
||||
const expandAndRemove = async (section: string, actionName: string) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
|
||||
await user.click(screen.getByText(section).closest("button")!);
|
||||
await user.click(await screen.findByRole("button", { name: actionName }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it("should ask to confirm before removing a discount", async () => {
|
||||
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
|
||||
|
||||
await expandAndRemove("Provider Discounts", "Remove discount for openai");
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/are you sure you want to remove the discount for openai\?/i)).toBeInTheDocument();
|
||||
expect(mockRemoveDiscount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove the discount once removal is confirmed", async () => {
|
||||
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
|
||||
|
||||
const user = await expandAndRemove("Provider Discounts", "Remove discount for openai");
|
||||
await user.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(mockRemoveDiscount).toHaveBeenCalledWith("openai");
|
||||
});
|
||||
|
||||
it("should leave the discount in place when the confirmation is cancelled", async () => {
|
||||
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
|
||||
|
||||
const user = await expandAndRemove("Provider Discounts", "Remove discount for openai");
|
||||
await user.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockRemoveDiscount).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should remove the margin once removal is confirmed", async () => {
|
||||
mockMarginConfig.mockReturnValue({ openai: 0.1 });
|
||||
|
||||
const user = await expandAndRemove("Fee/Price Margin", "Remove margin for openai");
|
||||
expect(screen.getByText(/are you sure you want to remove the margin for openai\?/i)).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(mockRemoveMargin).toHaveBeenCalledWith("openai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state messages", () => {
|
||||
it("should show the empty state message when no discount config is loaded", async () => {
|
||||
mockDiscountConfig.mockReturnValue({});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionBody,
|
||||
TabGroup,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
} from "@tremor/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Modal, Form } from "antd";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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 { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { DocsMenu } from "@/components/HelpLink";
|
||||
import HowItWorks from "./how_it_works";
|
||||
import { useDiscountConfig } from "./use_discount_config";
|
||||
|
|
@ -31,6 +31,29 @@ const DOCS_LINKS = [
|
|||
{ label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" },
|
||||
];
|
||||
|
||||
const REMOVAL_COPY = {
|
||||
discount: { title: "Remove Provider Discount", noun: "discount" },
|
||||
margin: { title: "Remove Provider Margin", noun: "margin" },
|
||||
} as const;
|
||||
|
||||
interface PendingRemoval {
|
||||
kind: keyof typeof REMOVAL_COPY;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-6 py-4 text-left";
|
||||
|
||||
const SectionHeader: React.FC<{ title: string; description: string }> = ({ title, description }) => (
|
||||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<span className="block text-lg font-semibold text-gray-900">{title}</span>
|
||||
<span className="block text-sm text-gray-500 mt-1">{description}</span>
|
||||
</div>
|
||||
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, userRole, accessToken }) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
|
||||
const [newDiscount, setNewDiscount] = useState<string>("");
|
||||
|
|
@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
const [percentageValue, setPercentageValue] = useState<string>("");
|
||||
const [fixedAmountValue, setFixedAmountValue] = useState<string>("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [marginForm] = Form.useForm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin";
|
||||
|
||||
|
|
@ -104,16 +127,18 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
handleAddProvider();
|
||||
};
|
||||
|
||||
const handleRemoveProvider = async (provider: string, providerDisplayName: string) => {
|
||||
modal.confirm({
|
||||
title: "Remove Provider Discount",
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
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) => {
|
||||
setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName });
|
||||
};
|
||||
|
||||
const handleConfirmRemoval = () => {
|
||||
if (!pendingRemoval) return;
|
||||
if (pendingRemoval.kind === "discount") {
|
||||
removeProvider(pendingRemoval.provider);
|
||||
} else {
|
||||
removeMargin(pendingRemoval.provider);
|
||||
}
|
||||
setPendingRemoval(null);
|
||||
};
|
||||
|
||||
const handleAddMargin = async () => {
|
||||
|
|
@ -141,16 +166,8 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
setMarginType("percentage");
|
||||
};
|
||||
|
||||
const handleRemoveMargin = async (provider: string, providerDisplayName: string) => {
|
||||
modal.confirm({
|
||||
title: "Remove Provider Margin",
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
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) => {
|
||||
setPendingRemoval({ kind: "margin", provider, displayName: providerDisplayName });
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
|
|
@ -159,18 +176,16 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
{contextHolder}
|
||||
|
||||
{/* Header Section - Outside the card */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>Cost Tracking Settings</Title>
|
||||
<p className="text-xl font-medium text-gray-900">Cost Tracking Settings</p>
|
||||
<DocsMenu items={DOCS_LINKS} />
|
||||
</div>
|
||||
<Text className="text-gray-500 mt-1">
|
||||
<p className="text-gray-500 mt-1">
|
||||
Configure cost discounts and margins for different LLM providers. Changes are saved automatically.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full space-y-4">
|
||||
{/* Accordion 1: Provider Discounts - Only for proxy admins */}
|
||||
{isProxyAdmin && (
|
||||
<Accordion>
|
||||
<AccordionHeader className="px-6 py-4">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<Text className="text-lg font-semibold text-gray-900">Provider Discounts</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
Apply percentage-based discounts to reduce costs for specific providers
|
||||
</Text>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<TabGroup>
|
||||
<TabList className="px-6 pt-4">
|
||||
<Tab>Discounts</Tab>
|
||||
<Tab>Test It</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
|
||||
<Collapsible className="rounded-lg border">
|
||||
<SectionHeader
|
||||
title="Provider Discounts"
|
||||
description="Apply percentage-based discounts to reduce costs for specific providers"
|
||||
/>
|
||||
<CollapsibleContent className="px-0">
|
||||
<Tabs defaultValue="discounts">
|
||||
<TabsList className="mx-6 mt-4">
|
||||
<TabsTrigger value="discounts">Discounts</TabsTrigger>
|
||||
<TabsTrigger value="test-it">Test It</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="discounts">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500">Loading configuration...</p>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
</div>
|
||||
) : Object.keys(discountConfig).length > 0 ? (
|
||||
<ProviderDiscountTable
|
||||
discountConfig={discountConfig}
|
||||
onDiscountChange={handleDiscountChange}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-16 px-6 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Text className="text-gray-700 font-medium mb-2">No provider discounts configured</Text>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
Click "Add Provider Discount" to get started
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="px-6 pb-4">
|
||||
<HowItWorks />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
) : Object.keys(discountConfig).length > 0 ? (
|
||||
<ProviderDiscountTable
|
||||
discountConfig={discountConfig}
|
||||
onDiscountChange={handleDiscountChange}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-16 px-6 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-gray-700 font-medium mb-2">No provider discounts configured</p>
|
||||
<p className="text-gray-500 text-sm">Click "Add Provider Discount" to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="test-it">
|
||||
<div className="px-6 pb-4">
|
||||
<HowItWorks />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Accordion 2: Fee/Price Margin - Only for proxy admins */}
|
||||
{isProxyAdmin && (
|
||||
<Accordion>
|
||||
<AccordionHeader className="px-6 py-4">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<Text className="text-lg font-semibold text-gray-900">Fee/Price Margin</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
Add fees or margins to LLM costs for internal billing and cost recovery
|
||||
</Text>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<Collapsible className="rounded-lg border">
|
||||
<SectionHeader
|
||||
title="Fee/Price Margin"
|
||||
description="Add fees or margins to LLM costs for internal billing and cost recovery"
|
||||
/>
|
||||
<CollapsibleContent className="px-0">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsMarginModalVisible(true)}>+ Add Provider Margin</Button>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
<p className="text-gray-500">Loading configuration...</p>
|
||||
</div>
|
||||
) : Object.keys(marginConfig).length > 0 ? (
|
||||
<ProviderMarginTable
|
||||
|
|
@ -284,33 +287,49 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Text className="text-gray-700 font-medium mb-2">No provider margins configured</Text>
|
||||
<Text className="text-gray-500 text-sm">Click "Add Provider Margin" to get started</Text>
|
||||
<p className="text-gray-700 font-medium mb-2">No provider margins configured</p>
|
||||
<p className="text-gray-500 text-sm">Click "Add Provider Margin" to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Accordion 3: Pricing Calculator - Available to all roles */}
|
||||
<Accordion defaultOpen={true}>
|
||||
<AccordionHeader className="px-6 py-4">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<Text className="text-lg font-semibold text-gray-900">Pricing Calculator</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
Estimate LLM costs based on expected token usage and request volume
|
||||
</Text>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<Collapsible defaultOpen={true} className="rounded-lg border">
|
||||
<SectionHeader
|
||||
title="Pricing Calculator"
|
||||
description="Estimate LLM costs based on expected token usage and request volume"
|
||||
/>
|
||||
<CollapsibleContent className="px-0">
|
||||
<div className="p-6">
|
||||
<PricingCalculator accessToken={accessToken} models={models} />
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{pendingRemoval && (
|
||||
<AlertDialog open onOpenChange={(open) => !open && setPendingRemoval(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{REMOVAL_COPY[pendingRemoval.kind].title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "}
|
||||
{pendingRemoval.displayName}?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirmRemoval}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
|
|
@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text className="text-sm text-gray-600 mb-6">
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5%
|
||||
discount).
|
||||
</Text>
|
||||
</p>
|
||||
<Form form={form} onFinish={handleFormSubmit} layout="vertical" className="space-y-6">
|
||||
<AddProviderForm
|
||||
discountConfig={discountConfig}
|
||||
|
|
@ -362,10 +381,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text className="text-sm text-gray-600 mb-6">
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Select a provider (or "Global" for all providers) and configure the margin. You can use
|
||||
percentage-based or fixed amount.
|
||||
</Text>
|
||||
</p>
|
||||
<Form form={marginForm} layout="vertical" className="space-y-6">
|
||||
<AddMarginForm
|
||||
marginConfig={marginConfig}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ const DEFAULT_PROPS = {
|
|||
models: ["gpt-4", "gpt-3.5-turbo", "claude-3-sonnet"],
|
||||
};
|
||||
|
||||
const dataRows = (): HTMLElement[] =>
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("row")
|
||||
.filter((row) => within(row).queryAllByRole("combobox").length > 0);
|
||||
|
||||
const deleteButtonIn = (row: HTMLElement): HTMLElement => {
|
||||
const cells = within(row).getAllByRole("cell");
|
||||
return within(cells[cells.length - 1]).getByRole("button");
|
||||
};
|
||||
|
||||
describe("PricingCalculator", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -124,8 +134,31 @@ describe("PricingCalculator", () => {
|
|||
|
||||
it("should render column headers for Model, Input Tokens, and Output Tokens", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Input Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Input Tokens" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Output Tokens" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a numeric field for input tokens, output tokens and requests", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getAllByRole("spinbutton")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should offer a model picker per row", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should remove a row when its delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add another model/i }));
|
||||
const withTwoRows = dataRows();
|
||||
expect(withTwoRows).toHaveLength(2);
|
||||
|
||||
await user.click(deleteButtonIn(withTwoRows[1]));
|
||||
|
||||
expect(dataRows()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import { Table, Select, InputNumber, Button, Radio } from "antd";
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { PricingCalculatorProps, ModelEntry } from "./types";
|
||||
import MultiCostResults from "./multi_cost_results";
|
||||
import { useMultiCostEstimate } from "./use_multi_cost_estimate";
|
||||
|
|
@ -63,132 +67,115 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
|
|||
|
||||
const multiModelResult = getMultiModelResult(entries);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Model",
|
||||
dataIndex: "model",
|
||||
key: "model",
|
||||
width: "35%",
|
||||
render: (_: string, record: ModelEntry) => (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select a model"
|
||||
value={record.model || undefined}
|
||||
onChange={(value) => handleEntryChange(record.id, "model", value)}
|
||||
optionFilterProp="label"
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
options={models.map((model) => ({
|
||||
value: model,
|
||||
label: model,
|
||||
}))}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Input Tokens",
|
||||
dataIndex: "input_tokens",
|
||||
key: "input_tokens",
|
||||
width: "18%",
|
||||
render: (_: number, record: ModelEntry) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={record.input_tokens}
|
||||
onChange={(value) => handleEntryChange(record.id, "input_tokens", value ?? 0)}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Output Tokens",
|
||||
dataIndex: "output_tokens",
|
||||
key: "output_tokens",
|
||||
width: "18%",
|
||||
render: (_: number, record: ModelEntry) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={record.output_tokens}
|
||||
onChange={(value) => handleEntryChange(record.id, "output_tokens", value ?? 0)}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: `Requests/${timePeriod === "day" ? "Day" : "Month"}`,
|
||||
dataIndex: timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
|
||||
key: "num_requests",
|
||||
width: "20%",
|
||||
render: (_: number | undefined, record: ModelEntry) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={timePeriod === "day" ? record.num_requests_per_day : record.num_requests_per_month}
|
||||
onChange={(value) =>
|
||||
handleEntryChange(
|
||||
record.id,
|
||||
timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
|
||||
value ?? undefined,
|
||||
)
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
placeholder="-"
|
||||
formatter={(value) => (value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 50,
|
||||
render: (_: unknown, record: ModelEntry) => (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveEntry(record.id)}
|
||||
disabled={entries.length === 1}
|
||||
danger
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
const modelOptions = models.map((model) => ({ label: model, value: model }));
|
||||
const requestsField = timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<Radio.Group
|
||||
<RadioGroup
|
||||
value={timePeriod}
|
||||
onChange={(e) => handleTimePeriodChange(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
onValueChange={(value) => handleTimePeriodChange(value as TimePeriod)}
|
||||
className="flex w-auto items-center gap-4"
|
||||
>
|
||||
<Radio.Button value="day">Per Day</Radio.Button>
|
||||
<Radio.Button value="month">Per Month</Radio.Button>
|
||||
</Radio.Group>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="day" />
|
||||
Per Day
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="month" />
|
||||
Per Month
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
footer={() => (
|
||||
<Button type="dashed" onClick={handleAddEntry} icon={<PlusOutlined />} className="w-full">
|
||||
Add Another Model
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[35%]">Model</TableHead>
|
||||
<TableHead className="w-[18%]">Input Tokens</TableHead>
|
||||
<TableHead className="w-[18%]">Output Tokens</TableHead>
|
||||
<TableHead className="w-[20%]">Requests/{timePeriod === "day" ? "Day" : "Month"}</TableHead>
|
||||
<TableHead className="w-[50px]">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((record, index) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="whitespace-normal">
|
||||
<SearchSelect
|
||||
options={modelOptions}
|
||||
value={record.model || undefined}
|
||||
onValueChange={(value) => handleEntryChange(record.id, "model", value)}
|
||||
placeholder="Select a model"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8"
|
||||
value={record.input_tokens}
|
||||
onChange={(e) =>
|
||||
handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8"
|
||||
value={record.output_tokens}
|
||||
onChange={(e) =>
|
||||
handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8"
|
||||
placeholder="-"
|
||||
value={record[requestsField] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleEntryChange(
|
||||
record.id,
|
||||
requestsField,
|
||||
e.target.value === "" ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove model row ${index + 1}`}
|
||||
onClick={() => handleRemoveEntry(record.id)}
|
||||
disabled={entries.length === 1}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<Button variant="outline" onClick={handleAddEntry} className="w-full border-dashed">
|
||||
<Plus className="size-3.5" />
|
||||
Add Another Model
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
|
||||
<MultiCostResults multiResult={multiModelResult} timePeriod={timePeriod} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ function emptyMultiResult(): MultiModelResult {
|
|||
};
|
||||
}
|
||||
|
||||
const expandToggle = (): HTMLElement => screen.getByRole("button", { name: /cost breakdown for / });
|
||||
|
||||
const shownBreakdown = (): HTMLElement | null => {
|
||||
const label = screen.queryByText("Total/Request");
|
||||
if (label === null) return null;
|
||||
return label.closest("[style*='display: none']") === null ? label : null;
|
||||
};
|
||||
|
||||
describe("MultiCostResults", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -200,40 +208,78 @@ describe("MultiCostResults", () => {
|
|||
expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a column header for each summary column", () => {
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Per Request" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Margin Fee" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Daily" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the model breakdown before the row is expanded", () => {
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(shownBreakdown()).toBeNull();
|
||||
});
|
||||
|
||||
it("should expand the model breakdown row when the expand button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
// 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"));
|
||||
expect(expandButton).toBeDefined();
|
||||
await user.click(expandToggle());
|
||||
|
||||
await user.click(expandButton!);
|
||||
|
||||
// After expanding, the SingleModelBreakdown should be visible
|
||||
expect(screen.getByText("Total/Request")).toBeInTheDocument();
|
||||
expect(shownBreakdown()).toBeVisible();
|
||||
expect(screen.getByText("Daily Total (100 req)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the collapse icon after expanding a row", async () => {
|
||||
it("should collapse the model breakdown again on a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
const getExpandButton = () => {
|
||||
const allButtons = screen.getAllByRole("button");
|
||||
return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export"));
|
||||
};
|
||||
await user.click(expandToggle());
|
||||
expect(shownBreakdown()).toBeVisible();
|
||||
|
||||
// Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons)
|
||||
// Just verify clicking works and the breakdown content appears
|
||||
await user.click(getExpandButton()!);
|
||||
expect(screen.getByText("Total/Request")).toBeInTheDocument();
|
||||
await user.click(expandToggle());
|
||||
expect(shownBreakdown()).toBeNull();
|
||||
});
|
||||
|
||||
// After a second click, the row collapses — content may be hidden or removed
|
||||
await user.click(getExpandButton()!);
|
||||
// The expanded content should no longer be visible
|
||||
expect(screen.queryByText("Total/Request")).not.toBeVisible();
|
||||
it("should name the breakdown toggle and report its expanded state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: "Show cost breakdown for gpt-4" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
const collapseToggle = screen.getByRole("button", { name: "Hide cost breakdown for gpt-4" });
|
||||
expect(collapseToggle).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("should not offer an expand toggle for a row that failed", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults
|
||||
multiResult={makeMultiResult({
|
||||
entries: [
|
||||
{
|
||||
entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 },
|
||||
result: makeCostResponse(),
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
entry: { id: "e2", model: "bad-model", input_tokens: 0, output_tokens: 0 },
|
||||
result: null,
|
||||
loading: false,
|
||||
error: "Pricing not found",
|
||||
},
|
||||
],
|
||||
})}
|
||||
timePeriod="day"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useState } from "react";
|
||||
import { Text, Button } from "@tremor/react";
|
||||
import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd";
|
||||
import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight } 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 { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { CostEstimateResponse } from "../types";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { MultiModelResult } from "./types";
|
||||
|
|
@ -41,55 +45,57 @@ const SingleModelBreakdown: React.FC<{
|
|||
<div className="space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="small" />
|
||||
<UiLoadingSpinner className="size-3.5" />
|
||||
<span>Updating...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Total/Request</Text>
|
||||
<Text className="text-base font-semibold text-blue-600">{formatCost(result.cost_per_request)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Total/Request</p>
|
||||
<p className="text-base font-semibold text-blue-600 break-words">{formatCost(result.cost_per_request)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Input Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.input_cost_per_request)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Input Cost</p>
|
||||
<p className="text-sm break-words">{formatCost(result.input_cost_per_request)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Output Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.output_cost_per_request)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Output Cost</p>
|
||||
<p className="text-sm break-words">{formatCost(result.output_cost_per_request)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Margin Fee</Text>
|
||||
<Text className={`text-sm ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Margin Fee</p>
|
||||
<p className={`text-sm break-words ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
|
||||
{formatCost(result.margin_cost_per_request)}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{periodCost !== null && (
|
||||
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-gray-200">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">
|
||||
{periodLabel} Total ({formatRequests(periodRequests)} req)
|
||||
</Text>
|
||||
<Text className={`text-base font-semibold ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}>
|
||||
</p>
|
||||
<p
|
||||
className={`text-base font-semibold break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
|
||||
>
|
||||
{formatCost(periodCost)}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Input</Text>
|
||||
<Text className="text-sm">{formatCost(periodInputCost)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">{periodLabel} Input</p>
|
||||
<p className="text-sm break-words">{formatCost(periodInputCost)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Output</Text>
|
||||
<Text className="text-sm">{formatCost(periodOutputCost)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">{periodLabel} Output</p>
|
||||
<p className="text-sm break-words">{formatCost(periodOutputCost)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Margin Fee</Text>
|
||||
<Text className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">{periodLabel} Margin Fee</p>
|
||||
<p className={`text-sm break-words ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
|
||||
{formatCost(periodMarginCost)}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -124,7 +130,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
if (!hasAnyResult && !isAnyLoading && !hasAnyError) {
|
||||
return (
|
||||
<div className="py-6 text-center border border-dashed border-gray-300 rounded-lg bg-gray-50">
|
||||
<Text className="text-gray-500">Select models above to see cost estimates</Text>
|
||||
<p className="text-gray-500">Select models above to see cost estimates</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -133,8 +139,8 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
if (!hasAnyResult && isAnyLoading && !hasAnyError) {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<Text className="text-gray-500 block mt-2">Calculating costs...</Text>
|
||||
<UiLoadingSpinner className="inline-block size-5" />
|
||||
<p className="text-gray-500 block mt-2">Calculating costs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -143,10 +149,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
if (!hasAnyResult && hasAnyError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Divider className="my-4" />
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
|
||||
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
|
||||
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
|
||||
</div>
|
||||
{/* Error Messages */}
|
||||
{errorEntries.map((e) => (
|
||||
|
|
@ -174,102 +180,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ 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;
|
||||
},
|
||||
) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{text}</span>
|
||||
{record.provider && (
|
||||
<Tag color="blue" className="text-xs">
|
||||
{record.provider}
|
||||
</Tag>
|
||||
)}
|
||||
{record.loading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
</div>
|
||||
{record.error && <div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm">⚠️ {record.error}</div>}
|
||||
{record.hasZeroCost && !record.error && (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
|
||||
⚠️ No pricing data found for this model. Set base_model in config.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className={`font-mono text-sm ${(value ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}>
|
||||
{formatCost(value)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: periodLabel,
|
||||
dataIndex: periodCostKey,
|
||||
key: "period_cost",
|
||||
align: "right" as const,
|
||||
render: (value: number | null, record: { error?: string | null }) =>
|
||||
record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "expand",
|
||||
width: 40,
|
||||
render: (_: unknown, record: { id: string; error?: string | null }) =>
|
||||
record.error ? null : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => toggleExpanded(record.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 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,
|
||||
id: e.entry.id,
|
||||
model: e.result?.model || e.entry.model,
|
||||
provider: e.result?.provider,
|
||||
|
|
@ -284,78 +198,153 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Divider className="my-4" />
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
|
||||
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
|
||||
<MultiExportDropdown multiResult={multiResult} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Combined Totals - Always show when there are results */}
|
||||
<Card size="small" className="bg-linear-to-r from-slate-50 to-blue-50 border-slate-200">
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Statistic
|
||||
title={<span className="text-xs">Total Per Request</span>}
|
||||
value={formatCost(multiResult.totals.cost_per_request)}
|
||||
valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Statistic
|
||||
title={<span className="text-xs">Total {periodLabel}</span>}
|
||||
value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
|
||||
valueStyle={{
|
||||
color: timePeriod === "day" ? "#52c41a" : "#722ed1",
|
||||
fontSize: "18px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card size="sm" className="px-4 bg-linear-to-r from-slate-50 to-blue-50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-gray-500">Total Per Request</span>
|
||||
<div className="text-lg font-mono text-blue-600 break-words">
|
||||
{formatCost(multiResult.totals.cost_per_request)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-gray-500">Total {periodLabel}</span>
|
||||
<div
|
||||
className={`text-lg font-mono break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
|
||||
>
|
||||
{formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasMargin && (
|
||||
<Row gutter={[16, 8]} className="mt-3 pt-3 border-t border-slate-200">
|
||||
<Col xs={24} sm={12}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-3 pt-3 border-t border-slate-200">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500">Margin Fee/Request</div>
|
||||
<div className="text-sm font-mono text-amber-600">
|
||||
<div className="text-sm font-mono text-amber-600 break-words">
|
||||
{formatCost(multiResult.totals.margin_per_request)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500">{periodLabel} Margin Fee</div>
|
||||
<div className="text-sm font-mono text-amber-600">
|
||||
<div className="text-sm font-mono text-amber-600 break-words">
|
||||
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Per-Model Table */}
|
||||
{summaryData.length > 0 && (
|
||||
<Table
|
||||
columns={summaryColumns}
|
||||
dataSource={summaryData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
className="border border-gray-200 rounded-lg"
|
||||
expandable={{
|
||||
expandedRowKeys: Array.from(expandedModels),
|
||||
expandedRowRender: (record) => {
|
||||
const entry = validEntries.find((e) => e.entry.id === record.id);
|
||||
if (!entry?.result) return null;
|
||||
<Table className="border border-gray-200 rounded-lg">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Per Request</TableHead>
|
||||
<TableHead className="text-right">Margin Fee</TableHead>
|
||||
<TableHead className="text-right">{periodLabel}</TableHead>
|
||||
<TableHead className="w-10">
|
||||
<span className="sr-only">Cost breakdown</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summaryData.map((record) => {
|
||||
const isExpanded = expandedModels.has(record.id);
|
||||
const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost;
|
||||
const breakdownEntry = validEntries.find((e) => e.entry.id === record.id);
|
||||
return (
|
||||
<div className="py-2">
|
||||
<SingleModelBreakdown result={entry.result} loading={entry.loading} timePeriod={timePeriod} />
|
||||
</div>
|
||||
<React.Fragment key={record.id}>
|
||||
<TableRow>
|
||||
<TableCell className="whitespace-normal">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm break-words">{record.model}</span>
|
||||
{record.provider && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{record.provider}
|
||||
</Badge>
|
||||
)}
|
||||
{record.loading && <UiLoadingSpinner className="size-3.5" />}
|
||||
</div>
|
||||
{record.error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm">⚠️ {record.error}</div>
|
||||
)}
|
||||
{record.hasZeroCost && !record.error && (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
|
||||
⚠️ No pricing data found for this model. Set base_model in config.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(record.cost_per_request)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span
|
||||
className={`font-mono text-sm ${(record.margin_cost_per_request ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}
|
||||
>
|
||||
{formatCost(record.margin_cost_per_request)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(periodCost)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!record.error && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? "Hide" : "Show"} cost breakdown for ${record.model}`}
|
||||
onClick={() => toggleExpanded(record.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && breakdownEntry?.result && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="whitespace-normal">
|
||||
<div className="py-2">
|
||||
<SingleModelBreakdown
|
||||
result={breakdownEntry.result}
|
||||
loading={breakdownEntry.loading}
|
||||
timePeriod={timePeriod}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
},
|
||||
showExpandColumn: false,
|
||||
}}
|
||||
/>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,49 +5,21 @@ 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) => <table>{children}</table>,
|
||||
TableHead: ({ children }: any) => <thead>{children}</thead>,
|
||||
TableRow: ({ children }: any) => <tr>{children}</tr>,
|
||||
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
|
||||
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: any) => <td>{children}</td>,
|
||||
Text: ({ children }: any) => <span>{children}</span>,
|
||||
TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
),
|
||||
Icon: ({ icon: IconComponent, onClick }: any) => {
|
||||
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
|
||||
return <button onClick={onClick} aria-label={name} />;
|
||||
},
|
||||
}));
|
||||
|
||||
const DEFAULT_DISCOUNT_CONFIG = {
|
||||
openai: 0.05,
|
||||
anthropic: 0.1,
|
||||
};
|
||||
|
||||
const ROW_ACTION_NAME = {
|
||||
edit: /^Edit discount for /,
|
||||
save: /^Save discount for /,
|
||||
cancel: /^Cancel editing discount for /,
|
||||
remove: /^Remove discount for /,
|
||||
} as const;
|
||||
|
||||
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
|
||||
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
|
||||
|
||||
describe("ProviderDiscountTable", () => {
|
||||
const onDiscountChange = vi.fn();
|
||||
const onRemoveProvider = vi.fn();
|
||||
|
|
@ -75,9 +47,9 @@ describe("ProviderDiscountTable", () => {
|
|||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Discount Percentage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Discount Percentage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display provider display names in the table", () => {
|
||||
|
|
@ -91,6 +63,21 @@ describe("ProviderDiscountTable", () => {
|
|||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort rows by provider display name", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={DEFAULT_DISCOUNT_CONFIG}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
expect.stringContaining("Anthropic"),
|
||||
expect.stringContaining("OpenAI"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should display the formatted discount percentage", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
|
|
@ -102,6 +89,17 @@ describe("ProviderDiscountTable", () => {
|
|||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the provider logo alongside the display name", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "OpenAI logo" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a text input when the edit icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -112,8 +110,7 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const pencilButton = screen.getByRole("button", { name: /PencilAltIcon/i });
|
||||
await user.click(pencilButton);
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -128,11 +125,26 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.queryByText("5.0%")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should seed the edit input with the current discount as a percentage", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("5")).toHaveValue("5");
|
||||
});
|
||||
|
||||
it("should call onDiscountChange with the new value when the save icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -143,17 +155,57 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
|
||||
});
|
||||
|
||||
it("should save the edited discount when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10{Enter}");
|
||||
|
||||
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should abandon the edit when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10{Escape}");
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should restore the display view after saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -164,8 +216,8 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -180,30 +232,14 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
await user.click(rowAction("cancel"));
|
||||
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call onDiscountChange when canceling edit", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onRemoveProvider with the provider key and display name when the trash icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -214,7 +250,7 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(rowAction("remove"));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
|
||||
});
|
||||
|
|
@ -229,12 +265,42 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "150");
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should expose each row action as a button named for its provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit discount for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove discount for OpenAI" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit discount for OpenAI" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save discount for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel editing discount for OpenAI" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the empty message when no discounts are configured", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{}}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No provider discounts configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
import { TextInput, Icon, Text } from "@tremor/react";
|
||||
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { Check, SquarePen, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleTable } from "@/components/common_components/simple_table";
|
||||
import { DiscountConfig } from "./types";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
|
|
@ -79,45 +80,57 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Discount Percentage",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<TextInput
|
||||
value={editValue}
|
||||
onValueChange={setEditValue}
|
||||
onKeyDown={(e) => handleKeyDown(e, row.provider)}
|
||||
placeholder="5"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
/>
|
||||
<Icon
|
||||
icon={XIcon}
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-medium">{(row.discount * 100).toFixed(1)}%</Text>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleStartEdit(row.provider, row.discount)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (row) => {
|
||||
const { displayName } = getProviderLogoAndName(row.provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, row.provider)}
|
||||
placeholder="5"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Save discount for ${displayName}`}
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Check className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Cancel editing discount for ${displayName}`}
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{(row.discount * 100).toFixed(1)}%</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit discount for ${displayName}`}
|
||||
onClick={() => handleStartEdit(row.provider, row.discount)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: "250px",
|
||||
},
|
||||
{
|
||||
|
|
@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
|
|||
cell: (row) => {
|
||||
const { displayName } = getProviderLogoAndName(row.provider);
|
||||
return (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove discount for ${displayName}`}
|
||||
onClick={() => onRemoveProvider(row.provider, displayName)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
width: "80px",
|
||||
|
|
|
|||
|
|
@ -6,43 +6,15 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
|
|||
import ProviderMarginTable from "./provider_margin_table";
|
||||
import { Providers, providerLogoMap } from "@/components/provider_info_helpers";
|
||||
|
||||
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;
|
||||
},
|
||||
}));
|
||||
const ROW_ACTION_NAME = {
|
||||
edit: /^Edit margin for /,
|
||||
save: /^Save margin for /,
|
||||
cancel: /^Cancel editing margin for /,
|
||||
remove: /^Remove margin for /,
|
||||
} as const;
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Table: ({ children }: any) => <table>{children}</table>,
|
||||
TableHead: ({ children }: any) => <thead>{children}</thead>,
|
||||
TableRow: ({ children }: any) => <tr>{children}</tr>,
|
||||
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
|
||||
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: any) => <td>{children}</td>,
|
||||
Text: ({ children }: any) => <span>{children}</span>,
|
||||
TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
Icon: ({ icon: IconComponent, onClick }: any) => {
|
||||
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
|
||||
return <button onClick={onClick} aria-label={name} />;
|
||||
},
|
||||
}));
|
||||
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
|
||||
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
|
||||
|
||||
describe("ProviderMarginTable", () => {
|
||||
const onMarginChange = vi.fn();
|
||||
|
|
@ -71,9 +43,9 @@ describe("ProviderMarginTable", () => {
|
|||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Margin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Margin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the provider display name", () => {
|
||||
|
|
@ -122,6 +94,21 @@ describe("ProviderMarginTable", () => {
|
|||
expect(screen.getByText("Global (All Providers)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort the global row above provider rows", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1, global: 0.05 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
expect.stringContaining("Global (All Providers)"),
|
||||
expect.stringContaining("OpenAI"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should display a numeric margin as a percentage", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
|
|
@ -165,12 +152,28 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should seed the percentage input from a numeric margin and leave the fixed amount blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("10")).toHaveValue("10");
|
||||
expect(screen.getByPlaceholderText("0.001")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should call onMarginChange with a percentage value when save is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -181,17 +184,37 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const percentInput = screen.getByPlaceholderText("10");
|
||||
await user.clear(percentInput);
|
||||
await user.type(percentInput, "20");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", 0.2);
|
||||
});
|
||||
|
||||
it("should call onMarginChange with a fixed-amount-only object when the percentage is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("10"));
|
||||
await user.type(screen.getByPlaceholderText("0.001"), "0.002");
|
||||
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", { fixed_amount: 0.002 });
|
||||
});
|
||||
|
||||
it("should cancel edit mode without calling onMarginChange when X is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -202,8 +225,8 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
await user.click(rowAction("cancel"));
|
||||
|
||||
expect(onMarginChange).not.toHaveBeenCalled();
|
||||
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
|
||||
|
|
@ -219,7 +242,7 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(rowAction("remove"));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
|
||||
});
|
||||
|
|
@ -234,11 +257,50 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(rowAction("remove"));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("global", "Global");
|
||||
});
|
||||
|
||||
it("should expose each row action as a button named for its provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit margin for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove margin for OpenAI" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit margin for OpenAI" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save margin for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel editing margin for OpenAI" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should name the global row's actions after the global provider", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ global: 0.05 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit margin for Global" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove margin for Global" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the empty message when no margins are configured", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable marginConfig={{}} onMarginChange={onMarginChange} onRemoveProvider={onRemoveProvider} />,
|
||||
);
|
||||
expect(screen.getByText("No provider margins configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when both percentage and fixed amount are entered", () => {
|
||||
it("should call onMarginChange with an object containing both values", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -250,7 +312,7 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const percentInput = screen.getByPlaceholderText("10");
|
||||
await user.clear(percentInput);
|
||||
|
|
@ -259,7 +321,7 @@ describe("ProviderMarginTable", () => {
|
|||
const fixedInput = screen.getByPlaceholderText("0.001");
|
||||
await user.type(fixedInput, "0.002");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", {
|
||||
percentage: 0.05,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
import { TextInput, Icon, Text } from "@tremor/react";
|
||||
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { Check, SquarePen, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleTable } from "@/components/common_components/simple_table";
|
||||
import { MarginConfig } from "./types";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
|
|
@ -17,6 +18,9 @@ interface ProviderMarginRow {
|
|||
margin: number | { percentage?: number; fixed_amount?: number };
|
||||
}
|
||||
|
||||
const marginRowDisplayName = (provider: string): string =>
|
||||
provider === "global" ? "Global" : getProviderLogoAndName(provider).displayName;
|
||||
|
||||
const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
|
||||
marginConfig,
|
||||
onMarginChange,
|
||||
|
|
@ -119,67 +123,82 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Margin",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<TextInput
|
||||
value={editPercentage}
|
||||
onValueChange={setEditPercentage}
|
||||
placeholder="10"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<span className="text-gray-400">+</span>
|
||||
<span className="text-gray-600">$</span>
|
||||
<TextInput
|
||||
value={editFixedAmount}
|
||||
onValueChange={setEditFixedAmount}
|
||||
placeholder="0.001"
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
/>
|
||||
<Icon
|
||||
icon={XIcon}
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-medium">{formatMargin(row.margin)}</Text>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleStartEdit(row.provider, row.margin)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (row) => {
|
||||
const displayName = marginRowDisplayName(row.provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editPercentage}
|
||||
onChange={(e) => setEditPercentage(e.target.value)}
|
||||
placeholder="10"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<span className="text-gray-400">+</span>
|
||||
<span className="text-gray-600">$</span>
|
||||
<Input
|
||||
value={editFixedAmount}
|
||||
onChange={(e) => setEditFixedAmount(e.target.value)}
|
||||
placeholder="0.001"
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Save margin for ${displayName}`}
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Check className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Cancel editing margin for ${displayName}`}
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{formatMargin(row.margin)}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit margin for ${displayName}`}
|
||||
onClick={() => handleStartEdit(row.provider, row.margin)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: "350px",
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
cell: (row) => {
|
||||
const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName;
|
||||
const displayName = marginRowDisplayName(row.provider);
|
||||
return (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove margin for ${displayName}`}
|
||||
onClick={() => onRemoveProvider(row.provider, displayName)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
width: "80px",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue