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:
Gemini Nexus Bot committed 2026-05-02 00:29:08 -03:00
1 parent 20785a8414
commit bc69a2e8b5
4 files changed
+449

No files matched your search

+130
View File
@@ -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>
);
};
+276
View File
@@ -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>
);
};
+36
View File
@@ -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;
}
+7
View File
@@ -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,