|
import React, { useState, useEffect } from "react"; |
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
|
import { Checkbox } from "@/components/ui/checkbox"; |
|
import { Input } from "@/components/ui/input"; |
|
import { |
|
Table, |
|
TableBody, |
|
TableCell, |
|
TableHead, |
|
TableHeader, |
|
TableRow, |
|
} from "@/components/ui/table"; |
|
import { MultiSelect } from "@/components/ui/multi-select"; |
|
import { |
|
Collapsible, |
|
CollapsibleContent, |
|
CollapsibleTrigger, |
|
} from "@/components/ui/collapsible"; |
|
import { Button } from "@/components/ui/button"; |
|
import { ChevronDown, ChevronRight } from "lucide-react"; |
|
import { mockData } from "./lib/data"; |
|
import { Switch } from "@/components/ui/switch"; |
|
|
|
interface FlattenedModel extends Model { |
|
provider: string; |
|
uri: string; |
|
} |
|
|
|
export interface Model { |
|
name: string; |
|
inputPrice: number; |
|
outputPrice: number; |
|
} |
|
|
|
export interface Provider { |
|
provider: string; |
|
uri: string; |
|
models: Model[]; |
|
} |
|
|
|
const App: React.FC = () => { |
|
const [data, setData] = useState<Provider[]>([]); |
|
const [comparisonModels, setComparisonModels] = useState<string[]>([]); |
|
const [inputTokens, setInputTokens] = useState<number>(1); |
|
const [outputTokens, setOutputTokens] = useState<number>(1); |
|
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); |
|
const [selectedModels, setSelectedModels] = useState<string[]>([]); |
|
const [expandedProviders, setExpandedProviders] = useState<string[]>([]); |
|
const [tokenCalculation, setTokenCalculation] = useState<string>("million"); |
|
const [linkProviderModel, setLinkProviderModel] = useState<boolean>(false); |
|
|
|
const [sortConfig, setSortConfig] = useState<{ |
|
key: keyof FlattenedModel; |
|
direction: string; |
|
} | null>(null); |
|
|
|
useEffect(() => { |
|
setData(mockData); |
|
}, []); |
|
|
|
const calculatePrice = (price: number, tokens: number): number => { |
|
let multiplier = 1; |
|
if (tokenCalculation === "thousand") { |
|
multiplier = 1e-3; |
|
} else if (tokenCalculation === "unit") { |
|
multiplier = 1e-6; |
|
} else if (tokenCalculation === "billion") { |
|
multiplier = 1e3; |
|
} |
|
return price * tokens * multiplier; |
|
}; |
|
|
|
const calculateComparison = ( |
|
modelPrice: number, |
|
comparisonPrice: number |
|
): string => { |
|
return (((modelPrice - comparisonPrice) / comparisonPrice) * 100).toFixed( |
|
2 |
|
); |
|
}; |
|
|
|
const flattenData = (data: Provider[]) => { |
|
return data.flatMap((provider) => |
|
provider.models.map((model) => ({ |
|
provider: provider.provider, |
|
uri: provider.uri, |
|
...model, |
|
})) |
|
); |
|
}; |
|
|
|
const filteredData = |
|
selectedProviders.length === 0 && |
|
selectedModels.length === 0 && |
|
!linkProviderModel |
|
? data.map((provider) => ({ |
|
...provider, |
|
models: provider.models, |
|
})) |
|
: data |
|
.filter( |
|
(provider) => |
|
selectedProviders.length === 0 || |
|
selectedProviders.includes(provider.provider) |
|
) |
|
.map((provider) => ({ |
|
...provider, |
|
models: provider.models.filter((model) => { |
|
|
|
if (linkProviderModel && selectedModels.length === 0) |
|
return selectedProviders.includes(provider.provider); |
|
|
|
|
|
if (!linkProviderModel && selectedModels.length === 0) |
|
return ( |
|
selectedProviders.length === 0 || |
|
selectedProviders.includes(provider.provider) |
|
); |
|
|
|
|
|
return selectedModels.includes(model.name); |
|
}), |
|
})) |
|
.filter((provider) => provider.models.length > 0); |
|
|
|
const sortedFlattenedData = React.useMemo(() => { |
|
let sortableData: FlattenedModel[] = flattenData(filteredData); |
|
if (sortConfig !== null) { |
|
sortableData.sort((a, b) => { |
|
const aValue = a[sortConfig.key]; |
|
const bValue = b[sortConfig.key]; |
|
|
|
if (typeof aValue === "string" && typeof bValue === "string") { |
|
return sortConfig.direction === "ascending" |
|
? aValue.localeCompare(bValue) |
|
: bValue.localeCompare(aValue); |
|
} else if (typeof aValue === "number" && typeof bValue === "number") { |
|
return sortConfig.direction === "ascending" |
|
? aValue - bValue |
|
: bValue - aValue; |
|
} else { |
|
return 0; |
|
} |
|
}); |
|
} |
|
return sortableData; |
|
}, [filteredData, sortConfig]); |
|
|
|
const requestSort = (key: keyof FlattenedModel) => { |
|
let direction = "ascending"; |
|
if ( |
|
sortConfig && |
|
sortConfig.key === key && |
|
sortConfig.direction === "ascending" |
|
) { |
|
direction = "descending"; |
|
} |
|
setSortConfig({ key, direction }); |
|
}; |
|
|
|
const toggleProviderExpansion = (provider: string) => { |
|
setExpandedProviders((prev) => |
|
prev.includes(provider) |
|
? prev.filter((p) => p !== provider) |
|
: [...prev, provider] |
|
); |
|
}; |
|
|
|
const getModelsForSelectedProviders = () => { |
|
if (!linkProviderModel) { |
|
return data |
|
.flatMap((provider) => |
|
provider.models.map((model) => ({ |
|
label: model.name, |
|
value: model.name, |
|
provider: provider.provider, |
|
})) |
|
) |
|
.reduce( |
|
( |
|
acc: { label: string; value: string; provider: string }[], |
|
curr: { label: string; value: string; provider: string } |
|
) => { |
|
if (!acc.find((m) => m.value === curr.value)) { |
|
acc.push(curr); |
|
} |
|
return acc; |
|
}, |
|
[] |
|
); |
|
} |
|
|
|
return data |
|
.filter((provider) => selectedProviders.includes(provider.provider)) |
|
.flatMap((provider) => |
|
provider.models.map((model) => ({ |
|
label: model.name, |
|
value: model.name, |
|
provider: provider.provider, |
|
})) |
|
) |
|
.reduce( |
|
( |
|
acc: { label: string; value: string; provider: string }[], |
|
curr: { label: string; value: string; provider: string } |
|
) => { |
|
if (!acc.find((m) => m.value === curr.value)) { |
|
acc.push(curr); |
|
} |
|
return acc; |
|
}, |
|
[] |
|
); |
|
}; |
|
|
|
return ( |
|
<Card className="w-full max-w-6xl mx-auto"> |
|
<CardHeader> |
|
<CardTitle>LLM Pricing Calculator</CardTitle> |
|
</CardHeader> |
|
<CardContent> |
|
<div className="mb-4"> |
|
<p className="italic text-sm text-muted-foreground mb-4"> |
|
<a |
|
href="https://huggingface.co./spaces/philschmid/llm-pricing" |
|
className="underline" |
|
> |
|
This is a fork of philschmid tool: philschmid/llm-pricing |
|
</a> |
|
</p> |
|
<h3 className="text-lg font-semibold mb-2"> |
|
Select Comparison Models |
|
</h3> |
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> |
|
{data.map((provider) => ( |
|
<Collapsible |
|
key={provider.provider} |
|
open={expandedProviders.includes(provider.provider)} |
|
onOpenChange={() => toggleProviderExpansion(provider.provider)} |
|
> |
|
<CollapsibleTrigger asChild> |
|
<Button variant="outline" className="w-full justify-between"> |
|
{provider.provider} |
|
{expandedProviders.includes(provider.provider) ? ( |
|
<ChevronDown className="h-4 w-4" /> |
|
) : ( |
|
<ChevronRight className="h-4 w-4" /> |
|
)} |
|
</Button> |
|
</CollapsibleTrigger> |
|
<CollapsibleContent className="mt-2"> |
|
{provider.models.map((model) => ( |
|
<div |
|
key={`${provider.provider}:${model.name}`} |
|
className="flex items-center space-x-2 mb-1" |
|
> |
|
<Checkbox |
|
id={`${provider.provider}:${model.name}`} |
|
checked={comparisonModels.includes( |
|
`${provider.provider}:${model.name}` |
|
)} |
|
onCheckedChange={(checked) => { |
|
if (checked) { |
|
setComparisonModels((prev) => [ |
|
...prev, |
|
`${provider.provider}:${model.name}`, |
|
]); |
|
} else { |
|
setComparisonModels((prev) => |
|
prev.filter( |
|
(m) => |
|
m !== `${provider.provider}:${model.name}` |
|
) |
|
); |
|
} |
|
}} |
|
/> |
|
<label |
|
htmlFor={`${provider.provider}:${model.name}`} |
|
className="text-sm font-medium text-gray-700" |
|
> |
|
{model.name} |
|
</label> |
|
</div> |
|
))} |
|
</CollapsibleContent> |
|
</Collapsible> |
|
))} |
|
</div> |
|
</div> |
|
|
|
<div className="flex gap-4 mb-4"> |
|
<div className="flex-1"> |
|
<label |
|
htmlFor="inputTokens" |
|
className="block text-sm font-medium text-gray-700" |
|
> |
|
Input Tokens ({tokenCalculation}) |
|
</label> |
|
<Input |
|
id="inputTokens" |
|
type="number" |
|
value={inputTokens} |
|
min={1} |
|
onChange={(e) => setInputTokens(Number(e.target.value))} |
|
className="mt-1" |
|
/> |
|
</div> |
|
<div className="flex-1"> |
|
<label |
|
htmlFor="outputTokens" |
|
className="block text-sm font-medium text-gray-700" |
|
> |
|
Output Tokens ({tokenCalculation}) |
|
</label> |
|
<Input |
|
id="outputTokens" |
|
type="number" |
|
value={outputTokens} |
|
min={1} |
|
onChange={(e) => setOutputTokens(Number(e.target.value))} |
|
className="mt-1" |
|
/> |
|
</div> |
|
<div className="flex-1"> |
|
<label |
|
htmlFor="tokenCalculation" |
|
className="block text-sm font-medium text-gray-700" |
|
> |
|
Token Calculation |
|
</label> |
|
<select |
|
id="tokenCalculation" |
|
value={tokenCalculation} |
|
onChange={(e) => setTokenCalculation(e.target.value)} |
|
className="mt-1 block w-full pl-3 pr-10 py-2 text-base bg-white border focus:outline-none focus:ring-indigo-500 sm:text-sm rounded-md" |
|
> |
|
<option value="billion">Billion Tokens</option> |
|
<option value="million">Million Tokens</option> |
|
<option value="thousand">Thousand Tokens</option> |
|
<option value="unit">Unit Tokens</option> |
|
</select> |
|
</div> |
|
</div> |
|
|
|
<p className="italic text-sm text-muted-foreground mb-4"> |
|
Note: If you use Amazon Bedrock or Azure prices for Anthropic, Cohere |
|
or OpenAI should be the same. |
|
</p> |
|
<div className="flex items-center space-x-2 mb-4"> |
|
<Switch |
|
id="linkProviderModel" |
|
checked={linkProviderModel} |
|
onCheckedChange={setLinkProviderModel} |
|
/> |
|
<label htmlFor="linkProviderModel" className="text-sm"> |
|
Link Provider and Model |
|
</label> |
|
</div> |
|
|
|
<Table> |
|
<TableHeader> |
|
<TableRow> |
|
<TableHead> |
|
<button type="button" onClick={() => requestSort("provider")}> |
|
Provider{" "} |
|
{sortConfig?.key === "provider" |
|
? sortConfig.direction === "ascending" |
|
? "▲" |
|
: "▼" |
|
: null} |
|
</button> |
|
</TableHead> |
|
<TableHead> |
|
<button type="button" onClick={() => requestSort("name")}> |
|
Model{" "} |
|
{sortConfig?.key === "name" |
|
? sortConfig.direction === "ascending" |
|
? "▲" |
|
: "▼" |
|
: null} |
|
</button> |
|
</TableHead> |
|
|
|
<TableHead> |
|
<button type="button" onClick={() => requestSort("inputPrice")}> |
|
Input Price (million tokens) |
|
{sortConfig?.key === "inputPrice" |
|
? sortConfig.direction === "ascending" |
|
? "▲" |
|
: "▼" |
|
: null} |
|
</button> |
|
</TableHead> |
|
<TableHead> |
|
<button |
|
type="button" |
|
onClick={() => requestSort("outputPrice")} |
|
> |
|
Output Price (million tokens) |
|
{sortConfig?.key === "outputPrice" |
|
? sortConfig.direction === "ascending" |
|
? "▲" |
|
: "▼" |
|
: null} |
|
</button> |
|
</TableHead> |
|
|
|
<TableHead> |
|
Total Price (per {tokenCalculation} tokens){" "} |
|
</TableHead> |
|
{comparisonModels.map((model) => ( |
|
<TableHead key={model} colSpan={2}> |
|
Compared to {model} |
|
</TableHead> |
|
))} |
|
</TableRow> |
|
<TableRow> |
|
<TableHead> |
|
<MultiSelect |
|
options={ |
|
data.map((provider) => ({ |
|
label: provider.provider, |
|
value: provider.provider, |
|
})) || [] |
|
} |
|
onValueChange={setSelectedProviders} |
|
defaultValue={selectedProviders} |
|
/> |
|
</TableHead> |
|
<TableHead> |
|
<MultiSelect |
|
options={getModelsForSelectedProviders()} |
|
defaultValue={selectedModels} |
|
onValueChange={setSelectedModels} |
|
/> |
|
</TableHead> |
|
<TableHead /> |
|
<TableHead /> |
|
<TableHead /> |
|
{comparisonModels.flatMap((model) => [ |
|
<TableHead key={`${model}-input`}>Input</TableHead>, |
|
<TableHead key={`${model}-output`}>Output</TableHead>, |
|
])} |
|
</TableRow> |
|
</TableHeader> |
|
<TableBody> |
|
{sortedFlattenedData.map((item) => ( |
|
<TableRow key={`${item.provider}-${item.name}`}> |
|
<TableCell> |
|
{" "} |
|
<a href={item.uri} className="underline"> |
|
{item.provider} |
|
</a> |
|
</TableCell> |
|
<TableCell>{item.name}</TableCell> |
|
|
|
<TableCell>{item.inputPrice.toFixed(2)}</TableCell> |
|
<TableCell>{item.outputPrice.toFixed(2)}</TableCell> |
|
|
|
<TableCell className="font-bold"> |
|
$ |
|
{( |
|
calculatePrice(item.inputPrice, inputTokens) + |
|
calculatePrice(item.outputPrice, outputTokens) |
|
).toFixed(2)} |
|
</TableCell> |
|
|
|
{comparisonModels.flatMap((comparisonModel) => { |
|
const [comparisonProvider, comparisonModelName] = |
|
comparisonModel.split(":"); |
|
const comparisonModelData = data |
|
.find((p) => p.provider === comparisonProvider) |
|
?.models.find((m) => m.name === comparisonModelName)!; |
|
return [ |
|
<TableCell |
|
key={`${comparisonModel}-input`} |
|
className={`${ |
|
parseFloat( |
|
calculateComparison( |
|
item.inputPrice, |
|
comparisonModelData.inputPrice |
|
) |
|
) < 0 |
|
? "bg-green-100" |
|
: parseFloat( |
|
calculateComparison( |
|
item.inputPrice, |
|
comparisonModelData.inputPrice |
|
) |
|
) > 0 |
|
? "bg-red-100" |
|
: "" |
|
}`} |
|
> |
|
{`${item.provider}:${item.name}` === comparisonModel |
|
? "0.00%" |
|
: `${calculateComparison( |
|
item.inputPrice, |
|
comparisonModelData.inputPrice |
|
)}%`} |
|
</TableCell>, |
|
<TableCell |
|
key={`${comparisonModel}-output`} |
|
className={`${ |
|
parseFloat( |
|
calculateComparison( |
|
item.outputPrice, |
|
comparisonModelData.outputPrice |
|
) |
|
) < 0 |
|
? "bg-green-100" |
|
: parseFloat( |
|
calculateComparison( |
|
item.outputPrice, |
|
comparisonModelData.outputPrice |
|
) |
|
) > 0 |
|
? "bg-red-100" |
|
: "" |
|
}`} |
|
> |
|
{`${item.provider}:${item.name}` === comparisonModel |
|
? "0.00%" |
|
: `${calculateComparison( |
|
item.outputPrice, |
|
comparisonModelData.outputPrice |
|
)}%`} |
|
</TableCell>, |
|
]; |
|
})} |
|
</TableRow> |
|
))} |
|
</TableBody> |
|
</Table> |
|
</CardContent> |
|
</Card> |
|
); |
|
}; |
|
|
|
export default App; |
|
|