mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Enhance payment and analytics tracking by adding input and output token metrics across various components
This commit is contained in:
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user