mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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 <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
cf557f707c
commit
b0cc244882
10 changed files with 822 additions and 1034 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<AddMarginFormProps> = ({
|
|||
onFixedAmountChange,
|
||||
onAddProvider,
|
||||
}) => {
|
||||
const availableProviders = Object.entries(Providers).filter(([providerEnum]) => {
|
||||
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
|
||||
return !(providerValue && marginConfig[providerValue]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Provider
|
||||
<InfoTip>
|
||||
Select 'Global' to apply margin to all providers, or
|
||||
select a specific provider
|
||||
</InfoTip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
>
|
||||
<AntdSelect
|
||||
showSearch
|
||||
placeholder="Select provider or 'Global'"
|
||||
value={selectedProvider}
|
||||
onChange={onProviderChange}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
<AntdSelect.Option
|
||||
key="global"
|
||||
value="global"
|
||||
label="Global (All Providers)"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">Global (All Providers)</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
{Object.entries(Providers).map(
|
||||
([providerEnum, providerDisplayName]) => {
|
||||
const providerValue =
|
||||
provider_map[providerEnum as keyof typeof provider_map];
|
||||
if (providerValue && marginConfig[providerValue]) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AntdSelect.Option
|
||||
key={providerEnum}
|
||||
value={providerEnum}
|
||||
label={providerDisplayName}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={providerLogoMap[providerDisplayName]}
|
||||
alt={`${providerEnum} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => handleImageError(e, providerDisplayName)}
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</AntdSelect>
|
||||
</Form.Item>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin-provider" className="flex items-center text-sm font-medium text-foreground">
|
||||
Provider
|
||||
<InfoTip>Select 'Global' to apply margin to all providers, or select a specific provider</InfoTip>
|
||||
</Label>
|
||||
<Select value={selectedProvider} onValueChange={(value) => onProviderChange(value || undefined)}>
|
||||
<SelectTrigger id="margin-provider" className="w-full">
|
||||
<SelectValue placeholder="Select provider or 'Global'" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">Global (All Providers)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{availableProviders.map(([providerEnum, providerDisplayName]) => (
|
||||
<SelectItem key={providerEnum} value={providerEnum}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={providerLogoMap[providerDisplayName]}
|
||||
alt={`${providerEnum} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => handleImageError(e, providerDisplayName)}
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Margin Type
|
||||
<InfoTip>
|
||||
Choose how to apply the margin: percentage-based or fixed amount
|
||||
</InfoTip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please select a margin type" }]}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center text-sm font-medium text-foreground">
|
||||
Margin Type
|
||||
<InfoTip>Choose how to apply the margin: percentage-based or fixed amount</InfoTip>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={marginType}
|
||||
onValueChange={(v) =>
|
||||
onMarginTypeChange(v as "percentage" | "fixed")
|
||||
}
|
||||
onValueChange={(v) => onMarginTypeChange(v as "percentage" | "fixed")}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
|
@ -148,40 +105,17 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
|
|||
Fixed Amount
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{marginType === "percentage" && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Margin Percentage
|
||||
<InfoTip>
|
||||
Enter a percentage value (e.g., 10 for 10% margin)
|
||||
</InfoTip>
|
||||
</span>
|
||||
}
|
||||
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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin-percentage" className="flex items-center text-sm font-medium text-foreground">
|
||||
Margin Percentage
|
||||
<InfoTip>Enter a percentage value (e.g., 10 for 10% margin)</InfoTip>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="margin-percentage"
|
||||
placeholder="10"
|
||||
value={percentageValue}
|
||||
onChange={(e) => onPercentageChange(e.target.value)}
|
||||
|
|
@ -189,50 +123,26 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
|
|||
/>
|
||||
<span className="text-muted-foreground">%</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marginType === "fixed" && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Fixed Margin Amount
|
||||
<InfoTip>
|
||||
Enter a fixed amount in USD (e.g., 0.001 for $0.001 per
|
||||
request)
|
||||
</InfoTip>
|
||||
</span>
|
||||
}
|
||||
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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="margin-fixed" className="flex items-center text-sm font-medium text-foreground">
|
||||
Fixed Margin Amount
|
||||
<InfoTip>Enter a fixed amount in USD (e.g., 0.001 for $0.001 per request)</InfoTip>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input
|
||||
id="margin-fixed"
|
||||
placeholder="0.001"
|
||||
value={fixedAmountValue}
|
||||
onChange={(e) => onFixedAmountChange(e.target.value)}
|
||||
className="rounded-lg flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
|
||||
|
|
|
|||
|
|
@ -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<AddProviderFormProps> = ({
|
|||
onDiscountChange,
|
||||
onAddProvider,
|
||||
}) => {
|
||||
const availableProviders = Object.entries(Providers).filter(([providerEnum]) => {
|
||||
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
|
||||
return !(providerValue && discountConfig[providerValue]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Provider
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-2 h-3 w-3 text-primary cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Select the LLM provider you want to configure a discount for
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
>
|
||||
<AntdSelect
|
||||
showSearch
|
||||
placeholder="Select provider"
|
||||
value={selectedProvider}
|
||||
onChange={onProviderChange}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
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 (
|
||||
<AntdSelect.Option
|
||||
key={providerEnum}
|
||||
value={providerEnum}
|
||||
label={providerDisplayName}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={providerLogoMap[providerDisplayName]}
|
||||
alt={`${providerEnum} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => handleImageError(e, providerDisplayName)}
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</AntdSelect>
|
||||
</Form.Item>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discount-provider" className="flex items-center text-sm font-medium text-foreground">
|
||||
Provider
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-2 h-3 w-3 text-primary cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Select the LLM provider you want to configure a discount for
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Select value={selectedProvider} onValueChange={(value) => onProviderChange(value || undefined)}>
|
||||
<SelectTrigger id="discount-provider" className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map(([providerEnum, providerDisplayName]) => (
|
||||
<SelectItem key={providerEnum} value={providerEnum}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={providerLogoMap[providerDisplayName]}
|
||||
alt={`${providerEnum} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => handleImageError(e, providerDisplayName)}
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-foreground flex items-center">
|
||||
Discount Percentage
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-2 h-3 w-3 text-primary cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Enter a percentage value (e.g., 5 for 5% discount)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please enter a discount percentage" }]}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discount-percentage" className="flex items-center text-sm font-medium text-foreground">
|
||||
Discount Percentage
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-2 h-3 w-3 text-primary cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">Enter a percentage value (e.g., 5 for 5% discount)</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="discount-percentage"
|
||||
placeholder="5"
|
||||
value={newDiscount}
|
||||
onChange={(e) => onDiscountChange(e.target.value)}
|
||||
|
|
@ -125,13 +92,10 @@ const AddProviderForm: React.FC<AddProviderFormProps> = ({
|
|||
/>
|
||||
<span className="text-muted-foreground">%</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border">
|
||||
<Button
|
||||
onClick={onAddProvider}
|
||||
disabled={!selectedProvider || !newDiscount}
|
||||
>
|
||||
<Button onClick={onAddProvider} disabled={!selectedProvider || !newDiscount}>
|
||||
Add Provider Discount
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<CostTrackingSettings userID="user-1" userRole="proxy_admin" accessToken={null} />
|
||||
<CostTrackingSettings userID="user-1" userRole="proxy_admin" accessToken={null} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
|
@ -103,69 +102,36 @@ describe("CostTrackingSettings", () => {
|
|||
});
|
||||
|
||||
it("should not show Provider Discounts section for a non-admin role", () => {
|
||||
renderWithProviders(
|
||||
<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />
|
||||
);
|
||||
renderWithProviders(<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />);
|
||||
expect(screen.queryByText("Provider Discounts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Fee/Price Margin section for a non-admin role", () => {
|
||||
renderWithProviders(
|
||||
<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />
|
||||
);
|
||||
renderWithProviders(<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />);
|
||||
expect(screen.queryByText("Fee/Price Margin")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Provider Discounts for the 'Admin' role as well", () => {
|
||||
renderWithProviders(
|
||||
<CostTrackingSettings userID="user-1" userRole="Admin" accessToken="test-token" />
|
||||
);
|
||||
renderWithProviders(<CostTrackingSettings userID="user-1" userRole="Admin" accessToken="test-token" />);
|
||||
expect(screen.getByText("Provider Discounts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the subtitle describing discount/margin configuration", () => {
|
||||
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
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(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
|
||||
// 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(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
|
||||
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(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
|
||||
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(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CostTrackingSettingsProps> = ({
|
||||
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 = () => (
|
||||
<svg className="mx-auto h-12 w-12 text-muted-foreground 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>
|
||||
);
|
||||
|
||||
const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, userRole, accessToken }) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
|
||||
const [newDiscount, setNewDiscount] = useState<string>("");
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
|
|
@ -35,13 +68,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({
|
|||
const [percentageValue, setPercentageValue] = useState<string>("");
|
||||
const [fixedAmountValue, setFixedAmountValue] = useState<string>("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const [marginForm] = Form.useForm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const [removeConfirm, setRemoveConfirm] = useState<RemoveConfirmState>(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<CostTrackingSettingsProps> = ({
|
|||
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<CostTrackingSettingsProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
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: <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) => {
|
||||
setRemoveConfirm({
|
||||
open: true,
|
||||
variant: "discount",
|
||||
provider,
|
||||
providerDisplayName,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -125,80 +148,88 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
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: <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) => {
|
||||
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 (
|
||||
<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>
|
||||
<h2 className="text-2xl font-semibold m-0">Cost Tracking Settings</h2>
|
||||
<DocsMenu items={DOCS_LINKS} />
|
||||
</div>
|
||||
<Text className="text-gray-500 mt-1">
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Configure cost discounts and margins for different LLM providers. Changes are saved automatically.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Card with Accordions */}
|
||||
<div className="bg-white rounded-lg shadow 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="bg-card rounded-lg shadow w-full max-w-full">
|
||||
<Accordion type="multiple" defaultValue={defaultAccordionValue}>
|
||||
{isProxyAdmin && (
|
||||
<AccordionItem value="provider-discounts" className="border-b">
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<span className="text-lg font-semibold text-foreground">Provider Discounts</span>
|
||||
<span className="text-sm text-muted-foreground mt-1">
|
||||
Apply percentage-based discounts to reduce costs for specific providers
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-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>
|
||||
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
<span className="text-muted-foreground">Loading configuration...</span>
|
||||
</div>
|
||||
) : Object.keys(discountConfig).length > 0 ? (
|
||||
<ProviderDiscountTable
|
||||
|
|
@ -208,197 +239,166 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({
|
|||
/>
|
||||
) : (
|
||||
<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">
|
||||
<EmptyStateIllustration />
|
||||
<div className="text-foreground font-medium mb-2">No provider discounts configured</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Click "Add Provider Discount" to get started
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
</TabsContent>
|
||||
<TabsContent value="test-it">
|
||||
<div className="px-6 pb-4">
|
||||
<HowItWorks />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button
|
||||
onClick={() => setIsMarginModalVisible(true)}
|
||||
>
|
||||
+ Add Provider Margin
|
||||
</Button>
|
||||
{isProxyAdmin && (
|
||||
<AccordionItem value="fee-price-margin" className="border-b">
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<span className="text-lg font-semibold text-foreground">Fee/Price Margin</span>
|
||||
<span className="text-sm text-muted-foreground mt-1">
|
||||
Add fees or margins to LLM costs for internal billing and cost recovery
|
||||
</span>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-0">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsMarginModalVisible(true)}>+ Add Provider Margin</Button>
|
||||
</div>
|
||||
) : Object.keys(marginConfig).length > 0 ? (
|
||||
<ProviderMarginTable
|
||||
marginConfig={marginConfig}
|
||||
onMarginChange={handleMarginChange}
|
||||
onRemoveProvider={handleRemoveMargin}
|
||||
/>
|
||||
) : (
|
||||
<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 margins configured
|
||||
</Text>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
Click "Add Provider Margin" to get started
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
)}
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<span className="text-muted-foreground">Loading configuration...</span>
|
||||
</div>
|
||||
) : Object.keys(marginConfig).length > 0 ? (
|
||||
<ProviderMarginTable
|
||||
marginConfig={marginConfig}
|
||||
onMarginChange={handleMarginChange}
|
||||
onRemoveProvider={handleRemoveMargin}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-16 px-6 text-center">
|
||||
<EmptyStateIllustration />
|
||||
<div className="text-foreground font-medium mb-2">No provider margins configured</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Click "Add Provider Margin" to get started
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="p-6">
|
||||
<PricingCalculator
|
||||
accessToken={accessToken}
|
||||
models={models}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
<AccordionItem value="pricing-calculator" className="border-b-0">
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<span className="text-lg font-semibold text-foreground">Pricing Calculator</span>
|
||||
<span className="text-sm text-muted-foreground mt-1">
|
||||
Estimate LLM costs based on expected token usage and request volume
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-0">
|
||||
<div className="p-6">
|
||||
<PricingCalculator accessToken={accessToken} models={models} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add Provider Discount</h2>
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
width={1000}
|
||||
onCancel={handleModalCancel}
|
||||
footer={null}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text 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>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleFormSubmit}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
>
|
||||
<AddProviderForm
|
||||
discountConfig={discountConfig}
|
||||
selectedProvider={selectedProvider}
|
||||
newDiscount={newDiscount}
|
||||
onProviderChange={setSelectedProvider}
|
||||
onDiscountChange={setNewDiscount}
|
||||
onAddProvider={handleAddProvider}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
{isModalVisible && (
|
||||
<Dialog open onOpenChange={handleDiscountModalOpenChange}>
|
||||
<DialogContent className="max-w-[1000px] top-8 translate-y-0">
|
||||
<DialogHeader className="pb-4 border-b border-border">
|
||||
<DialogTitle asChild>
|
||||
<h2 className="text-xl font-semibold text-foreground">Add Provider Discount</h2>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">Add a provider discount</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5%
|
||||
discount).
|
||||
</p>
|
||||
<AddProviderForm
|
||||
discountConfig={discountConfig}
|
||||
selectedProvider={selectedProvider}
|
||||
newDiscount={newDiscount}
|
||||
onProviderChange={setSelectedProvider}
|
||||
onDiscountChange={setNewDiscount}
|
||||
onAddProvider={handleAddProvider}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add Provider Margin</h2>
|
||||
</div>
|
||||
}
|
||||
open={isMarginModalVisible}
|
||||
width={1000}
|
||||
onCancel={handleMarginModalCancel}
|
||||
footer={null}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text 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>
|
||||
<Form
|
||||
form={marginForm}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
>
|
||||
<AddMarginForm
|
||||
marginConfig={marginConfig}
|
||||
selectedProvider={selectedMarginProvider}
|
||||
marginType={marginType}
|
||||
percentageValue={percentageValue}
|
||||
fixedAmountValue={fixedAmountValue}
|
||||
onProviderChange={setSelectedMarginProvider}
|
||||
onMarginTypeChange={setMarginType}
|
||||
onPercentageChange={setPercentageValue}
|
||||
onFixedAmountChange={setFixedAmountValue}
|
||||
onAddProvider={handleAddMargin}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
{isMarginModalVisible && (
|
||||
<Dialog open onOpenChange={handleMarginModalOpenChange}>
|
||||
<DialogContent className="max-w-[1000px] top-8 translate-y-0">
|
||||
<DialogHeader className="pb-4 border-b border-border">
|
||||
<DialogTitle asChild>
|
||||
<h2 className="text-xl font-semibold text-foreground">Add Provider Margin</h2>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">Add a provider margin</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Select a provider (or "Global" for all providers) and configure the margin. You can use
|
||||
percentage-based or fixed amount.
|
||||
</p>
|
||||
<AddMarginForm
|
||||
marginConfig={marginConfig}
|
||||
selectedProvider={selectedMarginProvider}
|
||||
marginType={marginType}
|
||||
percentageValue={percentageValue}
|
||||
fixedAmountValue={fixedAmountValue}
|
||||
onProviderChange={setSelectedMarginProvider}
|
||||
onMarginTypeChange={setMarginType}
|
||||
onPercentageChange={setPercentageValue}
|
||||
onFixedAmountChange={setFixedAmountValue}
|
||||
onAddProvider={handleAddMargin}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{removeConfirm.open && (
|
||||
<AlertDialog open onOpenChange={handleRemoveDialogOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
{removeConfirm.variant === "discount" ? "Remove Provider Discount" : "Remove Provider Margin"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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}?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmRemove}
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<PricingCalculatorProps> = ({
|
||||
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<NumericCellProps> = ({ value, min = 0, placeholder, onChange, ariaLabel }) => {
|
||||
const [draft, setDraft] = useState<string>(formatIntegerWithCommas(value));
|
||||
|
||||
React.useEffect(() => {
|
||||
setDraft(formatIntegerWithCommas(value));
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Input
|
||||
aria-label={ariaLabel}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
inputMode="numeric"
|
||||
onChange={handleChange}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, models }) => {
|
||||
const [entries, setEntries] = useState<ModelEntry[]>([createDefaultEntry()]);
|
||||
const [timePeriod, setTimePeriod] = useState<TimePeriod>("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<PricingCalculatorProps> = ({
|
|||
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<PricingCalculatorProps> = ({
|
|||
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) => (
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleRemoveEntry(record.id)}
|
||||
disabled={entries.length === 1}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
const requestsHeader = `Requests/${timePeriod === "day" ? "Day" : "Month"}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -201,23 +149,86 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
footer={() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddEntry}
|
||||
className="w-full border-dashed"
|
||||
>
|
||||
<div className="rounded-md border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[35%] h-10">Model</TableHead>
|
||||
<TableHead className="w-[18%] h-10">Input Tokens</TableHead>
|
||||
<TableHead className="w-[18%] h-10">Output Tokens</TableHead>
|
||||
<TableHead className="w-[20%] h-10">{requestsHeader}</TableHead>
|
||||
<TableHead className="w-[50px] h-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => {
|
||||
const requestsValue = timePeriod === "day" ? entry.num_requests_per_day : entry.num_requests_per_month;
|
||||
const requestsField = timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month";
|
||||
return (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="py-2 align-middle">
|
||||
<Select
|
||||
value={entry.model || undefined}
|
||||
onValueChange={(value) => handleEntryChange(entry.id, "model", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 align-middle">
|
||||
<NumericCell
|
||||
value={entry.input_tokens}
|
||||
onChange={(value) => handleEntryChange(entry.id, "input_tokens", value ?? 0)}
|
||||
ariaLabel="Input Tokens"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 align-middle">
|
||||
<NumericCell
|
||||
value={entry.output_tokens}
|
||||
onChange={(value) => handleEntryChange(entry.id, "output_tokens", value ?? 0)}
|
||||
ariaLabel="Output Tokens"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 align-middle">
|
||||
<NumericCell
|
||||
value={requestsValue}
|
||||
placeholder="-"
|
||||
onChange={(value) => handleEntryChange(entry.id, requestsField, value)}
|
||||
ariaLabel={requestsHeader}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 align-middle">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleRemoveEntry(entry.id)}
|
||||
disabled={entries.length === 1}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="border-t border-border p-2">
|
||||
<Button variant="outline" onClick={handleAddEntry} className="w-full border-dashed">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Another Model
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MultiCostResults multiResult={multiModelResult} timePeriod={timePeriod} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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> = {}): CostEstimateResponse {
|
||||
|
|
@ -94,9 +92,7 @@ describe("MultiCostResults", () => {
|
|||
|
||||
describe("when no model has been selected", () => {
|
||||
it("should show a prompt to select models", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={emptyMultiResult()} timePeriod="month" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={emptyMultiResult()} timePeriod="month" />);
|
||||
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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(screen.getByText("Cost Estimates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the Total Per Request statistic", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(screen.getByText("Total Per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Total Daily statistic when timePeriod is day", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(screen.getByText("Total Daily")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -180,47 +170,44 @@ describe("MultiCostResults", () => {
|
|||
renderWithProviders(
|
||||
<MultiCostResults
|
||||
multiResult={makeMultiResult({
|
||||
totals: { cost_per_request: 0.05, daily_cost: null, monthly_cost: 150.0, margin_per_request: 0, daily_margin: null, monthly_margin: null },
|
||||
totals: {
|
||||
cost_per_request: 0.05,
|
||||
daily_cost: null,
|
||||
monthly_cost: 150.0,
|
||||
margin_per_request: 0,
|
||||
daily_margin: null,
|
||||
monthly_margin: null,
|
||||
},
|
||||
})}
|
||||
timePeriod="month"
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Total Monthly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the model name in the summary table", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the provider tag next to the model name", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(screen.getByText("openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the Export button when results are available", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
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")
|
||||
);
|
||||
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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(screen.queryByText("Margin Fee/Request")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }) => (
|
||||
<LoaderCircle className={`${size === "sm" ? "h-3.5 w-3.5" : "h-5 w-5"} animate-spin text-muted-foreground`} />
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||
<div className="space-y-3 bg-muted p-4 rounded-lg">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Spin indicator={<LoadingOutlined className="animate-spin" />} size="small" />
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Spinner size="sm" />
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground block">Total/Request</span>
|
||||
<span className="text-base font-semibold text-blue-600 dark:text-blue-400">
|
||||
{formatCost(result.cost_per_request)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Input Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.input_cost_per_request)}</Text>
|
||||
<span className="text-xs text-muted-foreground block">Input Cost</span>
|
||||
<span className="text-sm">{formatCost(result.input_cost_per_request)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Output Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.output_cost_per_request)}</Text>
|
||||
<span className="text-xs text-muted-foreground block">Output Cost</span>
|
||||
<span className="text-sm">{formatCost(result.output_cost_per_request)}</span>
|
||||
</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" : ""}`}>
|
||||
<span className="text-xs text-muted-foreground block">Margin Fee</span>
|
||||
<span className={`text-sm ${result.margin_cost_per_request > 0 ? "text-amber-600 dark:text-amber-400" : ""}`}>
|
||||
{formatCost(result.margin_cost_per_request)}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{periodCost !== null && (
|
||||
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-gray-200">
|
||||
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-border">
|
||||
<div>
|
||||
<Text 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"}`}>
|
||||
<span className="text-xs text-muted-foreground block">
|
||||
{periodLabel} Total ({formatRequests(periodRequests)} req)
|
||||
</span>
|
||||
<span
|
||||
className={`text-base font-semibold ${
|
||||
timePeriod === "day" ? "text-green-600 dark:text-green-400" : "text-purple-600 dark:text-purple-400"
|
||||
}`}
|
||||
>
|
||||
{formatCost(periodCost)}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Input</Text>
|
||||
<Text className="text-sm">{formatCost(periodInputCost)}</Text>
|
||||
<span className="text-xs text-muted-foreground block">{periodLabel} Input</span>
|
||||
<span className="text-sm">{formatCost(periodInputCost)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Output</Text>
|
||||
<Text className="text-sm">{formatCost(periodOutputCost)}</Text>
|
||||
<span className="text-xs text-muted-foreground block">{periodLabel} Output</span>
|
||||
<span className="text-sm">{formatCost(periodOutputCost)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Margin Fee</Text>
|
||||
<Text className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
|
||||
<span className="text-xs text-muted-foreground block">{periodLabel} Margin Fee</span>
|
||||
<span className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600 dark:text-amber-400" : ""}`}>
|
||||
{formatCost(periodMarginCost)}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result.input_cost_per_token || result.output_cost_per_token) && (
|
||||
<div className="text-xs text-gray-400 pt-2 border-t border-gray-200">
|
||||
Token Pricing: {" "}
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
Token Pricing:{" "}
|
||||
{result.input_cost_per_token && (
|
||||
<span>Input ${formatNumberWithCommas(result.input_cost_per_token * 1_000_000, 2)}/1M</span>
|
||||
)}
|
||||
|
|
@ -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<MultiCostResultsProps> = ({ multiResult, timePeriod }) => {
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -119,39 +146,36 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ 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 (
|
||||
<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>
|
||||
<div className="py-6 text-center border border-dashed border-border rounded-lg bg-muted">
|
||||
<span className="text-muted-foreground">Select models above to see cost estimates</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state only if loading and no results/errors yet
|
||||
if (!hasAnyResult && isAnyLoading && !hasAnyError) {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Spin indicator={<LoadingOutlined className="animate-spin" />} />
|
||||
<Text className="text-gray-500 block mt-2">Calculating costs...</Text>
|
||||
<div className="py-6 text-center flex flex-col items-center gap-2">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Calculating costs...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show errors-only view when there are errors but no valid results
|
||||
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 className="animate-spin" />} size="small" />}
|
||||
<span className="text-base font-semibold text-foreground">Cost Estimates</span>
|
||||
{isAnyLoading && <Spinner size="sm" />}
|
||||
</div>
|
||||
{/* Error Messages */}
|
||||
{errorEntries.map((e) => (
|
||||
<div key={e.entry.id} className="text-sm text-red-600 bg-red-50 p-3 rounded-lg border border-red-200">
|
||||
<div
|
||||
key={e.entry.id}
|
||||
className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 p-3 rounded-lg border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<span className="font-medium">{e.entry.model || "Unknown model"}: </span>
|
||||
{e.error}
|
||||
</div>
|
||||
|
|
@ -173,95 +197,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 className="animate-spin" />} size="small" />
|
||||
)}
|
||||
</div>
|
||||
{record.error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
|
||||
⚠️ {record.error}
|
||||
</div>
|
||||
)}
|
||||
{record.hasZeroCost && !record.error && (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
||||
⚠️ 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,
|
||||
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<MultiCostResultsProps> = ({ 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 (
|
||||
<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>
|
||||
<span className="text-base font-semibold text-foreground">Cost Estimates</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAnyLoading && <Spin indicator={<LoadingOutlined className="animate-spin" />} size="small" />}
|
||||
{isAnyLoading && <Spinner size="sm" />}
|
||||
<MultiExportDropdown multiResult={multiResult} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Combined Totals - Always show when there are results */}
|
||||
<Card size="small" className="bg-gradient-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 className="bg-gradient-to-r from-slate-50 to-blue-50 dark:from-slate-900 dark:to-blue-950 border-border p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Total Per Request</div>
|
||||
<div className="font-mono text-lg text-blue-600 dark:text-blue-400">
|
||||
{formatCost(multiResult.totals.cost_per_request)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Total {periodLabel}</div>
|
||||
<div
|
||||
className={`font-mono text-lg ${
|
||||
timePeriod === "day" ? "text-green-600 dark:text-green-400" : "text-purple-600 dark:text-purple-400"
|
||||
}`}
|
||||
>
|
||||
{formatCost(periodTotal)}
|
||||
</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="text-xs text-gray-500">Margin Fee/Request</div>
|
||||
<div className="text-sm font-mono text-amber-600">{formatCost(multiResult.totals.margin_per_request)}</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<div className="text-xs text-gray-500">{periodLabel} Margin Fee</div>
|
||||
<div className="text-sm font-mono text-amber-600">
|
||||
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3 pt-3 border-t border-border">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Margin Fee/Request</div>
|
||||
<div className="text-sm font-mono text-amber-600 dark:text-amber-400">
|
||||
{formatCost(multiResult.totals.margin_per_request)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{periodLabel} Margin Fee</div>
|
||||
<div className="text-sm font-mono text-amber-600 dark:text-amber-400">{formatCost(periodMargin)}</div>
|
||||
</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;
|
||||
return (
|
||||
<div className="py-2">
|
||||
<SingleModelBreakdown result={entry.result} loading={entry.loading} timePeriod={timePeriod} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
showExpandColumn: false,
|
||||
}}
|
||||
/>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-10">Model</TableHead>
|
||||
<TableHead className="h-10 text-right">Per Request</TableHead>
|
||||
<TableHead className="h-10 text-right">Margin Fee</TableHead>
|
||||
<TableHead className="h-10 text-right">{periodLabel}</TableHead>
|
||||
<TableHead className="h-10 w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summaryData.map((record) => {
|
||||
const isExpanded = expandedModels.has(record.id);
|
||||
const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost;
|
||||
return (
|
||||
<React.Fragment key={record.id}>
|
||||
<TableRow>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{record.model}</span>
|
||||
{record.provider && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{record.provider}
|
||||
</Badge>
|
||||
)}
|
||||
{record.loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
{record.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 px-2 py-1 rounded">
|
||||
⚠️ {record.error}
|
||||
</div>
|
||||
)}
|
||||
{record.hasZeroCost && !record.error && (
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-2 py-1 rounded">
|
||||
⚠️ No pricing data found for this model. Set base_model in config.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-right">
|
||||
{record.error ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(record.cost_per_request)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-right">
|
||||
{record.error ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span
|
||||
className={`font-mono text-sm ${
|
||||
(record.margin_cost_per_request ?? 0) > 0
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{formatCost(record.margin_cost_per_request)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-right">
|
||||
{record.error ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(periodCost)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 w-[40px]">
|
||||
{record.error ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleExpanded(record.id)}
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && !record.error && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-2 bg-muted/50">
|
||||
{(() => {
|
||||
const entry = validEntries.find((e) => e.entry.id === record.id);
|
||||
if (!entry?.result) return null;
|
||||
return (
|
||||
<SingleModelBreakdown
|
||||
result={entry.result}
|
||||
loading={entry.loading}
|
||||
timePeriod={timePeriod}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => <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} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./provider_display_helpers", () => ({
|
||||
getProviderDisplayInfo: vi.fn((providerValue: string) => ({
|
||||
displayName: providerValue === "openai" ? "OpenAI" : providerValue,
|
||||
|
|
@ -63,7 +33,7 @@ describe("ProviderDiscountTable", () => {
|
|||
discountConfig={DEFAULT_DISCOUNT_CONFIG}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -74,7 +44,7 @@ describe("ProviderDiscountTable", () => {
|
|||
discountConfig={DEFAULT_DISCOUNT_CONFIG}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Discount Percentage")).toBeInTheDocument();
|
||||
|
|
@ -87,7 +57,7 @@ describe("ProviderDiscountTable", () => {
|
|||
discountConfig={DEFAULT_DISCOUNT_CONFIG}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -98,23 +68,22 @@ describe("ProviderDiscountTable", () => {
|
|||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a text input when the edit icon is clicked", async () => {
|
||||
it("should show a text input when the edit button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
const pencilButton = screen.getByRole("button", { name: /PencilAltIcon/i });
|
||||
await user.click(pencilButton);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
|
||||
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -126,31 +95,31 @@ describe("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: "Edit" }));
|
||||
|
||||
expect(screen.queryByText("5.0%")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onDiscountChange with the new value when the save icon is clicked", async () => {
|
||||
it("should call onDiscountChange with the new value when the save button is clicked", 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: "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(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
|
||||
});
|
||||
|
|
@ -162,27 +131,27 @@ describe("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: /CheckIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should cancel edit mode when the cancel icon is clicked", async () => {
|
||||
it("should cancel edit mode when the cancel button is clicked", 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 }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
|
|
@ -196,26 +165,26 @@ describe("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 }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onRemoveProvider with the provider key and display name when the trash icon is clicked", async () => {
|
||||
it("should call onRemoveProvider with the provider key and display name when Remove is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
|
||||
});
|
||||
|
|
@ -227,14 +196,14 @@ describe("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: "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(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,36 +5,6 @@ import userEvent from "@testing-library/user-event";
|
|||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import ProviderMarginTable from "./provider_margin_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, 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} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./provider_display_helpers", () => ({
|
||||
getProviderDisplayInfo: vi.fn((providerValue: string) => {
|
||||
if (providerValue === "openai") return { displayName: "OpenAI", logo: "", enumKey: "OpenAI" };
|
||||
|
|
@ -58,7 +28,7 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -69,7 +39,7 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Margin")).toBeInTheDocument();
|
||||
|
|
@ -82,7 +52,7 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -93,7 +63,7 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ global: 0.05 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Global (All Providers)")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -104,7 +74,7 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("10.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -115,7 +85,7 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: { fixed_amount: 0.001 } }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$0.001000")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -126,22 +96,22 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: { percentage: 0.1, fixed_amount: 0.001 } }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/10\.0%.*\$0\.001000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show edit inputs when the pencil icon is clicked", async () => {
|
||||
it("should show edit inputs when the edit button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
|
||||
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
|
||||
|
|
@ -154,48 +124,48 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "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(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", 0.2);
|
||||
});
|
||||
|
||||
it("should cancel edit mode without calling onMarginChange when X is clicked", async () => {
|
||||
it("should cancel edit mode without calling onMarginChange when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onMarginChange).not.toHaveBeenCalled();
|
||||
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRemoveProvider with provider key and display name when trash is clicked", async () => {
|
||||
it("should call onRemoveProvider with provider key and display name when remove is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
|
||||
});
|
||||
|
|
@ -207,10 +177,10 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ global: 0.05 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("global", "Global");
|
||||
});
|
||||
|
|
@ -223,10 +193,10 @@ describe("ProviderMarginTable", () => {
|
|||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
|
||||
const percentInput = screen.getByPlaceholderText("10");
|
||||
await user.clear(percentInput);
|
||||
|
|
@ -235,7 +205,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(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", {
|
||||
percentage: 0.05,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue