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:
parent
a36afaa85e
commit
f302c88dfb
3 changed files with 646 additions and 100 deletions
173
src/app/dashboard/settings/pricing/page.js
Normal file
173
src/app/dashboard/settings/pricing/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/shared/components/PricingModal.js
Normal file
208
src/shared/components/PricingModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue