feat(ui): add cost tracking to usage dashboard and pricing settings

- Add toggle view (Tokens/Costs) to UsageStats component
- Display cost breakdown in usage tables
- Add Total Cost card combined with Total Output Tokens
- Create PricingModal component for editing rates
- Create Pricing Settings page

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Catalin Stanciu 2026-01-07 00:00:56 +02:00 committed by decolua
parent a36afaa85e
commit f302c88dfb
3 changed files with 646 additions and 100 deletions

View file

@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Card from "@/shared/components/Card";
import PricingModal from "@/shared/components/PricingModal";
export default function PricingSettingsPage() {
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [currentPricing, setCurrentPricing] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadPricing();
}, []);
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setCurrentPricing(data);
}
} catch (error) {
console.error("Failed to load pricing:", error);
} finally {
setLoading(false);
}
};
const handlePricingUpdated = () => {
loadPricing();
};
// Count total models with pricing
const getModelCount = () => {
if (!currentPricing) return 0;
let count = 0;
for (const provider in currentPricing) {
count += Object.keys(currentPricing[provider]).length;
}
return count;
};
// Get providers list
const getProviders = () => {
if (!currentPricing) return [];
return Object.keys(currentPricing).sort();
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Pricing Settings</h1>
<p className="text-text-muted mt-1">
Configure pricing rates for cost tracking and calculations
</p>
</div>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 transition-colors"
>
Edit Pricing
</button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">
Total Models
</div>
<div className="text-2xl font-bold mt-1">
{loading ? "..." : getModelCount()}
</div>
</Card>
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">
Providers
</div>
<div className="text-2xl font-bold mt-1">
{loading ? "..." : getProviders().length}
</div>
</Card>
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">
Status
</div>
<div className="text-2xl font-bold mt-1 text-success">
{loading ? "..." : "Active"}
</div>
</Card>
</div>
{/* Info Section */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">How Pricing Works</h2>
<div className="space-y-3 text-sm text-text-muted">
<p>
<strong>Cost Calculation:</strong> Costs are calculated based on token usage and pricing rates.
Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)
</p>
<p>
<strong>Pricing Format:</strong> All rates are in <strong>dollars per million tokens</strong> ($/1M tokens).
Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.
</p>
<p>
<strong>Token Types:</strong>
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li><strong>Input:</strong> Standard prompt tokens</li>
<li><strong>Output:</strong> Completion/response tokens</li>
<li><strong>Cached:</strong> Cached input tokens (typically 50% of input rate)</li>
<li><strong>Reasoning:</strong> Special reasoning/thinking tokens (fallback to output rate)</li>
<li><strong>Cache Creation:</strong> Tokens used to create cache entries (fallback to input rate)</li>
</ul>
<p>
<strong>Custom Pricing:</strong> You can override default pricing for specific models.
Reset to defaults anytime to restore standard rates.
</p>
</div>
</Card>
{/* Current Pricing Preview */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Current Pricing Overview</h2>
<button
onClick={() => setShowModal(true)}
className="text-primary hover:underline text-sm"
>
View Full Details
</button>
</div>
{loading ? (
<div className="text-center py-4 text-text-muted">Loading pricing data...</div>
) : currentPricing ? (
<div className="space-y-3">
{Object.keys(currentPricing).slice(0, 5).map(provider => (
<div key={provider} className="text-sm">
<span className="font-semibold">{provider.toUpperCase()}:</span>{" "}
<span className="text-text-muted">
{Object.keys(currentPricing[provider]).length} models
</span>
</div>
))}
{Object.keys(currentPricing).length > 5 && (
<div className="text-sm text-text-muted">
+ {Object.keys(currentPricing).length - 5} more providers
</div>
)}
</div>
) : (
<div className="text-text-muted">No pricing data available</div>
)}
</Card>
{/* Pricing Modal */}
{showModal && (
<PricingModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSave={handlePricingUpdated}
/>
)}
</div>
);
}

View file

@ -0,0 +1,208 @@
"use client";
import { useState, useEffect } from "react";
import { getDefaultPricing, formatCost } from "@/shared/constants/pricing.js";
export default function PricingModal({ isOpen, onClose, onSave }) {
const [pricingData, setPricingData] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
loadPricing();
}
}, [isOpen]);
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
};
const handlePricingChange = (provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData(prev => {
const newData = { ...prev };
if (!newData[provider]) newData[provider] = {};
if (!newData[provider][model]) newData[provider][model] = {};
newData[provider][model][field] = numValue;
return newData;
});
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pricingData)
});
if (response.ok) {
onSave?.();
onClose();
} else {
const error = await response.json();
alert(`Failed to save pricing: ${error.error}`);
}
} catch (error) {
console.error("Failed to save pricing:", error);
alert("Failed to save pricing");
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm("Reset all pricing to defaults? This cannot be undone.")) return;
try {
const response = await fetch("/api/pricing", { method: "DELETE" });
if (response.ok) {
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to reset pricing:", error);
alert("Failed to reset pricing");
}
};
if (!isOpen) return null;
// Get all unique providers and models for display
const allProviders = Object.keys(pricingData).sort();
const pricingFields = ["input", "output", "cached", "reasoning", "cache_creation"];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-bg-base border border-border rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-semibold">Pricing Configuration</h2>
<button
onClick={onClose}
className="text-text-muted hover:text-text text-2xl leading-none"
>
×
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="text-center py-8 text-text-muted">Loading pricing data...</div>
) : (
<div className="space-y-6">
{/* Instructions */}
<div className="bg-bg-subtle border border-border rounded-lg p-3 text-sm">
<p className="font-medium mb-1">Pricing Rates Format</p>
<p className="text-text-muted">
All rates are in <strong>dollars per million tokens</strong> ($/1M tokens).
Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.
</p>
</div>
{/* Pricing Tables */}
{allProviders.map(provider => {
const models = Object.keys(pricingData[provider]).sort();
return (
<div key={provider} className="border border-border rounded-lg overflow-hidden">
<div className="bg-bg-subtle px-4 py-2 font-semibold text-sm">
{provider.toUpperCase()}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-bg-hover text-text-muted uppercase text-xs">
<tr>
<th className="px-3 py-2 text-left">Model</th>
<th className="px-3 py-2 text-right">Input</th>
<th className="px-3 py-2 text-right">Output</th>
<th className="px-3 py-2 text-right">Cached</th>
<th className="px-3 py-2 text-right">Reasoning</th>
<th className="px-3 py-2 text-right">Cache Creation</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{models.map(model => (
<tr key={model} className="hover:bg-bg-subtle/50">
<td className="px-3 py-2 font-medium">{model}</td>
{pricingFields.map(field => (
<td key={field} className="px-3 py-2">
<input
type="number"
step="0.01"
min="0"
value={pricingData[provider][model][field] || 0}
onChange={(e) => handlePricingChange(provider, model, field, e.target.value)}
className="w-20 px-2 py-1 text-right bg-bg-base border border-border rounded focus:outline-none focus:border-primary"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
{allProviders.length === 0 && (
<div className="text-center py-8 text-text-muted">
No pricing data available
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
<button
onClick={handleReset}
className="px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
disabled={saving}
>
Reset to Defaults
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-muted hover:text-text border border-border rounded transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
disabled={saving}
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -37,6 +37,7 @@ export default function UsageStats() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [viewMode, setViewMode] = useState("tokens"); // 'tokens' or 'costs'
const toggleSort = (field) => {
const params = new URLSearchParams(searchParams.toString());
@ -51,12 +52,31 @@ export default function UsageStats() {
const sortData = (dataMap, pendingMap = {}) => {
return Object.entries(dataMap || {})
.map(([key, data]) => ({
...data,
key,
totalTokens: (data.promptTokens || 0) + (data.completionTokens || 0),
pending: pendingMap[key] || 0,
}))
.map(([key, data]) => {
const totalTokens =
(data.promptTokens || 0) + (data.completionTokens || 0);
const totalCost = data.cost || 0;
// Calculate cost breakdown (estimated based on token ratio)
const inputCost =
totalTokens > 0
? (data.promptTokens || 0) * (totalCost / totalTokens)
: 0;
const outputCost =
totalTokens > 0
? (data.completionTokens || 0) * (totalCost / totalTokens)
: 0;
return {
...data,
key,
totalTokens,
totalCost,
inputCost,
outputCost,
pending: pendingMap[key] || 0,
};
})
.sort((a, b) => {
let valA = a[sortBy];
let valB = b[sortBy];
@ -133,6 +153,9 @@ export default function UsageStats() {
// Format number with commas
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
// Format cost with dollar sign and 2 decimals
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
// Time format for "Last Used"
const fmtTime = (iso) => {
if (!iso) return "Never";
@ -149,10 +172,35 @@ export default function UsageStats() {
return (
<div className="flex flex-col gap-6">
{/* Header with Auto Refresh Toggle */}
{/* Header with Auto Refresh Toggle and View Toggle */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Usage Overview</h2>
<div className="flex items-center gap-2">
{/* View Toggle */}
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
<button
onClick={() => setViewMode("tokens")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
viewMode === "tokens"
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("costs")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
viewMode === "costs"
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
Costs
</button>
</div>
{/* Auto Refresh Toggle */}
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
<span>Auto Refresh (1s)</span>
<div
@ -207,7 +255,7 @@ export default function UsageStats() {
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4 flex flex-col gap-1">
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">
@ -223,7 +271,7 @@ export default function UsageStats() {
/>
</div>
</Card>
<Card className="p-4 flex flex-col gap-1">
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">
@ -239,9 +287,9 @@ export default function UsageStats() {
/>
</div>
</Card>
<Card className="p-4 flex flex-col gap-1">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<Card className="px-4 py-2 flex flex-col gap-1">
<div className="flex justify-between items-start gap-4">
<div className="flex flex-col gap-1 flex-1">
<span className="text-text-muted text-sm uppercase font-semibold">
Total Output Tokens
</span>
@ -249,10 +297,15 @@ export default function UsageStats() {
{fmt(stats.totalCompletionTokens)}
</span>
</div>
<MiniBarGraph
data={(stats.last10Minutes || []).map((m) => m.completionTokens)}
colorClass="bg-success/50"
/>
<div className="w-px bg-border self-stretch mx-2" />
<div className="flex flex-col gap-1 flex-1">
<span className="text-text-muted text-sm uppercase font-semibold">
Total Cost
</span>
<span className="text-2xl font-bold text-warning">
{fmtCost(stats.totalCost)}
</span>
</div>
</div>
</Card>
</div>
@ -310,39 +363,79 @@ export default function UsageStats() {
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Tokens{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Tokens{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIcon
field="totalTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
{viewMode === "tokens" ? (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Tokens{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Tokens{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIcon
field="totalTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
</>
) : (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Cost{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Cost{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("cost")}
>
Total Cost{" "}
<SortIcon
field="cost"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
</>
)}
</tr>
</thead>
<tbody className="divide-y divide-border">
@ -367,15 +460,31 @@ export default function UsageStats() {
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(data.lastUsed)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(data.totalTokens)}
</td>
{viewMode === "tokens" ? (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(data.totalTokens)}
</td>
</>
) : (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.inputCost)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.outputCost)}
</td>
<td className="px-6 py-3 text-right font-medium text-warning">
{fmtCost(data.totalCost)}
</td>
</>
)}
</tr>
))}
{sortedModels.length === 0 && (
@ -457,39 +566,79 @@ export default function UsageStats() {
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Tokens{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Tokens{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIcon
field="totalTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
{viewMode === "tokens" ? (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Tokens{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Tokens{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIcon
field="totalTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
</>
) : (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Cost{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Cost{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("cost")}
>
Total Cost{" "}
<SortIcon
field="cost"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
</>
)}
</tr>
</thead>
<tbody className="divide-y divide-border">
@ -524,15 +673,31 @@ export default function UsageStats() {
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(data.lastUsed)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(data.totalTokens)}
</td>
{viewMode === "tokens" ? (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(data.totalTokens)}
</td>
</>
) : (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.inputCost)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.outputCost)}
</td>
<td className="px-6 py-3 text-right font-medium text-warning">
{fmtCost(data.totalCost)}
</td>
</>
)}
</tr>
))}
{sortedAccounts.length === 0 && (