feat: implement usage tracking for AI requests

Adds local token usage tracking for all AI providers. Usage data is
captured during stream processing and stored in a local database.
Includes a new Usage tab in the Providers dashboard to visualize
historical token consumption.

🤖 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-09 17:23:28 +07:00
committed by decolua
co-authored by Claude Sonnet 4.5
parent 5645d0a0fb
commit 9c3d6f4ad8
12 changed files with 7460 additions and 81 deletions
+67 -37
View File
@@ -1,13 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardSkeleton, Badge } from "@/shared/components";
import { Card, CardSkeleton, Badge, UsageStats } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import Image from "next/image";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
export default function ProvidersPage() {
const [activeTab, setActiveTab] = useState("connections");
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
@@ -31,33 +31,33 @@ export default function ProvidersPage() {
const providerConnections = connections.filter(
c => c.provider === providerId && c.authType === authType
);
// Helper: check if connection is effectively active (cooldown expired)
const getEffectiveStatus = (conn) => {
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
};
const connected = providerConnections.filter(c => {
const status = getEffectiveStatus(c);
return status === "active" || status === "success";
}).length;
const errorConns = providerConnections.filter(c => {
const status = getEffectiveStatus(c);
return status === "error" || status === "expired" || status === "unavailable";
});
const error = errorConns.length;
const total = providerConnections.length;
// Get latest error info
const latestError = errorConns.sort((a, b) =>
const latestError = errorConns.sort((a, b) =>
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
)[0];
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
return { connected, error, total, errorCode, errorTime };
};
@@ -71,36 +71,66 @@ export default function ProvidersPage() {
}
return (
<div className="flex flex-col gap-8">
{/* OAuth Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">OAuth Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
<ProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
/>
))}
</div>
<div className="flex flex-col gap-6">
{/* Tabs */}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("connections")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "connections"
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-primary"
}`}
>
Connections
</button>
<button
onClick={() => setActiveTab("usage")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "usage"
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-primary"
}`}
>
Usage
</button>
</div>
{/* API Key Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">API Key Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
<ApiKeyProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "apikey")}
/>
))}
</div>
</div>
{activeTab === "usage" ? (
<UsageStats />
) : (
<>
{/* OAuth Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">OAuth Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
<ProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
/>
))}
</div>
</div>
{/* API Key Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">API Key Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
<ApiKeyProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "apikey")}
/>
))}
</div>
</div>
</>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { getUsageStats } from "@/lib/usageDb";
export async function GET() {
try {
const stats = await getUsageStats();
return NextResponse.json(stats);
} catch (error) {
console.error("Error fetching usage stats:", error);
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
}
}
+266
View File
@@ -0,0 +1,266 @@
import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import path from "path";
import os from "os";
import fs from "fs";
import { fileURLToPath } from "url";
// Get app name from root package.json config
function getAppName() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Look for root package.json (monorepo root)
const rootPkgPath = path.resolve(__dirname, "../../../package.json");
try {
const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8"));
return pkg.config?.appName || "9router";
} catch {
return "9router";
}
}
// Get user data directory based on platform
function getUserDataDir() {
const platform = process.platform;
const homeDir = os.homedir();
const appName = getAppName();
if (platform === "win32") {
return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName);
} else {
// macOS & Linux: ~/.{appName}
return path.join(homeDir, `.${appName}`);
}
}
// Data file path - stored in user home directory
const DATA_DIR = getUserDataDir();
const DB_FILE = path.join(DATA_DIR, "usage.json");
// Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// Default data structure
const defaultData = {
history: []
};
// Singleton instance
let dbInstance = null;
/**
* Get usage database instance (singleton)
*/
export async function getUsageDb() {
if (!dbInstance) {
const adapter = new JSONFile(DB_FILE);
dbInstance = new Low(adapter, defaultData);
// Try to read DB with error recovery for corrupt JSON
try {
await dbInstance.read();
} catch (error) {
if (error instanceof SyntaxError) {
console.warn('[DB] Corrupt Usage JSON detected, resetting to defaults...');
dbInstance.data = defaultData;
await dbInstance.write();
} else {
throw error;
}
}
// Initialize with default data if empty
if (!dbInstance.data) {
dbInstance.data = defaultData;
await dbInstance.write();
}
}
return dbInstance;
}
/**
* Save request usage
* @param {object} entry - Usage entry { provider, model, tokens: { prompt_tokens, completion_tokens, ... }, connectionId? }
*/
export async function saveRequestUsage(entry) {
try {
const db = await getUsageDb();
// Add timestamp if not present
if (!entry.timestamp) {
entry.timestamp = new Date().toISOString();
}
// Ensure history array exists
if (!Array.isArray(db.data.history)) {
db.data.history = [];
}
db.data.history.push(entry);
// Optional: Limit history size if needed in future
// if (db.data.history.length > 10000) db.data.history.shift();
await db.write();
} catch (error) {
console.error("Failed to save usage stats:", error);
}
}
/**
* Get usage history
* @param {object} filter - Filter criteria
*/
export async function getUsageHistory(filter = {}) {
const db = await getUsageDb();
let history = db.data.history || [];
// Apply filters
if (filter.provider) {
history = history.filter(h => h.provider === filter.provider);
}
if (filter.model) {
history = history.filter(h => h.model === filter.model);
}
if (filter.startDate) {
const start = new Date(filter.startDate).getTime();
history = history.filter(h => new Date(h.timestamp).getTime() >= start);
}
if (filter.endDate) {
const end = new Date(filter.endDate).getTime();
history = history.filter(h => new Date(h.timestamp).getTime() <= end);
}
return history;
}
/**
* Get aggregated usage stats
*/
export async function getUsageStats() {
const db = await getUsageDb();
const history = db.data.history || [];
// Import localDb to get provider connection names
const { getProviderConnections } = await import("@/lib/localDb.js");
// Fetch all provider connections to get account names
let allConnections = [];
try {
allConnections = await getProviderConnections();
} catch (error) {
// If localDb is not available (e.g., in some environments), continue without account names
console.warn("Could not fetch provider connections for usage stats:", error.message);
}
// Create a map from connectionId to account name
const connectionMap = {};
for (const conn of allConnections) {
connectionMap[conn.id] = conn.name || conn.email || conn.id;
}
const stats = {
totalRequests: history.length,
totalPromptTokens: 0,
totalCompletionTokens: 0,
byProvider: {},
byModel: {},
byAccount: {},
last10Minutes: []
};
// Initialize 10-minute buckets using stable minute boundaries
const now = new Date();
// Floor to the start of the current minute
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
// Create buckets keyed by minute timestamp for stable lookups
const bucketMap = {};
for (let i = 0; i < 10; i++) {
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
const bucketKey = bucketTime.getTime();
bucketMap[bucketKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0
};
stats.last10Minutes.push(bucketMap[bucketKey]);
}
for (const entry of history) {
const promptTokens = entry.tokens?.prompt_tokens || 0;
const completionTokens = entry.tokens?.completion_tokens || 0;
const entryTime = new Date(entry.timestamp);
stats.totalPromptTokens += promptTokens;
stats.totalCompletionTokens += completionTokens;
// Last 10 minutes aggregation - floor entry time to its minute
if (entryTime >= tenMinutesAgo && entryTime <= now) {
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
if (bucketMap[entryMinuteStart]) {
bucketMap[entryMinuteStart].requests++;
bucketMap[entryMinuteStart].promptTokens += promptTokens;
bucketMap[entryMinuteStart].completionTokens += completionTokens;
}
}
// By Provider
if (!stats.byProvider[entry.provider]) {
stats.byProvider[entry.provider] = {
requests: 0,
promptTokens: 0,
completionTokens: 0
};
}
stats.byProvider[entry.provider].requests++;
stats.byProvider[entry.provider].promptTokens += promptTokens;
stats.byProvider[entry.provider].completionTokens += completionTokens;
// By Model
// Format: "modelName (provider)" if provider is known
const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model;
if (!stats.byModel[modelKey]) {
stats.byModel[modelKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
rawModel: entry.model,
provider: entry.provider
};
}
stats.byModel[modelKey].requests++;
stats.byModel[modelKey].promptTokens += promptTokens;
stats.byModel[modelKey].completionTokens += completionTokens;
// By Account (model + oauth account)
// Use connectionId if available, otherwise fallback to provider name
if (entry.connectionId) {
const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`;
const accountKey = `${entry.model} (${entry.provider} - ${accountName})`;
if (!stats.byAccount[accountKey]) {
stats.byAccount[accountKey] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
rawModel: entry.model,
provider: entry.provider,
connectionId: entry.connectionId,
accountName: accountName
};
}
stats.byAccount[accountKey].requests++;
stats.byAccount[accountKey].promptTokens += promptTokens;
stats.byAccount[accountKey].completionTokens += completionTokens;
}
}
return stats;
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useState, useEffect } from "react";
import Card from "./Card";
import Badge from "./Badge";
import { CardSkeleton } from "./Loading";
function MiniBarGraph({ data, colorClass = "bg-primary" }) {
const max = Math.max(...data, 1);
return (
<div className="flex items-end gap-1 h-8 w-24">
{data.slice(-9).map((val, i) => (
<div
key={i}
className={`flex-1 rounded-t-sm transition-all duration-500 ${colorClass}`}
style={{ height: `${Math.max((val / max) * 100, 5)}%` }}
title={val}
/>
))}
</div>
);
}
export default function UsageStats() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(false);
useEffect(() => {
fetchStats();
}, []);
useEffect(() => {
let interval;
if (autoRefresh) {
interval = setInterval(() => {
fetchStats(false); // fetch without loading skeleton
}, 5000);
}
return () => clearInterval(interval);
}, [autoRefresh]);
const fetchStats = async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const res = await fetch("/api/usage/history");
if (res.ok) {
const data = await res.json();
setStats(data);
}
} catch (error) {
console.error("Failed to fetch usage stats:", error);
} finally {
if (showLoading) setLoading(false);
}
};
if (loading) return <CardSkeleton />;
if (!stats) return <div className="text-text-muted">Failed to load usage statistics.</div>;
// Format number with commas
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
return (
<div className="flex flex-col gap-6">
{/* Header with Auto Refresh Toggle */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Usage Overview</h2>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
<span>Auto Refresh (5s)</span>
<div
onClick={() => setAutoRefresh(!autoRefresh)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${autoRefresh ? 'bg-primary' : 'bg-bg-subtle border border-border'}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${autoRefresh ? 'translate-x-5' : 'translate-x-1'}`}
/>
</div>
</label>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4 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">Total Requests</span>
<span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span>
</div>
<MiniBarGraph
data={(stats.last10Minutes || []).map(m => m.requests)}
colorClass="bg-text-muted/30"
/>
</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">
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
<span className="text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
</div>
<MiniBarGraph
data={(stats.last10Minutes || []).map(m => m.promptTokens)}
colorClass="bg-primary/50"
/>
</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">
<span className="text-text-muted text-sm uppercase font-semibold">Total Output Tokens</span>
<span className="text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
</div>
<MiniBarGraph
data={(stats.last10Minutes || []).map(m => m.completionTokens)}
colorClass="bg-success/50"
/>
</div>
</Card>
</div>
{/* Usage by Model Table */}
<Card className="overflow-hidden">
<div className="p-4 border-b border-border bg-bg-subtle/50">
<h3 className="font-semibold">Usage by Model</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
<tr>
<th className="px-6 py-3">Model</th>
<th className="px-6 py-3">Provider</th>
<th className="px-6 py-3 text-right">Requests</th>
<th className="px-6 py-3 text-right">Input Tokens</th>
<th className="px-6 py-3 text-right">Output Tokens</th>
<th className="px-6 py-3 text-right">Total Tokens</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(stats.byModel || {}).map(([key, data]) => (
<tr key={key} className="hover:bg-bg-subtle/20">
<td className="px-6 py-3 font-medium">{data.rawModel}</td>
<td className="px-6 py-3">
<Badge variant="neutral" size="sm">{data.provider}</Badge>
</td>
<td className="px-6 py-3 text-right">{fmt(data.requests)}</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.promptTokens + data.completionTokens)}</td>
</tr>
))}
{Object.keys(stats.byModel || {}).length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-text-muted">
No usage recorded yet. Make some requests to see data here.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
{/* Usage by Account Table */}
<Card className="overflow-hidden">
<div className="p-4 border-b border-border bg-bg-subtle/50">
<h3 className="font-semibold">Usage by Account</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
<tr>
<th className="px-6 py-3">Model</th>
<th className="px-6 py-3">Provider</th>
<th className="px-6 py-3">Account</th>
<th className="px-6 py-3 text-right">Requests</th>
<th className="px-6 py-3 text-right">Input Tokens</th>
<th className="px-6 py-3 text-right">Output Tokens</th>
<th className="px-6 py-3 text-right">Total Tokens</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(stats.byAccount || {}).map(([key, data]) => (
<tr key={key} className="hover:bg-bg-subtle/20">
<td className="px-6 py-3 font-medium">{data.rawModel}</td>
<td className="px-6 py-3">
<Badge variant="neutral" size="sm">{data.provider}</Badge>
</td>
<td className="px-6 py-3">
<span className="font-medium">{data.accountName || `Account ${data.connectionId?.slice(0, 8)}...`}</span>
</td>
<td className="px-6 py-3 text-right">{fmt(data.requests)}</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.promptTokens + data.completionTokens)}</td>
</tr>
))}
{Object.keys(stats.byAccount || {}).length === 0 && (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-text-muted">
No account-specific usage recorded yet. Make requests using OAuth accounts to see data here.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}
+1
View File
@@ -15,6 +15,7 @@ export { default as Header } from "./Header";
export { default as Footer } from "./Footer";
export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as UsageStats } from "./UsageStats";
// Layouts
export * from "./layouts";
+4 -4
View File
@@ -1,5 +1,5 @@
// Re-export from open-sse (single source of truth)
export {
// Import directly from file to avoid pulling in server-side dependencies via index.js
export {
PROVIDER_MODELS,
getProviderModels,
getDefaultModel,
@@ -8,10 +8,10 @@ export {
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
getModelsByProviderId
} from "open-sse";
} from "open-sse/config/providerModels.js";
import { AI_PROVIDERS } from "./providers.js";
import { PROVIDER_MODELS as MODELS } from "open-sse";
import { PROVIDER_MODELS as MODELS } from "open-sse/config/providerModels.js";
// Providers that accept any model (passthrough)
const PASSTHROUGH_PROVIDERS = new Set(
+1
View File
@@ -120,6 +120,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null) {
credentials: refreshedCredentials,
log,
clientRawRequest,
connectionId: credentials.connectionId,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,