feat(usage): add cost reporting with custom model pricing and OpenRouter
- Create CostReportCard with per-model input/output cost breakdown table - Create ModelPricingDialog for custom pricing configuration - Add 'Fetch from OpenRouter' button to auto-populate pricing from API - Add custom_model_pricing to AppConfig (persisted in gui_config.json) - Refactor calculateCost to accept optional custom pricing overrides - Support Manual, OpenRouter, and Default pricing sources
This commit is contained in:
1 parent
20785a8414
commit
bc69a2e8b5
4 files changed
+449
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { calculateCost } from '@/constants/pricing';
|
||||
|
||||
export interface ModelCostEntry {
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
export interface CostReportCardProps {
|
||||
models: ModelCostEntry[];
|
||||
totalInputCost: number;
|
||||
totalOutputCost: number;
|
||||
totalCost: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export const CostReportCard: React.FC<CostReportCardProps> = ({
|
||||
models,
|
||||
totalInputCost,
|
||||
totalOutputCost,
|
||||
totalCost,
|
||||
isLoading,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="bg-card/50 border-white/[0.06]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
|
||||
Loading cost data...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!models.length) {
|
||||
return (
|
||||
<Card className="bg-card/50 border-white/[0.06]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
|
||||
No usage data for cost calculation
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50 border-white/[0.06]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b border-white/[0.06]">
|
||||
<th className="py-2 pr-4 font-medium">Model</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Input Tokens</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Input Cost</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Output Tokens</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Output Cost</th>
|
||||
<th className="py-2 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{models.map((m) => (
|
||||
<tr key={m.model} className="hover:bg-white/[0.02]">
|
||||
<td className="text-foreground py-2 pr-4">{m.model}</td>
|
||||
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
|
||||
{formatNumber(m.inputTokens)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
|
||||
${m.inputCost.toFixed(2)}
|
||||
</td>
|
||||
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
|
||||
{formatNumber(m.outputTokens)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
|
||||
${m.outputCost.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono font-medium text-emerald-500">
|
||||
${m.totalCost.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/[0.08] font-medium">
|
||||
<td className="text-foreground py-3 pr-4">Total</td>
|
||||
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
|
||||
{formatNumber(models.reduce((sum, m) => sum + m.inputTokens, 0))}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
|
||||
${totalInputCost.toFixed(2)}
|
||||
</td>
|
||||
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
|
||||
{formatNumber(models.reduce((sum, m) => sum + m.outputTokens, 0))}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
|
||||
${totalOutputCost.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-3 text-right font-mono font-bold text-emerald-500">
|
||||
${totalCost.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { MODEL_PRICING } from '@/constants/pricing';
|
||||
|
||||
export interface ModelPricingDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
customPricing: Record<string, { input: number; output: number; source?: string }>;
|
||||
onSavePricing: (
|
||||
pricing: Record<string, { input: number; output: number; source: string }>,
|
||||
) => void;
|
||||
availableModels: string[];
|
||||
}
|
||||
|
||||
export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
customPricing,
|
||||
onSavePricing,
|
||||
availableModels,
|
||||
}) => {
|
||||
const [localPricing, setLocalPricing] = useState<
|
||||
Record<string, { input: number; output: number; source: string }>
|
||||
>({});
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Initialize local state with custom pricing or empty if default
|
||||
const initial: Record<string, { input: number; output: number; source: string }> = {};
|
||||
Object.entries(customPricing || {}).forEach(([model, price]) => {
|
||||
initial[model] = {
|
||||
input: price.input,
|
||||
output: price.output,
|
||||
source: price.source || 'Custom',
|
||||
};
|
||||
});
|
||||
setLocalPricing(initial);
|
||||
}
|
||||
}, [open, customPricing]);
|
||||
|
||||
const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => {
|
||||
const numValue = parseFloat(value);
|
||||
if (isNaN(numValue) && value !== '') return;
|
||||
|
||||
setLocalPricing((prev) => {
|
||||
const current = prev[model] || {
|
||||
input: getBasePrice(model, 'input'),
|
||||
output: getBasePrice(model, 'output'),
|
||||
source: 'Default',
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[model]: {
|
||||
...current,
|
||||
[type]: value === '' ? 0 : numValue,
|
||||
source: 'Custom',
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = (model: string) => {
|
||||
setLocalPricing((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[model];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getBasePrice = (model: string, type: 'input' | 'output'): number => {
|
||||
const normalizedModel = model.toLowerCase().replace('models/', '');
|
||||
let pricing = MODEL_PRICING['default'];
|
||||
|
||||
for (const [key, value] of Object.entries(MODEL_PRICING)) {
|
||||
if (normalizedModel.includes(key)) {
|
||||
pricing = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pricing[type];
|
||||
};
|
||||
|
||||
const getDisplayPrice = (model: string, type: 'input' | 'output'): number | string => {
|
||||
if (localPricing[model]) {
|
||||
return localPricing[model][type];
|
||||
}
|
||||
return ''; // Show empty to display placeholder
|
||||
};
|
||||
|
||||
const getSource = (model: string) => {
|
||||
if (localPricing[model]) {
|
||||
return localPricing[model].source;
|
||||
}
|
||||
return 'Default';
|
||||
};
|
||||
|
||||
const fetchOpenRouterPricing = async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models');
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.data) {
|
||||
const newPricing = { ...localPricing };
|
||||
|
||||
// Try to match available models with OpenRouter models
|
||||
availableModels.forEach((model) => {
|
||||
// Simplistic matching - OpenRouter models often have provider prefixes
|
||||
const orModel = data.data.find(
|
||||
(m: any) =>
|
||||
m.id.endsWith(model) || m.id.includes(model) || model.includes(m.id.split('/').pop()),
|
||||
);
|
||||
|
||||
if (orModel && orModel.pricing) {
|
||||
// OpenRouter pricing is per token, we need per 1M tokens
|
||||
const inputPrice = parseFloat(orModel.pricing.prompt) * 1_000_000;
|
||||
const outputPrice = parseFloat(orModel.pricing.completion) * 1_000_000;
|
||||
|
||||
if (!isNaN(inputPrice) && !isNaN(outputPrice)) {
|
||||
newPricing[model] = {
|
||||
input: Number(inputPrice.toFixed(4)),
|
||||
output: Number(outputPrice.toFixed(4)),
|
||||
source: 'OpenRouter',
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalPricing(newPricing);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch OpenRouter pricing:', error);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSavePricing(localPricing);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Model Pricing Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set custom pricing per model (USD per 1M tokens). These values are used to calculate
|
||||
cost estimates.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mb-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchOpenRouterPricing}
|
||||
disabled={isFetching}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-3 w-3" />
|
||||
)}
|
||||
Fetch from OpenRouter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar flex-1 overflow-y-auto rounded-md border pr-2">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Model</th>
|
||||
<th className="w-[120px] px-4 py-3 font-medium">Input Price</th>
|
||||
<th className="w-[120px] px-4 py-3 font-medium">Output Price</th>
|
||||
<th className="w-[100px] px-4 py-3 font-medium">Source</th>
|
||||
<th className="w-[60px] px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{availableModels.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-muted-foreground px-4 py-8 text-center">
|
||||
No models have been used yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
availableModels.map((model) => {
|
||||
const source = getSource(model);
|
||||
return (
|
||||
<tr key={model} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-mono text-xs">{model}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder={getBasePrice(model, 'input').toString()}
|
||||
value={getDisplayPrice(model, 'input')}
|
||||
onChange={(e) => handlePriceChange(model, 'input', e.target.value)}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder={getBasePrice(model, 'output').toString()}
|
||||
value={getDisplayPrice(model, 'output')}
|
||||
onChange={(e) => handlePriceChange(model, 'output', e.target.value)}
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge
|
||||
variant={source === 'Default' ? 'outline' : 'secondary'}
|
||||
className={`text-[10px] ${
|
||||
source === 'Custom'
|
||||
? 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20'
|
||||
: source === 'OpenRouter'
|
||||
? 'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{source}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{source !== 'Default' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-6 w-6"
|
||||
onClick={() => handleReset(model)}
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// Costs in USD per 1M tokens
|
||||
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||
'gemini-3.1-pro': { input: 1.25, output: 5.0 },
|
||||
'gemini-3.1-pro-preview': { input: 1.25, output: 5.0 },
|
||||
'gemini-3-flash': { input: 0.075, output: 0.3 },
|
||||
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
|
||||
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
|
||||
// fallback for unknowns
|
||||
default: { input: 1.0, output: 4.0 },
|
||||
};
|
||||
|
||||
export function calculateCost(
|
||||
model: string,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
customPricing?: Record<string, { input: number; output: number }>,
|
||||
): number {
|
||||
// Check custom pricing first
|
||||
if (customPricing && customPricing[model]) {
|
||||
const cp = customPricing[model];
|
||||
return (inputTokens / 1_000_000) * cp.input + (outputTokens / 1_000_000) * cp.output;
|
||||
}
|
||||
|
||||
// Normalize model name roughly for default matching
|
||||
const normalizedModel = model.toLowerCase().replace('models/', '');
|
||||
let pricing = MODEL_PRICING['default'];
|
||||
|
||||
for (const [key, value] of Object.entries(MODEL_PRICING)) {
|
||||
if (normalizedModel.includes(key)) {
|
||||
pricing = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
|
||||
}
|
||||
@@ -41,6 +41,12 @@ export const AppConfigSchema = z.object({
|
||||
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
||||
provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI
|
||||
grid_layout: z.enum(['auto', '2-col', '3-col', 'list', 'compact']).default('auto'), // Account card grid layout
|
||||
custom_model_pricing: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({ input: z.number(), output: z.number(), source: z.string().optional() }),
|
||||
)
|
||||
.default({}),
|
||||
account_sort: z
|
||||
.enum(['recently-used', 'quota-overall', 'quota-claude', 'quota-pro3', 'quota-flash'])
|
||||
.default('recently-used'),
|
||||
@@ -67,6 +73,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
model_visibility: {}, // Model visibility preferences
|
||||
provider_groupings_enabled: false, // Enable provider groupings UI
|
||||
grid_layout: 'auto' as const, // Account card grid layout
|
||||
custom_model_pricing: {},
|
||||
account_sort: 'recently-used' as const,
|
||||
quota_alert_enabled: false,
|
||||
quota_alert_threshold: 20,
|
||||
|
||||
Reference in new issue
Block a user