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
|
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
||||||
provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI
|
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
|
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
|
account_sort: z
|
||||||
.enum(['recently-used', 'quota-overall', 'quota-claude', 'quota-pro3', 'quota-flash'])
|
.enum(['recently-used', 'quota-overall', 'quota-claude', 'quota-pro3', 'quota-flash'])
|
||||||
.default('recently-used'),
|
.default('recently-used'),
|
||||||
@@ -67,6 +73,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
model_visibility: {}, // Model visibility preferences
|
model_visibility: {}, // Model visibility preferences
|
||||||
provider_groupings_enabled: false, // Enable provider groupings UI
|
provider_groupings_enabled: false, // Enable provider groupings UI
|
||||||
grid_layout: 'auto' as const, // Account card grid layout
|
grid_layout: 'auto' as const, // Account card grid layout
|
||||||
|
custom_model_pricing: {},
|
||||||
account_sort: 'recently-used' as const,
|
account_sort: 'recently-used' as const,
|
||||||
quota_alert_enabled: false,
|
quota_alert_enabled: false,
|
||||||
quota_alert_threshold: 20,
|
quota_alert_threshold: 20,
|
||||||
|
|||||||
Reference in new issue
Block a user