fix - multi model selector

This commit is contained in:
Ishaan Jaffer 2026-01-05 19:41:54 +05:30
parent 6de354f85a
commit 0e4448a731
7 changed files with 1144 additions and 16 deletions

View file

@ -1,31 +1,94 @@
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 { Button, Text } from "@tremor/react";
import { PlusOutlined } from "@ant-design/icons";
import { PricingCalculatorProps, ModelEntry } from "./types";
import ModelEntryRow from "./model_entry_row";
import MultiCostResults from "./multi_cost_results";
import { useMultiCostEstimate } from "./use_multi_cost_estimate";
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 { 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 handleAddEntry = useCallback(() => {
setEntries((prev) => [...prev, createDefaultEntry()]);
}, []);
const handleRemoveEntry = useCallback(
(id: string) => {
setEntries((prev) => prev.filter((entry) => entry.id !== id));
removeEntry(id);
},
[removeEntry]
);
// Note: Fetching is triggered by handleEntryChange when model is selected
const multiModelResult = getMultiModelResult(entries);
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-between mb-2">
<Text className="text-sm text-gray-600">
Add models to estimate costs. Each model can have its own token counts and request volumes.
</Text>
<Button
size="xs"
variant="secondary"
icon={PlusOutlined}
onClick={handleAddEntry}
>
Add Model
</Button>
</div>
<div className="space-y-3">
{entries.map((entry) => (
<ModelEntryRow
key={entry.id}
entry={entry}
models={models}
onChange={handleEntryChange}
onRemove={handleRemoveEntry}
canRemove={entries.length > 1}
/>
))}
</div>
<MultiCostResults multiResult={multiModelResult} />
</div>
);
};
export default PricingCalculator;

View file

@ -0,0 +1,107 @@
import React from "react";
import { Select, InputNumber, Button, Tooltip } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { ModelEntry } from "./types";
interface ModelEntryRowProps {
entry: ModelEntry;
models: string[];
onChange: (id: string, field: keyof ModelEntry, value: string | number | undefined) => void;
onRemove: (id: string) => void;
canRemove: boolean;
}
const ModelEntryRow: React.FC<ModelEntryRowProps> = ({
entry,
models,
onChange,
onRemove,
canRemove,
}) => {
return (
<div className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1 grid grid-cols-5 gap-3">
<div>
<label className="text-xs text-gray-500 block mb-1">Model</label>
<Select
showSearch
placeholder="Select model"
value={entry.model || undefined}
onChange={(value) => onChange(entry.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"
/>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Input Tokens</label>
<InputNumber
min={0}
value={entry.input_tokens}
onChange={(value) => onChange(entry.id, "input_tokens", value ?? 0)}
style={{ width: "100%" }}
size="small"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
/>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Output Tokens</label>
<InputNumber
min={0}
value={entry.output_tokens}
onChange={(value) => onChange(entry.id, "output_tokens", value ?? 0)}
style={{ width: "100%" }}
size="small"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
/>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Requests/Day</label>
<InputNumber
min={0}
value={entry.num_requests_per_day}
onChange={(value) => onChange(entry.id, "num_requests_per_day", value ?? undefined)}
style={{ width: "100%" }}
size="small"
placeholder="Optional"
formatter={(value) => value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : ""}
/>
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">Requests/Month</label>
<InputNumber
min={0}
value={entry.num_requests_per_month}
onChange={(value) => onChange(entry.id, "num_requests_per_month", value ?? undefined)}
style={{ width: "100%" }}
size="small"
placeholder="Optional"
formatter={(value) => value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : ""}
/>
</div>
</div>
<div className="pt-5">
<Tooltip title={canRemove ? "Remove model" : "At least one model required"}>
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => onRemove(entry.id)}
disabled={!canRemove}
danger
size="small"
/>
</Tooltip>
</div>
</div>
);
};
export default ModelEntryRow;

View file

@ -0,0 +1,363 @@
import React, { useState } from "react";
import { Text, Button } from "@tremor/react";
import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd";
import { DollarOutlined, 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;
}
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;
}> = ({ result, loading }) => {
return (
<div className="space-y-3">
{loading && (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<Spin indicator={<LoadingOutlined spin />} size="small" />
<span>Updating...</span>
</div>
)}
<Card size="small" title="Per-Request Cost Breakdown">
<Row gutter={16}>
<Col span={6}>
<Statistic
title="Total Cost"
value={formatCost(result.cost_per_request)}
valueStyle={{ color: "#1890ff", fontSize: "16px" }}
prefix={<DollarOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="Input Cost"
value={formatCost(result.input_cost_per_request)}
valueStyle={{ fontSize: "14px" }}
/>
</Col>
<Col span={6}>
<Statistic
title="Output Cost"
value={formatCost(result.output_cost_per_request)}
valueStyle={{ fontSize: "14px" }}
/>
</Col>
<Col span={6}>
<Statistic
title="Margin/Fee"
value={formatCost(result.margin_cost_per_request)}
valueStyle={{
fontSize: "14px",
color: result.margin_cost_per_request > 0 ? "#faad14" : undefined,
}}
/>
</Col>
</Row>
</Card>
{result.daily_cost !== null && (
<Card
size="small"
title={`Daily Costs (${formatRequests(result.num_requests_per_day)} requests/day)`}
>
<Row gutter={16}>
<Col span={6}>
<Statistic
title="Total Daily"
value={formatCost(result.daily_cost)}
valueStyle={{ color: "#52c41a", fontSize: "16px" }}
prefix={<DollarOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="Input Cost"
value={formatCost(result.daily_input_cost)}
valueStyle={{ fontSize: "14px" }}
/>
</Col>
<Col span={6}>
<Statistic
title="Output Cost"
value={formatCost(result.daily_output_cost)}
valueStyle={{ fontSize: "14px" }}
/>
</Col>
<Col span={6}>
<Statistic
title="Margin/Fee"
value={formatCost(result.daily_margin_cost)}
valueStyle={{
fontSize: "14px",
color: (result.daily_margin_cost ?? 0) > 0 ? "#faad14" : undefined,
}}
/>
</Col>
</Row>
</Card>
)}
{result.monthly_cost !== null && (
<Card
size="small"
title={`Monthly Costs (${formatRequests(result.num_requests_per_month)} requests/month)`}
>
<Row gutter={16}>
<Col span={6}>
<Statistic
title="Total Monthly"
value={formatCost(result.monthly_cost)}
valueStyle={{ color: "#722ed1", fontSize: "16px" }}
prefix={<DollarOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="Input Cost"
value={formatCost(result.monthly_input_cost)}
valueStyle={{ fontSize: "14px" }}
/>
</Col>
<Col span={6}>
<Statistic
title="Output Cost"
value={formatCost(result.monthly_output_cost)}
valueStyle={{ fontSize: "14px" }}
/>
</Col>
<Col span={6}>
<Statistic
title="Margin/Fee"
value={formatCost(result.monthly_margin_cost)}
valueStyle={{
fontSize: "14px",
color: (result.monthly_margin_cost ?? 0) > 0 ? "#faad14" : undefined,
}}
/>
</Col>
</Row>
</Card>
)}
{(result.input_cost_per_token || result.output_cost_per_token) && (
<div className="text-xs text-gray-500">
<span className="font-medium">Token Pricing: </span>
{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 }) => {
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-8 text-center border border-dashed border-gray-300 rounded-lg">
<Text className="text-gray-500">
Select models above to see cost estimates
</Text>
</div>
);
}
if (!hasAnyResult && isAnyLoading) {
return (
<div className="py-8 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 summaryColumns = [
{
title: "Model",
dataIndex: "model",
key: "model",
render: (text: string, record: { id: string; provider?: string | null }) => (
<div>
<span className="font-medium">{text}</span>
{record.provider && (
<Tag color="blue" className="ml-2 text-xs">
{record.provider}
</Tag>
)}
</div>
),
},
{
title: "Per Request",
dataIndex: "cost_per_request",
key: "cost_per_request",
render: (value: number) => formatCost(value),
},
{
title: "Daily",
dataIndex: "daily_cost",
key: "daily_cost",
render: (value: number | null) => formatCost(value),
},
{
title: "Monthly",
dataIndex: "monthly_cost",
key: "monthly_cost",
render: (value: number | null) => formatCost(value),
},
{
title: "",
key: "expand",
width: 50,
render: (_: unknown, record: { id: string }) => (
<Button
size="xs"
variant="light"
onClick={() => toggleExpanded(record.id)}
>
{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,
daily_cost: e.result!.daily_cost,
monthly_cost: e.result!.monthly_cost,
}));
return (
<div className="space-y-4">
<Divider />
<div className="flex items-center justify-between">
<div>
<Text className="text-lg font-semibold text-gray-900">Cost Estimates</Text>
<Text className="text-sm text-gray-500 block mt-1">
{validEntries.length} model{validEntries.length !== 1 ? "s" : ""} configured
</Text>
</div>
<div className="flex items-center gap-2">
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<MultiExportDropdown multiResult={multiResult} />
</div>
</div>
{/* Totals Summary */}
{validEntries.length > 1 && (
<Card size="small" className="bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
<div className="flex items-center justify-between">
<Text className="font-semibold text-gray-800">Combined Totals</Text>
</div>
<Row gutter={16} className="mt-3">
<Col span={8}>
<Statistic
title="Total Per Request"
value={formatCost(multiResult.totals.cost_per_request)}
valueStyle={{ color: "#1890ff", fontSize: "20px" }}
prefix={<DollarOutlined />}
/>
</Col>
<Col span={8}>
<Statistic
title="Total Daily"
value={formatCost(multiResult.totals.daily_cost)}
valueStyle={{ color: "#52c41a", fontSize: "20px" }}
prefix={<DollarOutlined />}
/>
</Col>
<Col span={8}>
<Statistic
title="Total Monthly"
value={formatCost(multiResult.totals.monthly_cost)}
valueStyle={{ color: "#722ed1", fontSize: "20px" }}
prefix={<DollarOutlined />}
/>
</Col>
</Row>
</Card>
)}
{/* Per-Model Summary Table */}
<Table
columns={summaryColumns}
dataSource={summaryData}
pagination={false}
size="small"
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-4 px-2">
<SingleModelBreakdown result={entry.result} loading={entry.loading} />
</div>
);
},
showExpandColumn: false,
}}
/>
{/* Error Messages */}
{multiResult.entries
.filter((e) => e.error)
.map((e) => (
<div key={e.entry.id} className="text-sm text-red-500 bg-red-50 p-2 rounded">
<span className="font-medium">{e.entry.model || "Unknown model"}: </span>
{e.error}
</div>
))}
</div>
);
};
export default MultiCostResults;

View file

@ -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;

View file

@ -0,0 +1,302 @@
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>
${modelCount > 1 ? `
<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>
</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
if (validEntries.length > 1) {
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() || "-"],
[""]
);
}
// 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);
};

View file

@ -11,3 +11,26 @@ 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;
};
}

View file

@ -0,0 +1,193 @@
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;
for (const r of results) {
if (r.result) {
totalCostPerRequest += r.result.cost_per_request;
if (r.result.daily_cost !== null) {
totalDailyCost = (totalDailyCost ?? 0) + r.result.daily_cost;
}
if (r.result.monthly_cost !== null) {
totalMonthlyCost = (totalMonthlyCost ?? 0) + r.result.monthly_cost;
}
}
}
return {
entries: results,
totals: {
cost_per_request: totalCostPerRequest,
daily_cost: totalDailyCost,
monthly_cost: totalMonthlyCost,
},
};
},
[entryResults]
);
return {
debouncedFetchForEntry,
removeEntry,
getMultiModelResult,
};
}