mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-24 00:28:32 +00:00
[Feat] Litellm UI allow selecting many models for cost estimator (#18653)
* LiteLLM Cost Estimate * fix - multi model selector * v2 of report * fixes * export fix
This commit is contained in:
@@ -22605,6 +22605,53 @@
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"openrouter/google/gemini-3-flash-preview": {
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"input_cost_per_audio_token": 1e-06,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_audio_length_hours": 8.4,
|
||||
"max_audio_per_prompt": 1,
|
||||
"max_images_per_prompt": 3000,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 65535,
|
||||
"max_pdf_size_mb": 30,
|
||||
"max_tokens": 65535,
|
||||
"max_video_length": 1,
|
||||
"max_videos_per_prompt": 10,
|
||||
"mode": "chat",
|
||||
"output_cost_per_reasoning_token": 3e-06,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"rpm": 2000,
|
||||
"source": "https://ai.google.dev/pricing/gemini-3",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/v1/batch"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_audio_output": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_url_context": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"tpm": 800000
|
||||
},
|
||||
"openrouter/google/gemini-pro-1.5": {
|
||||
"input_cost_per_image": 0.00265,
|
||||
"input_cost_per_token": 2.5e-06,
|
||||
|
||||
+2
-2
@@ -89,7 +89,7 @@ export const exportToPDF = (result: CostEstimateResponse): void => {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LLM Cost Estimate Report</h1>
|
||||
<h1>🚅 LiteLLM Cost Estimate Report</h1>
|
||||
|
||||
<div class="meta">
|
||||
<p><strong>Model:</strong> ${result.model}</p>
|
||||
@@ -214,7 +214,7 @@ export const exportToPDF = (result: CostEstimateResponse): void => {
|
||||
|
||||
export const exportToCSV = (result: CostEstimateResponse): void => {
|
||||
const rows = [
|
||||
["LLM Cost Estimate Report"],
|
||||
["🚅 LiteLLM Cost Estimate Report"],
|
||||
[""],
|
||||
["Configuration"],
|
||||
["Model", result.model],
|
||||
|
||||
+192
-16
@@ -1,31 +1,207 @@
|
||||
import React, { useCallback } from "react";
|
||||
import PricingForm from "./pricing_form";
|
||||
import CostResults from "./cost_results";
|
||||
import { useCostEstimate } from "./use_cost_estimate";
|
||||
import { PricingCalculatorProps, PricingFormValues } from "./types";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Table, Select, InputNumber, Button, Radio } from "antd";
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { PricingCalculatorProps, ModelEntry } from "./types";
|
||||
import MultiCostResults from "./multi_cost_results";
|
||||
import { useMultiCostEstimate } from "./use_multi_cost_estimate";
|
||||
|
||||
type TimePeriod = "day" | "month";
|
||||
|
||||
const generateId = () => `entry-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const createDefaultEntry = (): ModelEntry => ({
|
||||
id: generateId(),
|
||||
model: "",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
num_requests_per_day: undefined,
|
||||
num_requests_per_month: undefined,
|
||||
});
|
||||
|
||||
const PricingCalculator: React.FC<PricingCalculatorProps> = ({
|
||||
accessToken,
|
||||
models,
|
||||
}) => {
|
||||
const { loading, result, debouncedFetch } = useCostEstimate(accessToken);
|
||||
const [entries, setEntries] = useState<ModelEntry[]>([createDefaultEntry()]);
|
||||
const [timePeriod, setTimePeriod] = useState<TimePeriod>("month");
|
||||
const { debouncedFetchForEntry, removeEntry, getMultiModelResult } =
|
||||
useMultiCostEstimate(accessToken);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(_changedValues: Partial<PricingFormValues>, allValues: PricingFormValues) => {
|
||||
if (allValues.model) {
|
||||
debouncedFetch(allValues);
|
||||
}
|
||||
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 changedEntry = updated.find((e) => e.id === id);
|
||||
if (changedEntry && changedEntry.model) {
|
||||
debouncedFetchForEntry(changedEntry);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[debouncedFetch]
|
||||
[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,
|
||||
}))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
setEntries((prev) => [...prev, createDefaultEntry()]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveEntry = useCallback(
|
||||
(id: string) => {
|
||||
setEntries((prev) => prev.filter((entry) => entry.id !== id));
|
||||
removeEntry(id);
|
||||
},
|
||||
[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
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveEntry(record.id)}
|
||||
disabled={entries.length === 1}
|
||||
danger
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PricingForm models={models} onValuesChange={handleValuesChange} />
|
||||
<CostResults result={result} loading={loading} />
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<Radio.Group
|
||||
value={timePeriod}
|
||||
onChange={(e) => handleTimePeriodChange(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="day">Per Day</Radio.Button>
|
||||
<Radio.Button value="month">Per Month</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
footer={() => (
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={handleAddEntry}
|
||||
icon={<PlusOutlined />}
|
||||
className="w-full"
|
||||
>
|
||||
Add Another Model
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MultiCostResults multiResult={multiModelResult} timePeriod={timePeriod} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCalculator;
|
||||
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import React, { useState } from "react";
|
||||
import { Text, Button } from "@tremor/react";
|
||||
import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd";
|
||||
import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { CostEstimateResponse } from "../types";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { MultiModelResult } from "./types";
|
||||
import MultiExportDropdown from "./multi_export_dropdown";
|
||||
|
||||
interface MultiCostResultsProps {
|
||||
multiResult: MultiModelResult;
|
||||
timePeriod: "day" | "month";
|
||||
}
|
||||
|
||||
const formatCost = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value === 0) return "$0";
|
||||
if (value < 0.0001) return `$${value.toExponential(2)}`;
|
||||
if (value < 1) return `$${value.toFixed(4)}`;
|
||||
return `$${formatNumberWithCommas(value, 2, true)}`;
|
||||
};
|
||||
|
||||
const formatRequests = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return formatNumberWithCommas(value, 0, true);
|
||||
};
|
||||
|
||||
const SingleModelBreakdown: React.FC<{
|
||||
result: CostEstimateResponse;
|
||||
loading: boolean;
|
||||
timePeriod: "day" | "month";
|
||||
}> = ({ result, loading, timePeriod }) => {
|
||||
const periodLabel = timePeriod === "day" ? "Daily" : "Monthly";
|
||||
const periodCost = timePeriod === "day" ? result.daily_cost : result.monthly_cost;
|
||||
const periodInputCost = timePeriod === "day" ? result.daily_input_cost : result.monthly_input_cost;
|
||||
const periodOutputCost = timePeriod === "day" ? result.daily_output_cost : result.monthly_output_cost;
|
||||
const periodMarginCost = timePeriod === "day" ? result.daily_margin_cost : result.monthly_margin_cost;
|
||||
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">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="small" />
|
||||
<span>Updating...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Total/Request</Text>
|
||||
<Text className="text-base font-semibold text-blue-600">{formatCost(result.cost_per_request)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Input Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.input_cost_per_request)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Output Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.output_cost_per_request)}</Text>
|
||||
</div>
|
||||
<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" : ""}`}>
|
||||
{formatCost(result.margin_cost_per_request)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{periodCost !== null && (
|
||||
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-gray-200">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Total ({formatRequests(periodRequests)} req)</Text>
|
||||
<Text className={`text-base font-semibold ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}>
|
||||
{formatCost(periodCost)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Input</Text>
|
||||
<Text className="text-sm">{formatCost(periodInputCost)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Output</Text>
|
||||
<Text className="text-sm">{formatCost(periodOutputCost)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Margin Fee</Text>
|
||||
<Text className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
|
||||
{formatCost(periodMarginCost)}
|
||||
</Text>
|
||||
</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: {" "}
|
||||
{result.input_cost_per_token && (
|
||||
<span>Input ${formatNumberWithCommas(result.input_cost_per_token * 1_000_000, 2)}/1M</span>
|
||||
)}
|
||||
{result.input_cost_per_token && result.output_cost_per_token && " | "}
|
||||
{result.output_cost_per_token && (
|
||||
<span>Output ${formatNumberWithCommas(result.output_cost_per_token * 1_000_000, 2)}/1M</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePeriod }) => {
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
|
||||
const validEntries = multiResult.entries.filter((e) => e.result !== null);
|
||||
const loadingEntries = multiResult.entries.filter((e) => e.loading);
|
||||
const hasAnyResult = validEntries.length > 0;
|
||||
const isAnyLoading = loadingEntries.length > 0;
|
||||
|
||||
if (!hasAnyResult && !isAnyLoading) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAnyResult && isAnyLoading) {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<Text className="text-gray-500 block mt-2">Calculating costs...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
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 }) => (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Per Request",
|
||||
dataIndex: "cost_per_request",
|
||||
key: "cost_per_request",
|
||||
align: "right" as const,
|
||||
render: (value: number) => <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) => (
|
||||
<span className={`font-mono text-sm ${value > 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) => <span className="font-mono text-sm">{formatCost(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "expand",
|
||||
width: 40,
|
||||
render: (_: unknown, record: { id: string }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => toggleExpanded(record.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const summaryData = validEntries.map((e) => ({
|
||||
key: e.entry.id,
|
||||
id: e.entry.id,
|
||||
model: e.result!.model,
|
||||
provider: e.result!.provider,
|
||||
cost_per_request: e.result!.cost_per_request,
|
||||
margin_cost_per_request: e.result!.margin_cost_per_request,
|
||||
daily_cost: e.result!.daily_cost,
|
||||
monthly_cost: e.result!.monthly_cost,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Divider className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
<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>
|
||||
{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>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Per-Model Table */}
|
||||
{validEntries.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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Messages */}
|
||||
{multiResult.entries
|
||||
.filter((e) => e.error)
|
||||
.map((e) => (
|
||||
<div key={e.entry.id} className="text-sm text-red-600 bg-red-50 p-3 rounded-lg border border-red-200">
|
||||
<span className="font-medium">{e.entry.model || "Unknown model"}: </span>
|
||||
{e.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiCostResults;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@tremor/react";
|
||||
import { DownloadOutlined, FilePdfOutlined, FileExcelOutlined } from "@ant-design/icons";
|
||||
import { MultiModelResult } from "./types";
|
||||
import { exportMultiToPDF, exportMultiToCSV } from "./multi_export_utils";
|
||||
|
||||
interface MultiExportDropdownProps {
|
||||
multiResult: MultiModelResult;
|
||||
}
|
||||
|
||||
const MultiExportDropdown: React.FC<MultiExportDropdownProps> = ({ multiResult }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasResults = multiResult.entries.some((e) => e.result !== null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!hasResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={menuRef}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
icon={DownloadOutlined}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => {
|
||||
exportMultiToPDF(multiResult);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined className="mr-3 text-red-500" />
|
||||
Export as PDF
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => {
|
||||
exportMultiToCSV(multiResult);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileExcelOutlined className="mr-3 text-green-600" />
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiExportDropdown;
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
import { CostEstimateResponse } from "../types";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { MultiModelResult } from "./types";
|
||||
|
||||
const formatCostForExport = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value === 0) return "$0.00";
|
||||
if (value < 0.01) return `$${value.toFixed(6)}`;
|
||||
if (value < 1) return `$${value.toFixed(4)}`;
|
||||
return `$${formatNumberWithCommas(value, 2)}`;
|
||||
};
|
||||
|
||||
const formatRequestsForExport = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return formatNumberWithCommas(value, 0);
|
||||
};
|
||||
|
||||
const generateModelSection = (result: CostEstimateResponse): string => {
|
||||
return `
|
||||
<div class="model-section">
|
||||
<h3>${result.model} ${result.provider ? `<span class="provider">(${result.provider})</span>` : ""}</h3>
|
||||
|
||||
<div class="meta">
|
||||
<p><strong>Input Tokens per Request:</strong> ${formatRequestsForExport(result.input_tokens)}</p>
|
||||
<p><strong>Output Tokens per Request:</strong> ${formatRequestsForExport(result.output_tokens)}</p>
|
||||
${result.num_requests_per_day ? `<p><strong>Requests per Day:</strong> ${formatRequestsForExport(result.num_requests_per_day)}</p>` : ""}
|
||||
${result.num_requests_per_month ? `<p><strong>Requests per Month:</strong> ${formatRequestsForExport(result.num_requests_per_month)}</p>` : ""}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Cost Type</th>
|
||||
<th>Per Request</th>
|
||||
${result.daily_cost !== null ? "<th>Daily</th>" : ""}
|
||||
${result.monthly_cost !== null ? "<th>Monthly</th>" : ""}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Input Cost</td>
|
||||
<td class="cost-value">${formatCostForExport(result.input_cost_per_request)}</td>
|
||||
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_input_cost)}</td>` : ""}
|
||||
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_input_cost)}</td>` : ""}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Output Cost</td>
|
||||
<td class="cost-value">${formatCostForExport(result.output_cost_per_request)}</td>
|
||||
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_output_cost)}</td>` : ""}
|
||||
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_output_cost)}</td>` : ""}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Margin/Fee</td>
|
||||
<td class="cost-value">${formatCostForExport(result.margin_cost_per_request)}</td>
|
||||
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_margin_cost)}</td>` : ""}
|
||||
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_margin_cost)}</td>` : ""}
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
<td class="cost-value">${formatCostForExport(result.cost_per_request)}</td>
|
||||
${result.daily_cost !== null ? `<td class="cost-value">${formatCostForExport(result.daily_cost)}</td>` : ""}
|
||||
${result.monthly_cost !== null ? `<td class="cost-value">${formatCostForExport(result.monthly_cost)}</td>` : ""}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export const exportMultiToPDF = (multiResult: MultiModelResult): void => {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) {
|
||||
alert("Please allow popups to export PDF");
|
||||
return;
|
||||
}
|
||||
|
||||
const validEntries = multiResult.entries.filter((e) => e.result !== null);
|
||||
const modelCount = validEntries.length;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Multi-Model Cost Estimate Report</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
padding: 40px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #1a1a1a;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
h2 {
|
||||
color: #444;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
h3 {
|
||||
color: #555;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.provider {
|
||||
font-weight: normal;
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.meta {
|
||||
background: #f5f5f5;
|
||||
padding: 12px 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.meta p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cost-value {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.total-row {
|
||||
font-weight: bold;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.summary-box {
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #f9f0ff 100%);
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.summary-box h2 {
|
||||
margin-top: 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
}
|
||||
.summary-item .label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.summary-item .value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
}
|
||||
.summary-item .value.blue { color: #1890ff; }
|
||||
.summary-item .value.green { color: #52c41a; }
|
||||
.summary-item .value.purple { color: #722ed1; }
|
||||
.model-section {
|
||||
margin-bottom: 30px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 20px; }
|
||||
.model-section { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LLM Cost Estimate Report</h1>
|
||||
<p style="color: #666; margin-top: -20px; margin-bottom: 30px;">${modelCount} model${modelCount !== 1 ? "s" : ""} configured</p>
|
||||
|
||||
<div class="summary-box">
|
||||
<h2>Combined Totals</h2>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<div class="label">Total Per Request</div>
|
||||
<div class="value blue">${formatCostForExport(multiResult.totals.cost_per_request)}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">Total Daily</div>
|
||||
<div class="value green">${formatCostForExport(multiResult.totals.daily_cost)}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">Total Monthly</div>
|
||||
<div class="value purple">${formatCostForExport(multiResult.totals.monthly_cost)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${multiResult.totals.margin_per_request > 0 ? `
|
||||
<div class="summary-grid" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
|
||||
<div class="summary-item">
|
||||
<div class="label">Margin/Request</div>
|
||||
<div class="value" style="color: #faad14;">${formatCostForExport(multiResult.totals.margin_per_request)}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">Daily Margin</div>
|
||||
<div class="value" style="color: #faad14;">${formatCostForExport(multiResult.totals.daily_margin)}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">Monthly Margin</div>
|
||||
<div class="value" style="color: #faad14;">${formatCostForExport(multiResult.totals.monthly_margin)}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
|
||||
<h2>Model Breakdown</h2>
|
||||
${validEntries.map((e) => generateModelSection(e.result!)).join("")}
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by LiteLLM Pricing Calculator on ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
};
|
||||
|
||||
export const exportMultiToCSV = (multiResult: MultiModelResult): void => {
|
||||
const validEntries = multiResult.entries.filter((e) => e.result !== null);
|
||||
|
||||
const rows: string[][] = [
|
||||
["LLM Multi-Model Cost Estimate Report"],
|
||||
["Generated", new Date().toLocaleString()],
|
||||
[""],
|
||||
];
|
||||
|
||||
// Summary section
|
||||
rows.push(
|
||||
["COMBINED TOTALS"],
|
||||
["Total Per Request", multiResult.totals.cost_per_request.toString()],
|
||||
["Total Daily", multiResult.totals.daily_cost?.toString() || "-"],
|
||||
["Total Monthly", multiResult.totals.monthly_cost?.toString() || "-"],
|
||||
["Margin Per Request", multiResult.totals.margin_per_request.toString()],
|
||||
["Daily Margin", multiResult.totals.daily_margin?.toString() || "-"],
|
||||
["Monthly Margin", multiResult.totals.monthly_margin?.toString() || "-"],
|
||||
[""]
|
||||
);
|
||||
|
||||
// Summary table header
|
||||
rows.push([
|
||||
"Model",
|
||||
"Provider",
|
||||
"Input Tokens",
|
||||
"Output Tokens",
|
||||
"Requests/Day",
|
||||
"Requests/Month",
|
||||
"Cost/Request",
|
||||
"Daily Cost",
|
||||
"Monthly Cost",
|
||||
"Input Cost/Req",
|
||||
"Output Cost/Req",
|
||||
"Margin/Req",
|
||||
]);
|
||||
|
||||
// Add each model's data
|
||||
for (const entry of validEntries) {
|
||||
const r = entry.result!;
|
||||
rows.push([
|
||||
r.model,
|
||||
r.provider || "-",
|
||||
r.input_tokens.toString(),
|
||||
r.output_tokens.toString(),
|
||||
r.num_requests_per_day?.toString() || "-",
|
||||
r.num_requests_per_month?.toString() || "-",
|
||||
r.cost_per_request.toString(),
|
||||
r.daily_cost?.toString() || "-",
|
||||
r.monthly_cost?.toString() || "-",
|
||||
r.input_cost_per_request.toString(),
|
||||
r.output_cost_per_request.toString(),
|
||||
r.margin_cost_per_request.toString(),
|
||||
]);
|
||||
}
|
||||
|
||||
const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `cost_estimate_multi_model_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
@@ -11,3 +11,29 @@ export interface PricingFormValues {
|
||||
num_requests_per_month?: number;
|
||||
}
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
num_requests_per_day?: number;
|
||||
num_requests_per_month?: number;
|
||||
}
|
||||
|
||||
export interface MultiModelResult {
|
||||
entries: Array<{
|
||||
entry: ModelEntry;
|
||||
result: import("../types").CostEstimateResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}>;
|
||||
totals: {
|
||||
cost_per_request: number;
|
||||
daily_cost: number | null;
|
||||
monthly_cost: number | null;
|
||||
margin_per_request: number;
|
||||
daily_margin: number | null;
|
||||
monthly_margin: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { CostEstimateRequest, CostEstimateResponse } from "../types";
|
||||
import { ModelEntry, MultiModelResult } from "./types";
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
interface EntryResult {
|
||||
entry: ModelEntry;
|
||||
result: CostEstimateResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useMultiCostEstimate(accessToken: string | null) {
|
||||
const [entryResults, setEntryResults] = useState<Map<string, EntryResult>>(new Map());
|
||||
const debounceRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
|
||||
const fetchEstimateForEntry = useCallback(
|
||||
async (entry: ModelEntry) => {
|
||||
if (!accessToken || !entry.model) {
|
||||
setEntryResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(entry.id, {
|
||||
entry,
|
||||
result: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setEntryResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(entry.id);
|
||||
next.set(entry.id, {
|
||||
entry,
|
||||
result: existing?.result ?? null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/cost/estimate` : "/cost/estimate";
|
||||
|
||||
const requestBody: CostEstimateRequest = {
|
||||
model: entry.model,
|
||||
input_tokens: entry.input_tokens || 0,
|
||||
output_tokens: entry.output_tokens || 0,
|
||||
num_requests_per_day: entry.num_requests_per_day || null,
|
||||
num_requests_per_month: entry.num_requests_per_month || null,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: CostEstimateResponse = await response.json();
|
||||
setEntryResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(entry.id, {
|
||||
entry,
|
||||
result: data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
const errorMessage =
|
||||
errorData.detail?.error || errorData.detail || "Failed to estimate cost";
|
||||
setEntryResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(entry.id, {
|
||||
entry,
|
||||
result: null,
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error estimating cost:", error);
|
||||
setEntryResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(entry.id, {
|
||||
entry,
|
||||
result: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[accessToken]
|
||||
);
|
||||
|
||||
const debouncedFetchForEntry = useCallback(
|
||||
(entry: ModelEntry) => {
|
||||
const existingTimeout = debounceRefs.current.get(entry.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
fetchEstimateForEntry(entry);
|
||||
}, DEBOUNCE_MS);
|
||||
debounceRefs.current.set(entry.id, timeout);
|
||||
},
|
||||
[fetchEstimateForEntry]
|
||||
);
|
||||
|
||||
const removeEntry = useCallback((id: string) => {
|
||||
const timeout = debounceRefs.current.get(id);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
debounceRefs.current.delete(id);
|
||||
}
|
||||
setEntryResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refs = debounceRefs.current;
|
||||
return () => {
|
||||
refs.forEach((timeout) => clearTimeout(timeout));
|
||||
refs.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getMultiModelResult = useCallback(
|
||||
(entries: ModelEntry[]): MultiModelResult => {
|
||||
const results: MultiModelResult["entries"] = entries.map((entry) => {
|
||||
const cached = entryResults.get(entry.id);
|
||||
return {
|
||||
entry,
|
||||
result: cached?.result ?? null,
|
||||
loading: cached?.loading ?? false,
|
||||
error: cached?.error ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
let totalCostPerRequest = 0;
|
||||
let totalDailyCost: number | null = null;
|
||||
let totalMonthlyCost: number | null = null;
|
||||
let totalMarginPerRequest = 0;
|
||||
let totalDailyMargin: number | null = null;
|
||||
let totalMonthlyMargin: number | null = null;
|
||||
|
||||
for (const r of results) {
|
||||
if (r.result) {
|
||||
totalCostPerRequest += r.result.cost_per_request;
|
||||
totalMarginPerRequest += r.result.margin_cost_per_request;
|
||||
if (r.result.daily_cost !== null) {
|
||||
totalDailyCost = (totalDailyCost ?? 0) + r.result.daily_cost;
|
||||
}
|
||||
if (r.result.daily_margin_cost !== null) {
|
||||
totalDailyMargin = (totalDailyMargin ?? 0) + r.result.daily_margin_cost;
|
||||
}
|
||||
if (r.result.monthly_cost !== null) {
|
||||
totalMonthlyCost = (totalMonthlyCost ?? 0) + r.result.monthly_cost;
|
||||
}
|
||||
if (r.result.monthly_margin_cost !== null) {
|
||||
totalMonthlyMargin = (totalMonthlyMargin ?? 0) + r.result.monthly_margin_cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries: results,
|
||||
totals: {
|
||||
cost_per_request: totalCostPerRequest,
|
||||
daily_cost: totalDailyCost,
|
||||
monthly_cost: totalMonthlyCost,
|
||||
margin_per_request: totalMarginPerRequest,
|
||||
daily_margin: totalDailyMargin,
|
||||
monthly_margin: totalMonthlyMargin,
|
||||
},
|
||||
};
|
||||
},
|
||||
[entryResults]
|
||||
);
|
||||
|
||||
return {
|
||||
debouncedFetchForEntry,
|
||||
removeEntry,
|
||||
getMultiModelResult,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user