From bc69a2e8b52e5019c45faae12771af7e40d3f26b Mon Sep 17 00:00:00 2001 From: Gemini Nexus Bot Date: Sat, 2 May 2026 00:29:08 -0300 Subject: [PATCH] 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 --- src/components/usage/CostReportCard.tsx | 130 +++++++++ src/components/usage/ModelPricingDialog.tsx | 276 ++++++++++++++++++++ src/constants/pricing.ts | 36 +++ src/types/config.ts | 7 + 4 files changed, 449 insertions(+) create mode 100644 src/components/usage/CostReportCard.tsx create mode 100644 src/components/usage/ModelPricingDialog.tsx create mode 100644 src/constants/pricing.ts diff --git a/src/components/usage/CostReportCard.tsx b/src/components/usage/CostReportCard.tsx new file mode 100644 index 0000000..4e6a143 --- /dev/null +++ b/src/components/usage/CostReportCard.tsx @@ -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 = ({ + models, + totalInputCost, + totalOutputCost, + totalCost, + isLoading, +}) => { + if (isLoading) { + return ( + + + Cost Report + + +
+ Loading cost data... +
+
+
+ ); + } + + if (!models.length) { + return ( + + + Cost Report + + +
+ No usage data for cost calculation +
+
+
+ ); + } + + return ( + + + Cost Report + + +
+ + + + + + + + + + + + + {models.map((m) => ( + + + + + + + + + ))} + + + + + + + + + + + +
ModelInput TokensInput CostOutput TokensOutput CostTotal
{m.model} + {formatNumber(m.inputTokens)} + + ${m.inputCost.toFixed(2)} + + {formatNumber(m.outputTokens)} + + ${m.outputCost.toFixed(2)} + + ${m.totalCost.toFixed(2)} +
Total + {formatNumber(models.reduce((sum, m) => sum + m.inputTokens, 0))} + + ${totalInputCost.toFixed(2)} + + {formatNumber(models.reduce((sum, m) => sum + m.outputTokens, 0))} + + ${totalOutputCost.toFixed(2)} + + ${totalCost.toFixed(2)} +
+
+
+
+ ); +}; diff --git a/src/components/usage/ModelPricingDialog.tsx b/src/components/usage/ModelPricingDialog.tsx new file mode 100644 index 0000000..dab5945 --- /dev/null +++ b/src/components/usage/ModelPricingDialog.tsx @@ -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; + onSavePricing: ( + pricing: Record, + ) => void; + availableModels: string[]; +} + +export const ModelPricingDialog: React.FC = ({ + open, + onOpenChange, + customPricing, + onSavePricing, + availableModels, +}) => { + const [localPricing, setLocalPricing] = useState< + Record + >({}); + const [isFetching, setIsFetching] = useState(false); + + useEffect(() => { + if (open) { + // Initialize local state with custom pricing or empty if default + const initial: Record = {}; + 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 ( + + + + Model Pricing Configuration + + Set custom pricing per model (USD per 1M tokens). These values are used to calculate + cost estimates. + + + +
+ +
+ +
+ + + + + + + + + + + + {availableModels.length === 0 ? ( + + + + ) : ( + availableModels.map((model) => { + const source = getSource(model); + return ( + + + + + + + + ); + }) + )} + +
ModelInput PriceOutput PriceSource
+ No models have been used yet. +
{model} + handlePriceChange(model, 'input', e.target.value)} + className="h-8 font-mono text-xs" + /> + + handlePriceChange(model, 'output', e.target.value)} + className="h-8 font-mono text-xs" + /> + + + {source} + + + {source !== 'Default' && ( + + )} +
+
+ + + + + +
+
+ ); +}; diff --git a/src/constants/pricing.ts b/src/constants/pricing.ts new file mode 100644 index 0000000..b8cdb06 --- /dev/null +++ b/src/constants/pricing.ts @@ -0,0 +1,36 @@ +// Costs in USD per 1M tokens +export const MODEL_PRICING: Record = { + '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, +): 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; +} diff --git a/src/types/config.ts b/src/types/config.ts index 1c88e0b..c02d06e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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,