[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:
Ishaan Jaff
2026-01-05 20:19:59 +05:30
committed by GitHub
parent bf1c5bef59
commit 2b3b16c86c
8 changed files with 1174 additions and 18 deletions
@@ -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,
@@ -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],
@@ -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;
@@ -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;
@@ -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;
@@ -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;
};
}
@@ -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,
};
}