Enhance payment and analytics tracking by adding input and output token metrics across various components

This commit is contained in:
Evan Yang
2026-03-13 15:55:21 +08:00
parent cb22968ff3
commit 9bc3feff62
6 changed files with 1217 additions and 75 deletions
+4
View File
@@ -776,6 +776,8 @@ async def adjust_payment_for_tokens(
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"new_balance": billing_key.balance,
"model": model,
},
@@ -799,6 +801,8 @@ async def adjust_payment_for_tokens(
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
},
)
+2 -2
View File
@@ -13,11 +13,11 @@ DO NOT modify or remove these messages without updating the usage tracking logic
2. "Calculated token-based cost" (INFO) - routstr/auth.py
- Used to track successful completions and revenue
- The 'token_cost' and 'model' fields are extracted for dashboard metrics
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Max cost payment finalized" (INFO) - routstr/auth.py
- Used as the successful completion fallback when token usage is unavailable
- The 'charged_amount' and 'model' fields are extracted for dashboard metrics
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
+331 -27
View File
@@ -21,7 +21,7 @@ class UsageAnalyticsStore:
bytes.
"""
SCHEMA_VERSION = "3"
SCHEMA_VERSION = "4"
def __init__(self, logs_dir: Path, db_path: Path | None = None):
self.logs_dir = logs_dir
@@ -60,12 +60,20 @@ class UsageAnalyticsStore:
cutoff_timestamp=cutoff_timestamp,
limit=model_limit,
)
model_usage_mix = self._query_model_usage_mix_locked(
conn,
cutoff_timestamp=cutoff_timestamp,
interval_minutes=interval_minutes,
hours_back=hours_back,
limit=model_limit,
)
return {
"metrics": metrics,
"summary": summary,
"error_details": error_details,
"revenue_by_model": revenue_by_model,
"model_usage_mix": model_usage_mix,
}
def get_summary(self, *, hours_back: int) -> dict[str, Any]:
@@ -147,15 +155,6 @@ class UsageAnalyticsStore:
"SELECT value FROM analytics_meta WHERE key = 'schema_version'"
).fetchone()
current_version = current_version_row[0] if current_version_row else None
if current_version != self.SCHEMA_VERSION:
self._drop_index_tables_locked(conn)
conn.execute(
"""
INSERT OR REPLACE INTO analytics_meta (key, value)
VALUES ('schema_version', ?)
""",
(self.SCHEMA_VERSION,),
)
conn.execute(
"""
@@ -181,7 +180,10 @@ class UsageAnalyticsStore:
payment_processed INTEGER NOT NULL DEFAULT 0,
upstream_errors INTEGER NOT NULL DEFAULT 0,
revenue_msats REAL NOT NULL DEFAULT 0,
refunds_msats REAL NOT NULL DEFAULT 0
refunds_msats REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0
)
"""
)
@@ -195,6 +197,9 @@ class UsageAnalyticsStore:
failed INTEGER NOT NULL DEFAULT 0,
revenue_msats REAL NOT NULL DEFAULT 0,
refunds_msats REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (minute_ts, model)
)
"""
@@ -234,6 +239,9 @@ class UsageAnalyticsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_analytics_model_minute_ts ON analytics_model_minute (minute_ts)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_analytics_model_minute_model_ts ON analytics_model_minute (model, minute_ts)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_analytics_model_presence_ts ON analytics_model_presence_minute (minute_ts)"
)
@@ -243,8 +251,74 @@ class UsageAnalyticsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_analytics_error_events_ts ON analytics_error_events (timestamp DESC)"
)
self._migrate_schema_locked(conn)
if current_version != self.SCHEMA_VERSION:
conn.execute(
"""
INSERT OR REPLACE INTO analytics_meta (key, value)
VALUES ('schema_version', ?)
""",
(self.SCHEMA_VERSION,),
)
conn.commit()
def _migrate_schema_locked(self, conn: sqlite3.Connection) -> None:
self._ensure_column_locked(
conn,
"analytics_minute",
"input_tokens",
"INTEGER NOT NULL DEFAULT 0",
)
self._ensure_column_locked(
conn,
"analytics_minute",
"output_tokens",
"INTEGER NOT NULL DEFAULT 0",
)
self._ensure_column_locked(
conn,
"analytics_minute",
"total_tokens",
"INTEGER NOT NULL DEFAULT 0",
)
self._ensure_column_locked(
conn,
"analytics_model_minute",
"input_tokens",
"INTEGER NOT NULL DEFAULT 0",
)
self._ensure_column_locked(
conn,
"analytics_model_minute",
"output_tokens",
"INTEGER NOT NULL DEFAULT 0",
)
self._ensure_column_locked(
conn,
"analytics_model_minute",
"total_tokens",
"INTEGER NOT NULL DEFAULT 0",
)
def _ensure_column_locked(
self,
conn: sqlite3.Connection,
table: str,
column: str,
column_definition: str,
) -> None:
existing_columns = {
str(row["name"])
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
}
if column in existing_columns:
return
conn.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {column_definition}"
)
logger.info(f"Migrated analytics schema: added {table}.{column}")
def _drop_index_tables_locked(self, conn: sqlite3.Connection) -> None:
conn.execute("DROP TABLE IF EXISTS analytics_file_state")
conn.execute("DROP TABLE IF EXISTS analytics_minute")
@@ -473,13 +547,21 @@ class UsageAnalyticsStore:
elif level == "WARNING":
bucket["warnings"] += 1
completed, revenue_msats = self._extract_success_metrics(entry, message)
completed, revenue_msats, input_tokens, output_tokens = (
self._extract_success_metrics(entry, message)
)
if completed:
bucket["total_requests"] += 1
bucket["successful_chat_completions"] += 1
model_bucket = model_updates[(minute_key, model)]
model_bucket["requests"] += 1
model_bucket["successful"] += 1
bucket["input_tokens"] += input_tokens
bucket["output_tokens"] += output_tokens
bucket["total_tokens"] += input_tokens + output_tokens
model_bucket["input_tokens"] += input_tokens
model_bucket["output_tokens"] += output_tokens
model_bucket["total_tokens"] += input_tokens + output_tokens
if revenue_msats > 0:
bucket["revenue_msats"] += revenue_msats
@@ -547,6 +629,9 @@ class UsageAnalyticsStore:
int(stats["upstream_errors"]),
float(stats["revenue_msats"]),
float(stats["refunds_msats"]),
int(stats["input_tokens"]),
int(stats["output_tokens"]),
int(stats["total_tokens"]),
)
for minute_ts, stats in minute_updates.items()
]
@@ -563,9 +648,12 @@ class UsageAnalyticsStore:
payment_processed,
upstream_errors,
revenue_msats,
refunds_msats
refunds_msats,
input_tokens,
output_tokens,
total_tokens
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(minute_ts) DO UPDATE SET
total_entries = total_entries + excluded.total_entries,
total_requests = total_requests + excluded.total_requests,
@@ -576,7 +664,10 @@ class UsageAnalyticsStore:
payment_processed = payment_processed + excluded.payment_processed,
upstream_errors = upstream_errors + excluded.upstream_errors,
revenue_msats = revenue_msats + excluded.revenue_msats,
refunds_msats = refunds_msats + excluded.refunds_msats
refunds_msats = refunds_msats + excluded.refunds_msats,
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
total_tokens = total_tokens + excluded.total_tokens
""",
rows,
)
@@ -591,6 +682,9 @@ class UsageAnalyticsStore:
int(stats["failed"]),
float(stats["revenue_msats"]),
float(stats["refunds_msats"]),
int(stats["input_tokens"]),
int(stats["output_tokens"]),
int(stats["total_tokens"]),
)
for (minute_ts, model), stats in model_updates.items()
]
@@ -603,15 +697,21 @@ class UsageAnalyticsStore:
successful,
failed,
revenue_msats,
refunds_msats
refunds_msats,
input_tokens,
output_tokens,
total_tokens
)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(minute_ts, model) DO UPDATE SET
requests = requests + excluded.requests,
successful = successful + excluded.successful,
failed = failed + excluded.failed,
revenue_msats = revenue_msats + excluded.revenue_msats,
refunds_msats = refunds_msats + excluded.refunds_msats
refunds_msats = refunds_msats + excluded.refunds_msats,
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
total_tokens = total_tokens + excluded.total_tokens
""",
rows,
)
@@ -694,7 +794,10 @@ class UsageAnalyticsStore:
COALESCE(SUM(payment_processed), 0) AS payment_processed,
COALESCE(SUM(upstream_errors), 0) AS upstream_errors,
COALESCE(SUM(revenue_msats), 0) AS revenue_msats,
COALESCE(SUM(refunds_msats), 0) AS refunds_msats
COALESCE(SUM(refunds_msats), 0) AS refunds_msats,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(total_tokens), 0) AS total_tokens
FROM analytics_minute
WHERE minute_ts >= ?
GROUP BY bucket_ts
@@ -713,6 +816,9 @@ class UsageAnalyticsStore:
"upstream_errors": 0.0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
"input_tokens": 0.0,
"output_tokens": 0.0,
"total_tokens": 0.0,
}
points: list[dict[str, Any]] = []
@@ -726,6 +832,9 @@ class UsageAnalyticsStore:
upstream_errors = int(row["upstream_errors"])
revenue_msats = float(row["revenue_msats"])
refunds_msats = float(row["refunds_msats"])
input_tokens = int(row["input_tokens"])
output_tokens = int(row["output_tokens"])
total_tokens = int(row["total_tokens"])
totals["total_requests"] += total_requests
totals["successful_chat_completions"] += successful
@@ -736,6 +845,9 @@ class UsageAnalyticsStore:
totals["upstream_errors"] += upstream_errors
totals["revenue_msats"] += revenue_msats
totals["refunds_msats"] += refunds_msats
totals["input_tokens"] += input_tokens
totals["output_tokens"] += output_tokens
totals["total_tokens"] += total_tokens
points.append(
{
@@ -749,6 +861,9 @@ class UsageAnalyticsStore:
"upstream_errors": upstream_errors,
"revenue_msats": revenue_msats,
"refunds_msats": refunds_msats,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"requests": total_requests,
}
)
@@ -763,6 +878,9 @@ class UsageAnalyticsStore:
"upstream_errors": int(totals["upstream_errors"]),
"revenue_msats": float(totals["revenue_msats"]),
"refunds_msats": float(totals["refunds_msats"]),
"input_tokens": int(totals["input_tokens"]),
"output_tokens": int(totals["output_tokens"]),
"total_tokens": int(totals["total_tokens"]),
}
return {
@@ -788,7 +906,10 @@ class UsageAnalyticsStore:
COALESCE(SUM(payment_processed), 0) AS payment_processed,
COALESCE(SUM(upstream_errors), 0) AS upstream_errors,
COALESCE(SUM(revenue_msats), 0) AS revenue_msats,
COALESCE(SUM(refunds_msats), 0) AS refunds_msats
COALESCE(SUM(refunds_msats), 0) AS refunds_msats,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(total_tokens), 0) AS total_tokens
FROM analytics_minute
WHERE minute_ts >= ?
""",
@@ -824,6 +945,9 @@ class UsageAnalyticsStore:
total_requests = int(totals["total_requests"])
successful = int(totals["successful_chat_completions"])
failed_requests = int(totals["failed_requests"])
input_tokens = int(totals["input_tokens"])
output_tokens = int(totals["output_tokens"])
total_tokens = int(totals["total_tokens"])
revenue_msats = float(totals["revenue_msats"])
refunds_msats = float(totals["refunds_msats"])
@@ -845,6 +969,18 @@ class UsageAnalyticsStore:
"unique_models_count": len(unique_models),
"unique_models": unique_models,
"error_types": error_types,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"avg_input_tokens_per_completion": (input_tokens / successful)
if successful > 0
else 0,
"avg_output_tokens_per_completion": (output_tokens / successful)
if successful > 0
else 0,
"avg_total_tokens_per_completion": (total_tokens / successful)
if successful > 0
else 0,
"success_rate": (successful / total_requests * 100)
if total_requests > 0
else 0,
@@ -970,6 +1106,151 @@ class UsageAnalyticsStore:
"total_models": len(models),
}
def _query_model_usage_mix_locked(
self,
conn: sqlite3.Connection,
*,
cutoff_timestamp: str,
interval_minutes: int,
hours_back: int,
limit: int,
) -> dict[str, Any]:
top_limit = max(1, min(int(limit), 10))
top_rows = conn.execute(
"""
SELECT
model,
COALESCE(SUM(successful), 0) AS total_successful
FROM analytics_model_minute
WHERE minute_ts >= ?
AND model != 'unknown'
GROUP BY model
ORDER BY total_successful DESC
LIMIT ?
""",
(cutoff_timestamp, top_limit),
).fetchall()
top_models = [
str(row["model"])
for row in top_rows
if int(row["total_successful"] or 0) > 0
]
bucket_seconds = max(60, int(interval_minutes) * 60)
total_rows = conn.execute(
"""
SELECT
datetime(
(CAST(strftime('%s', minute_ts) AS INTEGER) / ?) * ?,
'unixepoch'
) AS bucket_ts,
COALESCE(SUM(successful), 0) AS total_successful,
COALESCE(SUM(revenue_msats), 0) AS total_revenue_msats,
COALESCE(SUM(total_tokens), 0) AS total_tokens
FROM analytics_model_minute
WHERE minute_ts >= ?
GROUP BY bucket_ts
ORDER BY bucket_ts
""",
(bucket_seconds, bucket_seconds, cutoff_timestamp),
).fetchall()
bucket_index: dict[str, dict[str, Any]] = {}
for row in total_rows:
total_successful = int(row["total_successful"])
total_revenue_msats = float(row["total_revenue_msats"])
total_tokens = int(row["total_tokens"])
if (
total_successful <= 0
and total_revenue_msats <= 0
and total_tokens <= 0
):
continue
bucket_ts = str(row["bucket_ts"])
bucket = bucket_index.setdefault(
bucket_ts,
{
"timestamp": bucket_ts,
"total_successful": 0,
"total_revenue_msats": 0.0,
"total_tokens": 0,
"others": 0,
"others_revenue_msats": 0.0,
"others_tokens": 0,
"model_counts": {},
"model_revenue_msats": {},
"model_tokens": {},
},
)
bucket["total_successful"] = total_successful
bucket["total_revenue_msats"] = total_revenue_msats
bucket["total_tokens"] = total_tokens
bucket["others"] = total_successful
bucket["others_revenue_msats"] = total_revenue_msats
bucket["others_tokens"] = total_tokens
if top_models and bucket_index:
placeholders = ",".join("?" for _ in top_models)
top_model_rows = conn.execute(
f"""
SELECT
datetime(
(CAST(strftime('%s', minute_ts) AS INTEGER) / ?) * ?,
'unixepoch'
) AS bucket_ts,
model,
COALESCE(SUM(successful), 0) AS successful,
COALESCE(SUM(revenue_msats), 0) AS revenue_msats,
COALESCE(SUM(total_tokens), 0) AS total_tokens
FROM analytics_model_minute
WHERE minute_ts >= ?
AND model IN ({placeholders})
GROUP BY bucket_ts, model
ORDER BY bucket_ts
""",
(bucket_seconds, bucket_seconds, cutoff_timestamp, *top_models),
).fetchall()
for row in top_model_rows:
bucket_ts = str(row["bucket_ts"])
bucket = bucket_index.get(bucket_ts)
if bucket is None:
continue
model = str(row["model"])
successful = int(row["successful"])
revenue_msats = float(row["revenue_msats"])
total_tokens = int(row["total_tokens"])
model_counts = bucket["model_counts"]
model_counts[model] = successful
model_revenue_msats = bucket["model_revenue_msats"]
model_revenue_msats[model] = revenue_msats
model_tokens = bucket["model_tokens"]
model_tokens[model] = total_tokens
bucket["others"] = max(0, int(bucket["others"]) - successful)
bucket["others_revenue_msats"] = max(
0.0,
float(bucket["others_revenue_msats"]) - revenue_msats,
)
bucket["others_tokens"] = max(
0,
int(bucket["others_tokens"]) - total_tokens,
)
metrics = sorted(bucket_index.values(), key=lambda item: str(item["timestamp"]))
return {
"top_models": top_models,
"metrics": metrics,
"hours_back": hours_back,
"interval_minutes": interval_minutes,
"total_buckets": len(metrics),
}
def _cutoff_timestamp(self, hours_back: int) -> str:
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
return cutoff.strftime("%Y-%m-%d %H:%M:%S")
@@ -983,26 +1264,43 @@ class UsageAnalyticsStore:
def _extract_success_metrics(
self, entry: dict[str, Any], message: str
) -> tuple[bool, float]:
) -> tuple[bool, float, int, int]:
# These auth logs are emitted once per successful settlement across providers
# and avoid duplicate counting from provider-specific completion logs.
logger_name = str(entry.get("name", ""))
if not logger_name.startswith("routstr.auth"):
return False, 0.0
return False, 0.0, 0, 0
input_tokens = self._parse_token_count(entry.get("input_tokens", 0))
output_tokens = self._parse_token_count(entry.get("output_tokens", 0))
if "calculated token-based cost" in message:
token_cost = entry.get("token_cost", 0)
if isinstance(token_cost, (int, float)) and token_cost > 0:
return True, float(token_cost)
return True, 0.0
return True, float(token_cost), input_tokens, output_tokens
return True, 0.0, input_tokens, output_tokens
if "max cost payment finalized" in message:
charged_amount = entry.get("charged_amount", 0)
if isinstance(charged_amount, (int, float)) and charged_amount > 0:
return True, float(charged_amount)
return True, 0.0
return True, float(charged_amount), input_tokens, output_tokens
return True, 0.0, input_tokens, output_tokens
return False, 0.0
return False, 0.0, 0, 0
def _parse_token_count(self, value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
def _new_minute_stats(self) -> dict[str, float]:
return {
@@ -1016,6 +1314,9 @@ class UsageAnalyticsStore:
"upstream_errors": 0.0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
"input_tokens": 0.0,
"output_tokens": 0.0,
"total_tokens": 0.0,
}
def _new_model_stats(self) -> dict[str, float]:
@@ -1025,4 +1326,7 @@ class UsageAnalyticsStore:
"failed": 0.0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
"input_tokens": 0.0,
"output_tokens": 0.0,
"total_tokens": 0.0,
}
+47 -23
View File
@@ -16,6 +16,8 @@ class CostData(BaseModel):
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
class MaxCostData(CostData):
@@ -63,10 +65,49 @@ async def calculate_cost( # todo: can be sync
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
)
usage_data = response_data["usage"]
def parse_token_count(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(usage_data.get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(usage_data.get("output_tokens", 0))
)
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
)
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
@@ -104,6 +145,8 @@ async def calculate_cost( # todo: can be sync
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
logger.warning(
@@ -184,31 +227,10 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=max_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
@@ -234,4 +256,6 @@ async def calculate_cost( # todo: can be sync
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
+815
View File
@@ -0,0 +1,815 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ExpandIcon, Minimize2Icon } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from '@/components/ui/chart';
import { useIsMobile } from '@/hooks/use-mobile';
import { type ModelUsageMix } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units';
import { cn } from '@/lib/utils';
interface TopModelsUsageChartProps {
mix: ModelUsageMix;
displayUnit: DisplayUnit;
usdPerSat: number | null;
}
type ChartMode = 'requests' | 'revenue' | 'tokens';
interface TooltipRow {
color: string;
dataKey: string;
label: string;
value: number;
}
type LeaderboardTrend = 'up' | 'down' | 'flat' | 'new';
interface LeaderboardRow {
chartDataKey: string | null;
displayName: string;
model: string;
provider: string;
rank: number;
totalRaw: number;
trend: LeaderboardTrend;
trendPercent: number | null;
}
function parseBucketDate(value: string): Date | null {
const normalized = value.includes('T')
? value
: `${value.replace(' ', 'T')}Z`;
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
const fallback = new Date(value);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
function hueFromString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 360;
}
function getSeriesColor(model: string, index: number): string {
const palette = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'#f59e0b',
'#06b6d4',
'#8b5cf6',
'#f97316',
'#34d399',
];
if (index < palette.length) {
return palette[index];
}
const hue = (hueFromString(model) + index * 23) % 360;
return `hsl(${hue} 70% 56%)`;
}
function formatTooltipTimestamp(label: string): string {
const date = parseBucketDate(label);
if (!date) {
return label;
}
return date.toLocaleString([], {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
function formatAxisTimestamp(
timestamp: string,
hasMultipleDays: boolean
): string {
const date = parseBucketDate(timestamp);
if (!date) {
return '';
}
if (hasMultipleDays) {
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
});
}
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function convertRevenueMsats(
amountMsats: number,
displayUnit: DisplayUnit,
usdPerSat: number | null
): number {
if (displayUnit === 'msat') {
return amountMsats;
}
const sats = amountMsats / 1000;
if (displayUnit === 'usd') {
return sats * (usdPerSat ?? 0);
}
return sats;
}
function prettifyProvider(provider: string): string {
const normalized = provider.trim().toLowerCase();
const aliasMap: Record<string, string> = {
'x ai': 'x-ai',
xai: 'x-ai',
'z ai': 'z-ai',
zai: 'z-ai',
open_ai: 'openai',
openai: 'openai',
};
if (aliasMap[normalized]) {
return aliasMap[normalized];
}
return normalized.replace(/[_-]+/g, ' ');
}
function detectProviderFromModel(model: string): string {
const value = model.toLowerCase();
if (value.includes('claude')) return 'anthropic';
if (value.includes('gpt') || value.includes('openai')) return 'openai';
if (value.includes('gemini')) return 'google';
if (value.includes('grok') || value.includes('x-ai') || value.includes('xai')) {
return 'x-ai';
}
if (value.includes('deepseek')) return 'deepseek';
if (value.includes('minimax')) return 'minimax';
if (value.includes('kimi') || value.includes('moonshot')) return 'moonshot';
if (value.includes('mistral')) return 'mistral';
if (value.includes('qwen') || value.includes('alibaba')) return 'alibaba';
if (value.includes('glm') || value.includes('z-ai') || value.includes('z ai')) {
return 'z-ai';
}
return 'unknown';
}
function getModelPresentation(
model: string
): { displayName: string; provider: string } {
const trimmed = model.trim();
const slashIndex = trimmed.indexOf('/');
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
const provider = prettifyProvider(trimmed.slice(0, slashIndex));
const displayName = trimmed.slice(slashIndex + 1);
return { displayName, provider };
}
return {
displayName: trimmed,
provider: detectProviderFromModel(trimmed),
};
}
export function TopModelsUsageChart({
mix,
displayUnit,
usdPerSat,
}: TopModelsUsageChartProps) {
const [mode, setMode] = useState<ChartMode>('requests');
const [hoveredSeriesKey, setHoveredSeriesKey] = useState<string | null>(null);
const [isChartPointerInside, setIsChartPointerInside] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const compactNumber = useMemo(
() =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 2,
}),
[]
);
const mixTopModels = useMemo(
() => (Array.isArray(mix.top_models) ? mix.top_models : []),
[mix.top_models]
);
const mixMetrics = useMemo(
() => (Array.isArray(mix.metrics) ? mix.metrics : []),
[mix.metrics]
);
const chartModels = useMemo(
() => mixTopModels.slice(0, 10),
[mixTopModels]
);
const leaderboardModels = useMemo(
() => mixTopModels.slice(0, 10),
[mixTopModels]
);
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
return 'sat';
}
return displayUnit;
}, [displayUnit, usdPerSat]);
const revenueUnitLabel =
revenueDisplayUnit === 'usd'
? 'USD'
: revenueDisplayUnit === 'sat'
? 'sats'
: revenueDisplayUnit === 'msat'
? 'msats'
: revenueDisplayUnit;
const series = useMemo(
() =>
chartModels.map((model, index) => ({
requestsKey: `model_req_${index}`,
revenueKey: `model_rev_${index}`,
tokensKey: `model_tok_${index}`,
label: model,
color: getSeriesColor(model, index),
})),
[chartModels]
);
const chartData = useMemo(
() =>
mixMetrics.map((metric) => {
const modelCounts = metric.model_counts ?? {};
const modelRevenue = metric.model_revenue_msats ?? {};
const modelTokens = metric.model_tokens ?? {};
const point: Record<string, number | string> = {
timestamp: metric.timestamp,
total_successful: metric.total_successful,
total_revenue_msats: metric.total_revenue_msats,
total_tokens: metric.total_tokens,
others_requests: metric.others,
others_revenue_msats: metric.others_revenue_msats,
others_tokens: metric.others_tokens,
};
for (const item of series) {
point[item.requestsKey] = modelCounts[item.label] ?? 0;
point[item.revenueKey] = modelRevenue[item.label] ?? 0;
point[item.tokensKey] = modelTokens[item.label] ?? 0;
}
return point;
}),
[mixMetrics, series]
);
const hasMultipleDays = useMemo(() => {
const daySet = new Set(
chartData.map((item) =>
parseBucketDate(String(item.timestamp))?.toDateString()
)
);
return daySet.size > 1;
}, [chartData]);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
for (const item of series) {
config[item.requestsKey] = {
label: item.label,
color: item.color,
};
config[item.revenueKey] = {
label: item.label,
color: item.color,
};
config[item.tokensKey] = {
label: item.label,
color: item.color,
};
}
config.others_requests = {
label: 'Others',
color: '#6b7280',
};
config.others_revenue_msats = {
label: 'Others',
color: '#6b7280',
};
config.others_tokens = {
label: 'Others',
color: '#6b7280',
};
return config;
}, [series]);
useEffect(() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}, [mode]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement === containerRef.current);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleFullscreen = async () => {
if (!containerRef.current) {
return;
}
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen();
} else {
await containerRef.current.requestFullscreen();
}
} catch (error) {
console.error('Failed to toggle top models chart fullscreen', error);
}
};
const formatValue = (rawValue: number): string => {
if (mode === 'requests') {
return compactNumber.format(rawValue);
}
if (mode === 'tokens') {
return compactNumber.format(rawValue);
}
const converted = convertRevenueMsats(
rawValue,
revenueDisplayUnit,
usdPerSat
);
const compact = compactNumber.format(converted);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${revenueUnitLabel}`;
};
const activeSeries = series.map((item) => ({
dataKey:
mode === 'requests'
? item.requestsKey
: mode === 'revenue'
? item.revenueKey
: item.tokensKey,
name: item.label,
color: item.color,
}));
const othersKey = (
mode === 'requests'
? 'others_requests'
: mode === 'revenue'
? 'others_revenue_msats'
: 'others_tokens'
) as 'others_requests' | 'others_revenue_msats' | 'others_tokens';
const activeSeriesKeys = [
...activeSeries.map((item) => item.dataKey),
othersKey,
];
const activeHoverSeriesKey =
hoveredSeriesKey && activeSeriesKeys.includes(hoveredSeriesKey)
? hoveredSeriesKey
: null;
const getSeriesOpacity = (dataKey: string): number =>
activeHoverSeriesKey && activeHoverSeriesKey !== dataKey ? 0.18 : 1;
const formatLeaderboardTotal = (rawValue: number): string => {
if (mode === 'requests') {
return `${compactNumber.format(rawValue)} requests`;
}
if (mode === 'tokens') {
return `${compactNumber.format(rawValue)} tokens`;
}
const converted = convertRevenueMsats(
rawValue,
revenueDisplayUnit,
usdPerSat
);
const compact = compactNumber.format(converted);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${revenueUnitLabel}`;
};
const formatTrendPercent = (value: number): string => {
const abs = Math.abs(value);
const rounded = abs >= 10 ? abs.toFixed(0) : abs.toFixed(1);
return rounded.replace(/\.0$/, '');
};
const leaderboardRows = useMemo<LeaderboardRow[]>(() => {
if (leaderboardModels.length === 0 || mixMetrics.length === 0) {
return [];
}
const windowSize = Math.floor(mixMetrics.length / 2);
const previousMetrics =
windowSize > 0
? mixMetrics.slice(-windowSize * 2, -windowSize)
: [];
const currentMetrics =
windowSize > 0 ? mixMetrics.slice(-windowSize) : mixMetrics;
const rows = leaderboardModels
.map((model) => {
const readMetric = (metric: (typeof mixMetrics)[number]): number =>
mode === 'requests'
? (metric.model_counts ?? {})[model] ?? 0
: mode === 'revenue'
? (metric.model_revenue_msats ?? {})[model] ?? 0
: (metric.model_tokens ?? {})[model] ?? 0;
const totalRaw = mixMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const previousRaw = previousMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const currentRaw = currentMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const trendPercent =
previousRaw > 0
? ((currentRaw - previousRaw) / previousRaw) * 100
: null;
let trend: LeaderboardTrend = 'flat';
if (previousRaw <= 0 && currentRaw > 0) {
trend = 'new';
} else if (trendPercent !== null && trendPercent > 0.5) {
trend = 'up';
} else if (trendPercent !== null && trendPercent < -0.5) {
trend = 'down';
}
const presentation = getModelPresentation(model);
const matchingSeries = series.find((item) => item.label === model);
const chartDataKey = matchingSeries
? mode === 'requests'
? matchingSeries.requestsKey
: mode === 'revenue'
? matchingSeries.revenueKey
: matchingSeries.tokensKey
: null;
return {
chartDataKey,
displayName: presentation.displayName,
model,
provider: presentation.provider,
rank: 0,
totalRaw,
trend,
trendPercent,
} satisfies LeaderboardRow;
})
.filter((row) => row.totalRaw > 0)
.sort((a, b) => b.totalRaw - a.totalRaw)
.slice(0, 10)
.map((row, index) => ({
...row,
rank: index + 1,
}));
return rows;
}, [leaderboardModels, mixMetrics, mode, series]);
if (chartData.length === 0) {
return null;
}
return (
<div ref={containerRef}>
<Card
className={cn(isFullscreen && 'h-full rounded-none border-0 ring-0')}
>
<CardHeader className='space-y-3 sm:space-y-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<CardTitle className='text-base sm:text-lg'>Model Usage</CardTitle>
<p className='text-muted-foreground mt-1 text-xs sm:text-sm'>
Stacked requests, revenue, or tokens by model (
{mix.interval_minutes}m buckets).
</p>
</div>
<div className='flex items-center gap-2 sm:shrink-0'>
<div className='bg-muted/25 border-border/60 flex items-center gap-1 rounded-full border p-1'>
<Button
type='button'
size='sm'
variant={mode === 'requests' ? 'secondary' : 'ghost'}
onClick={() => setMode('requests')}
className='h-7 rounded-full px-2.5 text-xs'
>
Requests
</Button>
<Button
type='button'
size='sm'
variant={mode === 'revenue' ? 'secondary' : 'ghost'}
onClick={() => setMode('revenue')}
className='h-7 rounded-full px-2.5 text-xs'
>
Revenue
</Button>
<Button
type='button'
size='sm'
variant={mode === 'tokens' ? 'secondary' : 'ghost'}
onClick={() => setMode('tokens')}
className='h-7 rounded-full px-2.5 text-xs'
>
Tokens
</Button>
</div>
<Button
type='button'
variant='outline'
size='icon'
className='hidden h-8 w-8 shrink-0 sm:inline-flex'
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2Icon className='h-4 w-4' />
) : (
<ExpandIcon className='h-4 w-4' />
)}
<span className='sr-only'>
{isFullscreen
? 'Exit fullscreen chart'
: 'Enter fullscreen chart'}
</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent className='space-y-3 sm:space-y-4'>
<ChartContainer
config={chartConfig}
className={cn(
'aspect-auto w-full',
isFullscreen
? 'h-[calc(100vh-220px)] min-h-[340px] sm:h-[calc(100vh-260px)] sm:min-h-[420px]'
: 'h-[260px] sm:h-[340px]'
)}
onMouseLeave={() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}}
>
<BarChart
data={chartData}
onMouseEnter={() => setIsChartPointerInside(true)}
onMouseMove={() => setIsChartPointerInside(true)}
onMouseLeave={() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}}
margin={{
top: 12,
right: isMobile ? 8 : 18,
left: isMobile ? 0 : 8,
bottom: 0,
}}
>
<CartesianGrid vertical={false} className='stroke-muted/30' />
<XAxis
dataKey='timestamp'
tickLine={false}
axisLine={false}
minTickGap={isMobile ? 14 : 24}
tickFormatter={(value) =>
formatAxisTimestamp(String(value), hasMultipleDays)
}
/>
<YAxis
tickLine={false}
axisLine={false}
width={isMobile ? 40 : 56}
tickFormatter={(value) =>
formatValue(
typeof value === 'number' ? value : Number(value || 0)
)
}
/>
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (!isChartPointerInside || !active || !payload?.length) {
return null;
}
const rows = payload
.map((entry) => {
const value =
typeof entry.value === 'number'
? entry.value
: Number(entry.value || 0);
return {
color: String(entry.color || '#6b7280'),
dataKey: String(entry.dataKey || ''),
label: String(entry.name || ''),
value,
} satisfies TooltipRow;
})
.filter((row) => Number.isFinite(row.value) && row.value > 0)
.sort((a, b) => b.value - a.value);
const total = rows.reduce((sum, row) => sum + row.value, 0);
if (rows.length === 0) {
return null;
}
return (
<div className='border-border/50 bg-background min-w-[220px] rounded-lg border px-2.5 py-2 text-xs shadow-xl'>
<p className='text-foreground mb-2 text-sm font-medium'>
{formatTooltipTimestamp(String(label || ''))}
</p>
<div className='space-y-1.5'>
{rows.map((row) => (
<div
key={row.label}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3',
activeHoverSeriesKey &&
row.dataKey !== activeHoverSeriesKey &&
'opacity-45'
)}
>
<span className='text-muted-foreground flex min-w-0 items-center gap-2'>
<span
className='h-2.5 w-1.5 shrink-0 rounded-sm'
style={{ backgroundColor: row.color }}
/>
<span className='truncate'>{row.label}</span>
</span>
<span className='text-foreground font-mono tabular-nums'>
{formatValue(row.value)}
</span>
</div>
))}
</div>
<div className='border-border/60 mt-2 border-t pt-2'>
<div className='grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3'>
<span className='text-muted-foreground'>Total</span>
<span className='text-foreground font-mono font-semibold tabular-nums'>
{formatValue(total)}
</span>
</div>
</div>
</div>
);
}}
/>
{activeSeries.map((item) => (
<Bar
key={item.dataKey}
dataKey={item.dataKey}
name={item.name}
stackId='models'
fill={item.color}
fillOpacity={getSeriesOpacity(item.dataKey)}
maxBarSize={44}
onMouseEnter={() => setHoveredSeriesKey(item.dataKey)}
onMouseLeave={() => setHoveredSeriesKey(null)}
/>
))}
<Bar
dataKey={othersKey}
name='Others'
stackId='models'
fill='#6b7280'
fillOpacity={getSeriesOpacity(othersKey)}
maxBarSize={44}
onMouseEnter={() => setHoveredSeriesKey(othersKey)}
onMouseLeave={() => setHoveredSeriesKey(null)}
/>
</BarChart>
</ChartContainer>
<div className='border-border/60 space-y-2 border-t pt-3 sm:pt-4'>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-xs font-medium'>
Top models
</p>
<p className='text-muted-foreground text-xs'>
Change vs prior period
</p>
</div>
{leaderboardRows.length > 0 ? (
<div className='divide-border/40 divide-y'>
{leaderboardRows.map((row) => {
const rowIsLinked = Boolean(row.chartDataKey);
const rowIsActive =
row.chartDataKey !== null &&
activeHoverSeriesKey === row.chartDataKey;
const rowIsDimmed =
Boolean(activeHoverSeriesKey) &&
row.chartDataKey !== null &&
row.chartDataKey !== activeHoverSeriesKey;
let trendLabel = '0%';
let trendClass = 'text-muted-foreground';
if (row.trend === 'new') {
trendLabel = 'new';
trendClass = 'text-blue-500';
} else if (row.trend === 'up' && row.trendPercent !== null) {
trendLabel = `↑${formatTrendPercent(row.trendPercent)}%`;
trendClass = 'text-emerald-500';
} else if (
row.trend === 'down' &&
row.trendPercent !== null
) {
trendLabel = `↓${formatTrendPercent(row.trendPercent)}%`;
trendClass = 'text-red-500';
} else if (row.trendPercent !== null) {
trendLabel = `${formatTrendPercent(row.trendPercent)}%`;
}
return (
<div
key={row.model}
className={cn(
'grid grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-3 rounded-md px-2 py-2 text-xs',
rowIsLinked &&
'cursor-pointer transition hover:bg-muted/25',
rowIsActive && 'bg-muted/30',
rowIsDimmed && 'opacity-45'
)}
title={row.model}
onMouseEnter={() => {
if (row.chartDataKey) {
setHoveredSeriesKey(row.chartDataKey);
}
}}
onMouseLeave={() => {
if (row.chartDataKey) {
setHoveredSeriesKey(null);
}
}}
>
<span className='text-muted-foreground w-5 text-right font-mono tabular-nums'>
{row.rank}.
</span>
<div className='min-w-0'>
<span className='truncate font-medium'>
{row.displayName}
</span>{' '}
<span className='text-muted-foreground truncate'>
by {row.provider}
</span>
</div>
<span className='text-foreground font-mono tabular-nums'>
{formatLeaderboardTotal(row.totalRaw)}
</span>
<span className={cn('font-medium', trendClass)}>
{trendLabel}
</span>
</div>
);
})}
</div>
) : (
<p className='text-muted-foreground text-xs'>
No model totals available for this range.
</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+18 -23
View File
@@ -35,9 +35,10 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const formatAmount = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const hasTokenStats =
typeof summary.total_tokens === 'number' ||
typeof summary.avg_total_tokens_per_completion === 'number';
const totalTokens = Number(summary.total_tokens ?? 0);
const avgTotalTokensPerCompletion = Number(
summary.avg_total_tokens_per_completion ?? 0
);
const cards = [
{
@@ -52,26 +53,20 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
icon: CheckCircle2,
iconClassName: 'text-emerald-600 dark:text-emerald-300',
},
...(hasTokenStats
? [
{
title: 'Total Tokens',
value: Number(summary.total_tokens ?? 0).toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Avg Tokens/Completion',
value: Number(
summary.avg_total_tokens_per_completion ?? 0
).toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
icon: Activity,
iconClassName: 'text-indigo-600 dark:text-indigo-300',
},
]
: []),
{
title: 'Total Tokens',
value: totalTokens.toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Avg Tokens/Completion',
value: avgTotalTokensPerCompletion.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
icon: Activity,
iconClassName: 'text-indigo-600 dark:text-indigo-300',
},
{
title: 'Revenue',
value: formatAmount(summary.revenue_msats),