From c8c30d7cfd2c75fc44716ba4b6188613482b7fb1 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Tue, 24 Feb 2026 02:07:20 +0800 Subject: [PATCH 01/13] Implement caching in LogManager for improved performance and optimize log entry retrieval --- routstr/core/log_manager.py | 216 ++++++++++++++++++------------------ 1 file changed, 110 insertions(+), 106 deletions(-) diff --git a/routstr/core/log_manager.py b/routstr/core/log_manager.py index 39ac8e7c..61dcceb9 100644 --- a/routstr/core/log_manager.py +++ b/routstr/core/log_manager.py @@ -303,128 +303,132 @@ class LogManager: return 0 def get_usage_summary(self, hours: int = 24) -> dict: - entries = list( - self._yield_log_entries( - hours_back=hours, window_center=datetime.now(timezone.utc) - ) + return self._cache_call( + ("usage_summary", hours), + lambda: self._calculate_summary_stats(self._get_cached_entries(hours)), ) - return self._calculate_summary_stats(entries) def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict: - entries = list( - self._yield_log_entries( - hours_back=hours, window_center=datetime.now(timezone.utc) - ) + return self._cache_call( + ("usage_metrics", interval, hours), + lambda: self._aggregate_metrics_by_time( + self._get_cached_entries(hours), interval, hours + ), ) - return self._aggregate_metrics_by_time(entries, interval, hours) def get_error_details(self, hours: int = 24, limit: int = 100) -> dict: - errors: list[dict[str, Any]] = [] + def compute() -> dict: + errors: list[dict[str, Any]] = [] - for entry in self._yield_log_entries(hours_back=hours): - if str(entry.get("levelname", "")).upper() != "ERROR": - continue + for entry in self._get_cached_entries(hours): + if str(entry.get("levelname", "")).upper() != "ERROR": + continue - errors.append( - { - "timestamp": entry.get("asctime", ""), - "message": entry.get("message", ""), - "error_type": entry.get("error_type", "unknown"), - "pathname": entry.get("pathname", ""), - "lineno": entry.get("lineno", 0), - "request_id": entry.get("request_id", ""), - } - ) + errors.append( + { + "timestamp": entry.get("asctime", ""), + "message": entry.get("message", ""), + "error_type": entry.get("error_type", "unknown"), + "pathname": entry.get("pathname", ""), + "lineno": entry.get("lineno", 0), + "request_id": entry.get("request_id", ""), + } + ) - errors.sort(key=lambda x: str(x["timestamp"]), reverse=True) - return {"errors": errors[:limit], "total_count": len(errors)} + errors.sort(key=lambda x: str(x["timestamp"]), reverse=True) + return {"errors": errors[:limit], "total_count": len(errors)} + + return self._cache_call(("error_details", hours, limit), compute) def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict: - entries = list( - self._yield_log_entries( - hours_back=hours, window_center=datetime.now(timezone.utc) - ) - ) + def compute() -> dict: + entries = self._get_cached_entries(hours) - model_stats: dict[str, dict[str, int | float]] = defaultdict( - lambda: { - "revenue_msats": 0, - "refunds_msats": 0, - "requests": 0, - "successful": 0, - "failed": 0, - } - ) - - for entry in entries: - try: - model = entry.get("model", "unknown") - if not isinstance(model, str): - model = "unknown" - - message = str(entry.get("message", "")).lower() - - completed, revenue_msats, _, _ = self._extract_success_metrics( - entry, message - ) - if completed: - model_stats[model]["requests"] += 1 - model_stats[model]["successful"] += 1 - if revenue_msats > 0: - model_stats[model]["revenue_msats"] += revenue_msats - - failed = ( - "revert payment" in message or "upstream request failed" in message - ) - if failed: - model_stats[model]["requests"] += 1 - model_stats[model]["failed"] += 1 - if "revert payment" in message: - max_cost = entry.get("max_cost_for_model", 0) - if isinstance(max_cost, (int, float)) and max_cost > 0: - model_stats[model]["refunds_msats"] += max_cost - - except Exception: - continue - - models: list[dict[str, Any]] = [] - total_revenue = 0.0 - - for model, stats in model_stats.items(): - revenue_msats = float(stats["revenue_msats"]) - refunds_msats = float(stats["refunds_msats"]) - - revenue_sats = revenue_msats / 1000 - refunds_sats = refunds_msats / 1000 - net_revenue_sats = revenue_sats - refunds_sats - - total_revenue += net_revenue_sats - - requests = int(stats["requests"]) - successful = int(stats["successful"]) - - models.append( - { - "model": model, - "revenue_sats": revenue_sats, - "refunds_sats": refunds_sats, - "net_revenue_sats": net_revenue_sats, - "requests": requests, - "successful": successful, - "failed": int(stats["failed"]), - "avg_revenue_per_request": ( - revenue_sats / successful if successful > 0 else 0 - ), + model_stats: dict[str, dict[str, int | float]] = defaultdict( + lambda: { + "revenue_msats": 0, + "refunds_msats": 0, + "requests": 0, + "successful": 0, + "failed": 0, } ) - models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True) + for entry in entries: + try: + model = entry.get("model", "unknown") + if not isinstance(model, str): + model = "unknown" - return { - "models": models[:limit], - "total_revenue_sats": total_revenue, - "total_models": len(models), - } + message = str(entry.get("message", "")).lower() + + completed, revenue_msats, _, _ = self._extract_success_metrics( + entry, message + ) + if completed: + model_stats[model]["requests"] += 1 + model_stats[model]["successful"] += 1 + if revenue_msats > 0: + model_stats[model]["revenue_msats"] += revenue_msats + + failed = ( + "revert payment" in message + or "upstream request failed" in message + ) + if failed: + model_stats[model]["requests"] += 1 + model_stats[model]["failed"] += 1 + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if ( + isinstance(max_cost, (int, float)) + and max_cost > 0 + ): + model_stats[model]["refunds_msats"] += max_cost + + except Exception: + continue + + models: list[dict[str, Any]] = [] + total_revenue = 0.0 + + for model, stats in model_stats.items(): + revenue_msats = float(stats["revenue_msats"]) + refunds_msats = float(stats["refunds_msats"]) + + revenue_sats = revenue_msats / 1000 + refunds_sats = refunds_msats / 1000 + net_revenue_sats = revenue_sats - refunds_sats + + total_revenue += net_revenue_sats + + requests = int(stats["requests"]) + successful = int(stats["successful"]) + + models.append( + { + "model": model, + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_sats": net_revenue_sats, + "requests": requests, + "successful": successful, + "failed": int(stats["failed"]), + "avg_revenue_per_request": ( + revenue_sats / successful if successful > 0 else 0 + ), + } + ) + + models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True) + + return { + "models": models[:limit], + "total_revenue_sats": total_revenue, + "total_models": len(models), + } + + return self._cache_call(("revenue_by_model", hours, limit), compute) def _build_summary_response(self, stats: dict[str, Any]) -> dict[str, Any]: revenue_sats = stats["revenue_msats"] / 1000 From 958f28fd82c222229dfb048b3fad1a0e92a208f6 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Tue, 24 Feb 2026 11:25:18 +0000 Subject: [PATCH 02/13] Optimize dashboard analytics pipeline and clean UI data flow --- routstr/core/admin.py | 336 +++---- routstr/core/log_manager.py | 552 ++++++++++-- routstr/core/usage_analytics_store.py | 1043 ++++++++++++++++++++++ routstr/wallet.py | 109 ++- ui/app/layout.tsx | 5 +- ui/app/page.tsx | 246 +++-- ui/components/revenue-by-model-table.tsx | 202 +++++ ui/lib/api/services/admin.ts | 92 +- 8 files changed, 2096 insertions(+), 489 deletions(-) create mode 100644 routstr/core/usage_analytics_store.py create mode 100644 ui/components/revenue-by-model-table.tsx diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..cdfb2e86 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -2,6 +2,7 @@ import json import secrets from datetime import datetime, timezone from pathlib import Path +from typing import NoReturn from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel @@ -26,18 +27,55 @@ logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) admin_sessions: dict[str, int] = {} -ADMIN_SESSION_DURATION = 3600 +ADMIN_SESSION_DURATION = 12 * 60 * 60 +# Usage analytics remain queryable up to 12 months. +MAX_USAGE_ANALYTICS_HOURS = 365 * 24 + + +def _current_timestamp() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None: + current_timestamp = ( + now_timestamp if now_timestamp is not None else _current_timestamp() + ) + expired_tokens = [ + token + for token, expiry_timestamp in admin_sessions.items() + if expiry_timestamp <= current_timestamp + ] + for token in expired_tokens: + admin_sessions.pop(token, None) + + +def _raise_unauthorized(detail: str) -> NoReturn: + raise HTTPException( + status_code=401, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) def require_admin_api(request: Request) -> None: - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1] - expiry = admin_sessions.get(token) - if expiry and expiry > int(datetime.now(timezone.utc).timestamp()): - return + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + _raise_unauthorized("Missing bearer token") - raise HTTPException(status_code=403, detail="Unauthorized") + token = auth_header.split(" ", 1)[1].strip() + if not token: + _raise_unauthorized("Missing bearer token") + + now_timestamp = _current_timestamp() + expiry_timestamp = admin_sessions.get(token) + if expiry_timestamp is None: + _raise_unauthorized("Invalid session token") + + if expiry_timestamp <= now_timestamp: + admin_sessions.pop(token, None) + _raise_unauthorized("Session expired") + + _cleanup_expired_admin_sessions(now_timestamp) @admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)]) @@ -206,18 +244,10 @@ async def admin_login( raise HTTPException(status_code=401, detail="Invalid password") token = secrets.token_urlsafe(32) - expiry_timestamp = ( - int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION - ) + expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION admin_sessions[token] = expiry_timestamp - expired_tokens = [ - t - for t, exp in admin_sessions.items() - if exp <= int(datetime.now(timezone.utc).timestamp()) - ] - for t in expired_tokens: - del admin_sessions[t] + _cleanup_expired_admin_sessions() return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION} @@ -544,7 +574,6 @@ class UpstreamProviderCreate(BaseModel): api_version: str | None = None enabled: bool = True provider_fee: float = 1.01 - provider_settings: dict | None = None class UpstreamProviderUpdate(BaseModel): @@ -554,7 +583,6 @@ class UpstreamProviderUpdate(BaseModel): api_version: str | None = None enabled: bool | None = None provider_fee: float | None = None - provider_settings: dict | None = None @admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)]) @@ -571,9 +599,6 @@ async def get_upstream_providers() -> list[dict[str, object]]: "api_version": p.api_version, "enabled": p.enabled, "provider_fee": p.provider_fee, - "provider_settings": json.loads(p.provider_settings) - if p.provider_settings - else None, } for p in providers ] @@ -603,9 +628,6 @@ async def create_upstream_provider( api_version=payload.api_version, enabled=payload.enabled, provider_fee=payload.provider_fee, - provider_settings=json.dumps(payload.provider_settings) - if payload.provider_settings - else None, ) session.add(provider) await session.commit() @@ -621,7 +643,6 @@ async def create_upstream_provider( "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, - "provider_settings": payload.provider_settings, } @@ -641,9 +662,6 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]: "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, - "provider_settings": json.loads(provider.provider_settings) - if provider.provider_settings - else None, } @@ -670,8 +688,6 @@ async def update_upstream_provider( provider.enabled = payload.enabled if payload.provider_fee is not None: provider.provider_fee = payload.provider_fee - if payload.provider_settings is not None: - provider.provider_settings = json.dumps(payload.provider_settings) session.add(provider) await session.commit() @@ -687,9 +703,6 @@ async def update_upstream_provider( "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, - "provider_settings": json.loads(provider.provider_settings) - if provider.provider_settings - else None, } @@ -809,47 +822,6 @@ class TopupRequest(BaseModel): amount: int -class TopupTokenRequest(BaseModel): - token: str - - -@admin_router.post( - "/api/upstream-providers/{provider_id}/topup-token", - dependencies=[Depends(require_admin_api)], -) -async def topup_provider_with_token( - provider_id: int, payload: TopupTokenRequest -) -> dict: - """Redeem a Cashu token for an upstream provider.""" - async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") - - import httpx - - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - headers = {} - if provider.api_key: - headers["Authorization"] = f"Bearer {provider.api_key}" - resp = await client.post( - f"{clean_url}/v1/balance/topup", - json={"cashu_token": payload.token}, - headers=headers, - ) - - if resp.status_code == 200: - return {"ok": True, "message": "Token redeemed successfully"} - else: - logger.error(f"Upstream token topup failed: {resp.text}") - try: - error_detail = resp.json() - except Exception: - error_detail = resp.text - return {"ok": False, "message": f"Upstream error: {error_detail}"} - - @admin_router.post( "/api/upstream-providers/{provider_id}/topup", dependencies=[Depends(require_admin_api)], @@ -876,49 +848,7 @@ async def initiate_provider_topup( f"Initiating top-up for provider {provider_id}", extra={"amount": payload.amount}, ) - - # For Routstr providers, we might be doing a Lightning top-up or a direct token transfer - if provider.provider_type == "routstr": - # UI sends sats for Routstr topup - import httpx - - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - # Proxy the request to upstream Routstr - # Use the actual API key from the database - resp = await client.post( - f"{clean_url}/v1/balance/lightning/invoice", - json={ - "amount_sats": int(payload.amount), - "purpose": "topup", - "api_key": provider.api_key, - }, - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, - ) - - if resp.status_code == 200: - data = resp.json() - return { - "ok": True, - "topup_data": { - "payment_request": data.get("bolt11"), - "invoice_id": data.get("invoice_id"), - "status": "pending", - }, - } - else: - logger.error(f"Upstream topup request failed: {resp.text}") - # Check if it's JSON error - try: - error_detail = resp.json() - except Exception: - error_detail = resp.text - raise HTTPException( - status_code=resp.status_code, detail=error_detail - ) - topup_data = await upstream_instance.initiate_topup(payload.amount) - logger.info( "Top-up initiated successfully", extra={ @@ -969,23 +899,6 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj if not provider: raise HTTPException(status_code=404, detail="Provider not found") - # For Routstr providers, proxy the status check - if provider.provider_type == "routstr": - import httpx - - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - resp = await client.get( - f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status", - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, - ) - if resp.status_code == 200: - status_data = resp.json() - return {"ok": True, "paid": status_data.get("status") == "paid"} - else: - logger.error(f"Upstream status check failed: {resp.text}") - return {"ok": False, "paid": False} - upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1013,7 +926,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj dependencies=[Depends(require_admin_api)], ) async def get_provider_balance(provider_id: int) -> dict[str, object]: - """Get the current balance for an upstream provider account.""" + """Get the current account balance for the upstream provider.""" from ..upstream.helpers import _instantiate_provider async with create_session() as session: @@ -1021,30 +934,6 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") - # For Routstr providers, proxy the balance check - if provider.provider_type == "routstr": - import httpx - - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - headers = {} - if provider.api_key: - headers["Authorization"] = f"Bearer {provider.api_key}" - resp = await client.get( - f"{clean_url}/v1/balance/info", - headers=headers, - ) - if resp.status_code == 200: - data = resp.json() - # Return balance in sats - balance = data.get("balance", 0) - if isinstance(balance, (int, float)): - return {"ok": True, "balance_data": balance // 1000} - return {"ok": True, "balance_data": balance} - else: - logger.error(f"Failed to fetch Routstr balance: {resp.text}") - return {"ok": False, "balance_data": None} - upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1081,16 +970,57 @@ async def get_usage_metrics( interval: int = Query( default=15, ge=1, le=1440, description="Time interval in minutes" ), - hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), + hours: int = Query( + default=24, + ge=1, + le=MAX_USAGE_ANALYTICS_HOURS, + description="Hours of history to analyze", + ), ) -> dict: """Get usage metrics aggregated by time interval.""" return log_manager.get_usage_metrics(interval=interval, hours=hours) +@admin_router.get("/api/usage/dashboard", dependencies=[Depends(require_admin_api)]) +async def get_usage_dashboard( + request: Request, + interval: int = Query( + default=15, ge=1, le=1440, description="Time interval in minutes" + ), + hours: int = Query( + default=24, + ge=1, + le=MAX_USAGE_ANALYTICS_HOURS, + description="Hours of history to analyze", + ), + error_limit: int = Query( + default=100, ge=1, le=1000, description="Maximum number of errors to return" + ), + model_limit: int = Query( + default=20, ge=1, le=100, description="Maximum number of models to return" + ), +) -> dict: + """ + Get all dashboard analytics in one request. + This runs one combined aggregation pass and avoids repeated scans. + """ + return log_manager.get_usage_dashboard( + interval=interval, + hours=hours, + error_limit=error_limit, + model_limit=model_limit, + ) + + @admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)]) async def get_usage_summary( request: Request, - hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), + hours: int = Query( + default=24, + ge=1, + le=MAX_USAGE_ANALYTICS_HOURS, + description="Hours of history to analyze", + ), ) -> dict: """Get summary statistics for the specified time period.""" return log_manager.get_usage_summary(hours=hours) @@ -1099,7 +1029,12 @@ async def get_usage_summary( @admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)]) async def get_error_details( request: Request, - hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), + hours: int = Query( + default=24, + ge=1, + le=MAX_USAGE_ANALYTICS_HOURS, + description="Hours of history to analyze", + ), limit: int = Query( default=100, ge=1, le=1000, description="Maximum number of errors to return" ), @@ -1113,7 +1048,12 @@ async def get_error_details( ) async def get_revenue_by_model( request: Request, - hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), + hours: int = Query( + default=24, + ge=1, + le=MAX_USAGE_ANALYTICS_HOURS, + description="Hours of history to analyze", + ), limit: int = Query( default=20, ge=1, le=100, description="Maximum number of models to return" ), @@ -1206,71 +1146,3 @@ async def get_log_dates_api(request: Request) -> dict[str, object]: continue return {"dates": dates} - - -@admin_router.post( - "/api/upstream-providers/{provider_id}/routstr/refund", - dependencies=[Depends(require_admin_api)], -) -async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]: - """Refund balance from an upstream Routstr provider back to the local wallet.""" - from ..upstream.helpers import _instantiate_provider - from ..upstream.routstr import RoutstrUpstreamProvider - - async with create_session() as session: - provider_row = await session.get(UpstreamProviderRow, provider_id) - if not provider_row: - raise HTTPException(status_code=404, detail="Provider not found") - - if provider_row.provider_type != "routstr": - raise HTTPException( - status_code=400, detail="Refund only supported for Routstr providers" - ) - - provider = _instantiate_provider(provider_row) - if not isinstance(provider, RoutstrUpstreamProvider): - raise HTTPException(status_code=400, detail="Invalid provider instance") - - try: - # Request refund from upstream - data = await provider.refund_balance() - if "error" in data: - # If the upstream returned an OpenAI-style error (like the model unknown error) - # it means the request likely didn't even reach the refund endpoint handler - # but was intercepted by the proxy layer. - error_info = data.get("error", {}) - message = ( - error_info.get("message") - if isinstance(error_info, dict) - else str(error_info) - ) - return { - "ok": False, - "message": f"Upstream refund failed: {message}", - } - - token = data.get("token") - if not token: - return {"ok": False, "message": "Upstream did not return a token"} - - # Receive token into local wallet - from ..wallet import recieve_token - - try: - # Use current wallet to receive - await recieve_token(token) - return { - "ok": True, - "message": "Successfully received refund from upstream provider", - } - except Exception as e: - logger.error(f"Failed to receive refund token: {e}") - return { - "ok": False, - "message": f"Failed to receive refund token: {str(e)}", - "token": token, - } - - except Exception as e: - logger.exception(f"Refund failed for provider {provider_id}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/routstr/core/log_manager.py b/routstr/core/log_manager.py index 61dcceb9..0444dcbf 100644 --- a/routstr/core/log_manager.py +++ b/routstr/core/log_manager.py @@ -1,17 +1,73 @@ import json +import time from collections import defaultdict from datetime import datetime, timedelta, timezone +from heapq import heappush, heapreplace from pathlib import Path -from typing import Any, Iterator +from threading import Lock +from typing import Any, Callable, Iterator, TypeVar from .logging import get_logger +from .usage_analytics_store import UsageAnalyticsStore logger = get_logger(__name__) +T = TypeVar("T") class LogManager: def __init__(self, logs_dir: Path = Path("logs")): self.logs_dir = logs_dir + self._usage_store = UsageAnalyticsStore(logs_dir=logs_dir) + self._analytics_cache_ttl_seconds = 30.0 + self._analytics_cache: dict[tuple[Any, ...], tuple[float, Any]] = {} + self._analytics_cache_lock = Lock() + self._cache_miss = object() + + def _get_cached(self, key: tuple[Any, ...]) -> Any: + now = time.time() + with self._analytics_cache_lock: + cached = self._analytics_cache.get(key) + if cached is None: + return self._cache_miss + + expires_at, value = cached + if expires_at <= now: + self._analytics_cache.pop(key, None) + return self._cache_miss + + return value + + def _set_cached( + self, key: tuple[Any, ...], value: Any, ttl_seconds: float | None = None + ) -> None: + ttl = ( + self._analytics_cache_ttl_seconds + if ttl_seconds is None + else max(1.0, ttl_seconds) + ) + expires_at = time.time() + ttl + with self._analytics_cache_lock: + self._analytics_cache[key] = (expires_at, value) + + def _cache_call( + self, + key: tuple[Any, ...], + compute: Callable[[], T], + ttl_seconds: float | None = None, + ) -> T: + cached = self._get_cached(key) + if cached is not self._cache_miss: + return cached + + value = compute() + self._set_cached(key, value, ttl_seconds=ttl_seconds) + return value + + def _get_cached_entries(self, hours: int) -> list[dict[str, Any]]: + return self._cache_call( + ("usage_entries", hours), + lambda: list(self._yield_log_entries(hours_back=hours)), + ) def _yield_log_entries( self, @@ -19,7 +75,6 @@ class LogManager: specific_date: str | None = None, reverse_files: bool = False, max_files: int | None = None, - window_center: datetime | None = None, ) -> Iterator[dict[str, Any]]: """ Yields log entries from files. @@ -29,7 +84,6 @@ class LogManager: specific_date: specific date string (YYYY-MM-DD) to look at. reverse_files: if True, process files in reverse order (newest first). max_files: maximum number of log files to process (most recent if reverse_files is True). - window_center: datetime object to center a 5-month window around. """ if not self.logs_dir.exists(): return @@ -44,36 +98,6 @@ class LogManager: log_files.append(log_file) else: log_files = sorted(self.logs_dir.glob("app_*.log")) - - if window_center: - # Calculate the 5 months: [center-2, center-1, center, center+1, center+2] - allowed_month_years = [] - cur_m = window_center.month - cur_y = window_center.year - - for offset in range(-2, 3): - m = cur_m + offset - y = cur_y - while m <= 0: - m += 12 - y -= 1 - while m > 12: - m -= 12 - y += 1 - allowed_month_years.append(f"{y}-{m:02d}") - - filtered_files = [] - for log_path in log_files: - try: - # Stem is "app_YYYY-MM-DD" - file_date_str = log_path.stem.split("_")[1] - file_month_year = file_date_str[:7] # YYYY-MM - if file_month_year in allowed_month_years: - filtered_files.append(log_path) - except Exception: - continue - log_files = filtered_files - if reverse_files: log_files.reverse() @@ -303,45 +327,124 @@ class LogManager: return 0 def get_usage_summary(self, hours: int = 24) -> dict: + def compute() -> dict: + try: + return self._usage_store.get_summary(hours_back=hours) + except Exception as e: + logger.error( + f"Usage analytics index failed, falling back to log scan: {e}" + ) + return self._calculate_summary_stats(self._get_cached_entries(hours)) + return self._cache_call( ("usage_summary", hours), - lambda: self._calculate_summary_stats(self._get_cached_entries(hours)), + compute, ) def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict: + def compute() -> dict: + try: + return self._usage_store.get_metrics( + interval_minutes=interval, + hours_back=hours, + ) + except Exception as e: + logger.error( + f"Usage analytics index failed, falling back to log scan: {e}" + ) + return self._aggregate_metrics_by_time( + self._get_cached_entries(hours), interval, hours + ) + return self._cache_call( ("usage_metrics", interval, hours), - lambda: self._aggregate_metrics_by_time( - self._get_cached_entries(hours), interval, hours - ), + compute, + ) + + def get_usage_dashboard( + self, + interval: int = 15, + hours: int = 24, + error_limit: int = 100, + model_limit: int = 20, + ) -> dict: + # Large ranges are expensive to scan; keep cached longer. + if hours <= 24: + cache_ttl = 60.0 + elif hours <= 7 * 24: + cache_ttl = 300.0 + elif hours <= 30 * 24: + cache_ttl = 1800.0 + elif hours <= 90 * 24: + cache_ttl = 7200.0 + else: + cache_ttl = 21600.0 + + def compute() -> dict: + try: + return self._usage_store.get_dashboard( + interval_minutes=interval, + hours_back=hours, + error_limit=error_limit, + model_limit=model_limit, + ) + except Exception as e: + logger.error( + f"Usage analytics index failed, falling back to log scan: {e}" + ) + return self._aggregate_dashboard( + interval_minutes=interval, + hours_back=hours, + error_limit=error_limit, + model_limit=model_limit, + ) + + return self._cache_call( + ("usage_dashboard", interval, hours, error_limit, model_limit), + compute, + ttl_seconds=cache_ttl, ) def get_error_details(self, hours: int = 24, limit: int = 100) -> dict: def compute() -> dict: - errors: list[dict[str, Any]] = [] - - for entry in self._get_cached_entries(hours): - if str(entry.get("levelname", "")).upper() != "ERROR": - continue - - errors.append( - { - "timestamp": entry.get("asctime", ""), - "message": entry.get("message", ""), - "error_type": entry.get("error_type", "unknown"), - "pathname": entry.get("pathname", ""), - "lineno": entry.get("lineno", 0), - "request_id": entry.get("request_id", ""), - } + try: + return self._usage_store.get_error_details(hours_back=hours, limit=limit) + except Exception as e: + logger.error( + f"Usage analytics index failed, falling back to log scan: {e}" ) - errors.sort(key=lambda x: str(x["timestamp"]), reverse=True) + errors: list[dict] = [] + for entry in self._get_cached_entries(hours): + if str(entry.get("levelname", "")).upper() == "ERROR": + timestamp_str = entry.get("asctime", "") + errors.append( + { + "timestamp": timestamp_str, + "message": entry.get("message", ""), + "error_type": entry.get("error_type", "unknown"), + "pathname": entry.get("pathname", ""), + "lineno": entry.get("lineno", 0), + "request_id": entry.get("request_id", ""), + } + ) + + errors.sort(key=lambda x: x["timestamp"], reverse=True) return {"errors": errors[:limit], "total_count": len(errors)} return self._cache_call(("error_details", hours, limit), compute) def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict: def compute() -> dict: + try: + return self._usage_store.get_revenue_by_model( + hours_back=hours, limit=limit + ) + except Exception as e: + logger.error( + f"Usage analytics index failed, falling back to log scan: {e}" + ) + entries = self._get_cached_entries(hours) model_stats: dict[str, dict[str, int | float]] = defaultdict( @@ -380,10 +483,7 @@ class LogManager: model_stats[model]["failed"] += 1 if "revert payment" in message: max_cost = entry.get("max_cost_for_model", 0) - if ( - isinstance(max_cost, (int, float)) - and max_cost > 0 - ): + if isinstance(max_cost, (int, float)) and max_cost > 0: model_stats[model]["refunds_msats"] += max_cost except Exception: @@ -559,6 +659,344 @@ class LogManager: return self._build_summary_response(stats) + def _aggregate_dashboard( + self, + interval_minutes: int, + hours_back: int, + error_limit: int, + model_limit: int, + ) -> dict[str, Any]: + time_buckets: dict[str, dict[str, Any]] = defaultdict( + lambda: { + "total_requests": 0, + "successful_chat_completions": 0, + "failed_requests": 0, + "errors": 0, + "warnings": 0, + "payment_processed": 0, + "upstream_errors": 0, + "revenue_msats": 0.0, + "refunds_msats": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } + ) + summary_stats: dict[str, Any] = { + "total_entries": 0, + "total_requests": 0, + "successful_chat_completions": 0, + "failed_requests": 0, + "total_errors": 0, + "total_warnings": 0, + "payment_processed": 0, + "upstream_errors": 0, + "unique_models": set(), + "error_types": defaultdict(int), + "revenue_msats": 0.0, + "refunds_msats": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } + model_stats: dict[str, dict[str, int | float]] = defaultdict( + lambda: { + "revenue_msats": 0, + "refunds_msats": 0, + "requests": 0, + "successful": 0, + "failed": 0, + } + ) + model_mix_buckets: dict[str, dict[str, int]] = defaultdict( + lambda: defaultdict(int) + ) + model_mix_revenue_buckets: dict[str, dict[str, float]] = defaultdict( + lambda: defaultdict(float) + ) + model_mix_token_buckets: dict[str, dict[str, int]] = defaultdict( + lambda: defaultdict(int) + ) + model_mix_totals: dict[str, int] = defaultdict(int) + model_mix_revenue_totals: dict[str, float] = defaultdict(float) + model_mix_token_totals: dict[str, int] = defaultdict(int) + latest_errors_heap: list[tuple[str, dict[str, Any]]] = [] + total_error_count = 0 + + for entry in self._yield_log_entries(hours_back=hours_back): + try: + summary_stats["total_entries"] += 1 + + timestamp_str = entry.get("asctime", "") + message = str(entry.get("message", "")).lower() + level = str(entry.get("levelname", "")).upper() + model = entry.get("model", "unknown") + if not isinstance(model, str): + model = "unknown" + + bucket_key = ( + self._bucket_key_for_timestamp(timestamp_str, interval_minutes) + if isinstance(timestamp_str, str) + else None + ) + bucket = time_buckets[bucket_key] if bucket_key else None + + if level == "ERROR": + summary_stats["total_errors"] += 1 + if bucket: + bucket["errors"] += 1 + if "error_type" in entry: + summary_stats["error_types"][str(entry["error_type"])] += 1 + + total_error_count += 1 + error_item = { + "timestamp": timestamp_str, + "message": entry.get("message", ""), + "error_type": entry.get("error_type", "unknown"), + "pathname": entry.get("pathname", ""), + "lineno": entry.get("lineno", 0), + "request_id": entry.get("request_id", ""), + } + if len(latest_errors_heap) < error_limit: + heappush(latest_errors_heap, (timestamp_str, error_item)) + elif timestamp_str > latest_errors_heap[0][0]: + heapreplace(latest_errors_heap, (timestamp_str, error_item)) + elif level == "WARNING": + summary_stats["total_warnings"] += 1 + if bucket: + bucket["warnings"] += 1 + + completed, revenue_msats, input_tokens, output_tokens = ( + self._extract_success_metrics(entry, message) + ) + if completed: + summary_stats["total_requests"] += 1 + summary_stats["successful_chat_completions"] += 1 + summary_stats["input_tokens"] += input_tokens + summary_stats["output_tokens"] += output_tokens + summary_stats["total_tokens"] += input_tokens + output_tokens + model_stats[model]["requests"] += 1 + model_stats[model]["successful"] += 1 + model_mix_totals[model] += 1 + if bucket: + bucket["total_requests"] += 1 + bucket["successful_chat_completions"] += 1 + bucket["input_tokens"] += input_tokens + bucket["output_tokens"] += output_tokens + bucket["total_tokens"] += input_tokens + output_tokens + if bucket_key: + model_mix_buckets[bucket_key][model] += 1 + if revenue_msats > 0: + model_mix_revenue_buckets[bucket_key][model] += revenue_msats + model_mix_revenue_totals[model] += revenue_msats + if input_tokens > 0 or output_tokens > 0: + token_total = input_tokens + output_tokens + model_mix_token_buckets[bucket_key][model] += token_total + model_mix_token_totals[model] += token_total + + if revenue_msats > 0: + summary_stats["revenue_msats"] += revenue_msats + model_stats[model]["revenue_msats"] += revenue_msats + if bucket: + bucket["revenue_msats"] += revenue_msats + + failed = ( + "upstream request failed" in message + or "revert payment" in message + ) + if failed: + summary_stats["total_requests"] += 1 + summary_stats["failed_requests"] += 1 + model_stats[model]["requests"] += 1 + model_stats[model]["failed"] += 1 + if bucket: + bucket["total_requests"] += 1 + bucket["failed_requests"] += 1 + + if "payment processed successfully" in message: + summary_stats["payment_processed"] += 1 + if bucket: + bucket["payment_processed"] += 1 + + if "upstream" in message and level == "ERROR": + summary_stats["upstream_errors"] += 1 + if bucket: + bucket["upstream_errors"] += 1 + + if model != "unknown": + summary_stats["unique_models"].add(model) + + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + max_cost_float = float(max_cost) + summary_stats["refunds_msats"] += max_cost_float + model_stats[model]["refunds_msats"] += max_cost_float + if bucket: + bucket["refunds_msats"] += max_cost_float + except Exception: + continue + + metrics_result = [] + for bucket_key in sorted(time_buckets.keys()): + bucket = dict(time_buckets[bucket_key]) + bucket["requests"] = bucket["total_requests"] + metrics_result.append({"timestamp": bucket_key, **bucket}) + + models: list[dict[str, Any]] = [] + total_revenue = 0.0 + for model_name, stats in model_stats.items(): + revenue_msats = float(stats["revenue_msats"]) + refunds_msats = float(stats["refunds_msats"]) + revenue_sats = revenue_msats / 1000 + refunds_sats = refunds_msats / 1000 + net_revenue_sats = revenue_sats - refunds_sats + total_revenue += net_revenue_sats + + successful = int(stats["successful"]) + models.append( + { + "model": model_name, + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_sats": net_revenue_sats, + "requests": int(stats["requests"]), + "successful": successful, + "failed": int(stats["failed"]), + "avg_revenue_per_request": ( + revenue_sats / successful if successful > 0 else 0 + ), + } + ) + + models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True) + latest_errors = [ + item + for _, item in sorted( + latest_errors_heap, key=lambda x: x[0], reverse=True + ) + ] + top_model_limit = max(1, min(model_limit, 20)) + top_models_requests = [ + model_name + for model_name, _ in sorted( + ( + (name, count) + for name, count in model_mix_totals.items() + if name != "unknown" + ), + key=lambda item: item[1], + reverse=True, + )[:top_model_limit] + ] + top_models_revenue = [ + model_name + for model_name, _ in sorted( + ( + (name, amount) + for name, amount in model_mix_revenue_totals.items() + if name != "unknown" + ), + key=lambda item: item[1], + reverse=True, + )[:top_model_limit] + ] + top_models_tokens = [ + model_name + for model_name, _ in sorted( + ( + (name, token_count) + for name, token_count in model_mix_token_totals.items() + if name != "unknown" + ), + key=lambda item: item[1], + reverse=True, + )[:top_model_limit] + ] + selected_models: list[str] = [] + for model in top_models_requests + top_models_revenue + top_models_tokens: + if model not in selected_models: + selected_models.append(model) + top_model_set = set(selected_models) + + model_usage_mix_metrics: list[dict[str, Any]] = [] + mix_bucket_keys = sorted( + set(model_mix_buckets.keys()) + | set(model_mix_revenue_buckets.keys()) + | set(model_mix_token_buckets.keys()) + ) + for bucket_key in mix_bucket_keys: + counts = model_mix_buckets.get(bucket_key, {}) + revenue_counts = model_mix_revenue_buckets.get(bucket_key, {}) + token_counts = model_mix_token_buckets.get(bucket_key, {}) + others = 0 + others_revenue_msats = 0.0 + others_tokens = 0 + model_counts: dict[str, int] = {} + model_revenue_msats: dict[str, float] = {} + model_tokens: dict[str, int] = {} + for model_name, successful_count in counts.items(): + if model_name in top_model_set: + model_counts[model_name] = int(successful_count) + else: + others += int(successful_count) + for model_name, revenue_value in revenue_counts.items(): + if model_name in top_model_set: + model_revenue_msats[model_name] = float(revenue_value) + else: + others_revenue_msats += float(revenue_value) + for model_name, token_value in token_counts.items(): + if model_name in top_model_set: + model_tokens[model_name] = int(token_value) + else: + others_tokens += int(token_value) + + model_usage_mix_metrics.append( + { + "timestamp": bucket_key, + "total_successful": int(sum(counts.values())), + "total_revenue_msats": float(sum(revenue_counts.values())), + "total_tokens": int(sum(token_counts.values())), + "others": others, + "others_revenue_msats": others_revenue_msats, + "others_tokens": others_tokens, + "model_counts": model_counts, + "model_revenue_msats": model_revenue_msats, + "model_tokens": model_tokens, + } + ) + + return { + "metrics": { + "metrics": metrics_result, + "interval_minutes": interval_minutes, + "hours_back": hours_back, + "total_buckets": len(metrics_result), + }, + "summary": self._build_summary_response(summary_stats), + "error_details": { + "errors": latest_errors, + "total_count": total_error_count, + }, + "revenue_by_model": { + "models": models[:model_limit], + "total_revenue_sats": total_revenue, + "total_models": len(models), + }, + "model_usage_mix": { + "top_models": top_models_requests, + "top_models_by_metric": { + "requests": top_models_requests, + "revenue": top_models_revenue, + "tokens": top_models_tokens, + }, + "metrics": model_usage_mix_metrics, + "interval_minutes": interval_minutes, + "hours_back": hours_back, + "total_buckets": len(model_usage_mix_metrics), + }, + } + def _aggregate_metrics_by_time( self, entries: list[dict], interval_minutes: int, hours_back: int ) -> dict: diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py new file mode 100644 index 00000000..809604e3 --- /dev/null +++ b/routstr/core/usage_analytics_store.py @@ -0,0 +1,1043 @@ +import json +import sqlite3 +import time +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import Lock +from typing import Any + +from .logging import get_logger + +logger = get_logger(__name__) + + +class UsageAnalyticsStore: + """ + Incremental usage analytics index backed by SQLite. + + Instead of rescanning raw JSON log files for every dashboard request, we keep + a rolling minute-level aggregate that is updated from only newly appended log + bytes. + """ + + SCHEMA_VERSION = "2" + + def __init__(self, logs_dir: Path, db_path: Path | None = None): + self.logs_dir = logs_dir + self.db_path = db_path or (logs_dir / "usage_analytics.db") + self._lock = Lock() + self._conn: sqlite3.Connection | None = None + + def get_dashboard( + self, + *, + interval_minutes: int, + hours_back: int, + error_limit: int, + model_limit: int, + max_points: int | None, + ) -> dict[str, Any]: + with self._lock: + conn = self._get_connection_locked() + self._ensure_up_to_date_locked(conn) + cutoff_timestamp = self._cutoff_timestamp(hours_back) + + summary = self._query_summary_locked(conn, cutoff_timestamp) + metrics = self._query_metrics_locked( + conn, + cutoff_timestamp=cutoff_timestamp, + interval_minutes=interval_minutes, + hours_back=hours_back, + max_points=max_points, + ) + error_details = self._query_error_details_locked( + conn, + cutoff_timestamp=cutoff_timestamp, + limit=error_limit, + total_error_count=summary["total_errors"], + ) + revenue_by_model = self._query_revenue_by_model_locked( + conn, + cutoff_timestamp=cutoff_timestamp, + limit=model_limit, + ) + + return { + "metrics": metrics, + "summary": summary, + "error_details": error_details, + "revenue_by_model": revenue_by_model, + } + + def get_summary(self, *, hours_back: int) -> dict[str, Any]: + with self._lock: + conn = self._get_connection_locked() + self._ensure_up_to_date_locked(conn) + cutoff_timestamp = self._cutoff_timestamp(hours_back) + return self._query_summary_locked(conn, cutoff_timestamp) + + def get_metrics( + self, + *, + interval_minutes: int, + hours_back: int, + ) -> dict[str, Any]: + with self._lock: + conn = self._get_connection_locked() + self._ensure_up_to_date_locked(conn) + cutoff_timestamp = self._cutoff_timestamp(hours_back) + return self._query_metrics_locked( + conn, + cutoff_timestamp=cutoff_timestamp, + interval_minutes=interval_minutes, + hours_back=hours_back, + max_points=None, + ) + + def get_error_details(self, *, hours_back: int, limit: int) -> dict[str, Any]: + with self._lock: + conn = self._get_connection_locked() + self._ensure_up_to_date_locked(conn) + cutoff_timestamp = self._cutoff_timestamp(hours_back) + return self._query_error_details_locked( + conn, + cutoff_timestamp=cutoff_timestamp, + limit=limit, + ) + + def get_revenue_by_model(self, *, hours_back: int, limit: int) -> dict[str, Any]: + with self._lock: + conn = self._get_connection_locked() + self._ensure_up_to_date_locked(conn) + cutoff_timestamp = self._cutoff_timestamp(hours_back) + return self._query_revenue_by_model_locked( + conn, + cutoff_timestamp=cutoff_timestamp, + limit=limit, + ) + + def _get_connection_locked(self) -> sqlite3.Connection: + if self._conn is not None: + return self._conn + + self.db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect( + self.db_path, + timeout=30.0, + check_same_thread=False, + ) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA temp_store=MEMORY") + conn.execute("PRAGMA cache_size=-20000") + self._initialize_schema_locked(conn) + self._conn = conn + return conn + + def _initialize_schema_locked(self, conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analytics_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + + current_version_row = conn.execute( + "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( + """ + CREATE TABLE IF NOT EXISTS analytics_file_state ( + path TEXT PRIMARY KEY, + inode INTEGER NOT NULL, + offset INTEGER NOT NULL, + size INTEGER NOT NULL, + updated_at REAL NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analytics_minute ( + minute_ts TEXT PRIMARY KEY, + total_entries INTEGER NOT NULL DEFAULT 0, + total_requests INTEGER NOT NULL DEFAULT 0, + successful_chat_completions INTEGER NOT NULL DEFAULT 0, + failed_requests INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + warnings INTEGER NOT NULL DEFAULT 0, + 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 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analytics_model_minute ( + minute_ts TEXT NOT NULL, + model TEXT NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + successful INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + revenue_msats REAL NOT NULL DEFAULT 0, + refunds_msats REAL NOT NULL DEFAULT 0, + PRIMARY KEY (minute_ts, model) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analytics_model_presence_minute ( + minute_ts TEXT NOT NULL, + model TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (minute_ts, model) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analytics_error_type_minute ( + minute_ts TEXT NOT NULL, + error_type TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (minute_ts, error_type) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analytics_error_events ( + timestamp TEXT NOT NULL, + message TEXT NOT NULL, + error_type TEXT NOT NULL, + pathname TEXT NOT NULL, + lineno INTEGER NOT NULL, + request_id TEXT NOT NULL + ) + """ + ) + 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_presence_ts ON analytics_model_presence_minute (minute_ts)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_analytics_error_type_minute_ts ON analytics_error_type_minute (minute_ts)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_analytics_error_events_ts ON analytics_error_events (timestamp DESC)" + ) + conn.commit() + + 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") + conn.execute("DROP TABLE IF EXISTS analytics_model_minute") + conn.execute("DROP TABLE IF EXISTS analytics_model_presence_minute") + conn.execute("DROP TABLE IF EXISTS analytics_error_type_minute") + conn.execute("DROP TABLE IF EXISTS analytics_error_events") + + def _ensure_up_to_date_locked(self, conn: sqlite3.Connection) -> None: + if not self.logs_dir.exists(): + return + + log_files = sorted(self.logs_dir.glob("app_*.log")) + if not log_files: + return + + requires_rebuild = False + + for log_file in log_files: + try: + self._process_log_file_locked(conn, log_file) + except RuntimeError: + requires_rebuild = True + break + except Exception as exc: + logger.error(f"Failed indexing usage analytics for {log_file}: {exc}") + continue + + if requires_rebuild: + logger.warning( + "Usage analytics index out-of-sync, rebuilding from all log files" + ) + self._rebuild_locked(conn, log_files) + return + + # Commit even when only file-state metadata changed + # (for example when we intentionally keep offset at the last full line). + conn.commit() + + def _rebuild_locked( + self, conn: sqlite3.Connection, log_files: list[Path] | None = None + ) -> None: + self._drop_index_tables_locked(conn) + self._initialize_schema_locked(conn) + + files = log_files if log_files is not None else sorted(self.logs_dir.glob("app_*.log")) + for log_file in files: + try: + self._process_log_file_locked(conn, log_file, force_full_read=True) + except Exception as exc: + logger.error(f"Failed rebuilding usage analytics for {log_file}: {exc}") + conn.commit() + + def _process_log_file_locked( + self, + conn: sqlite3.Connection, + log_file: Path, + force_full_read: bool = False, + ) -> bool: + stat = log_file.stat() + inode = int(getattr(stat, "st_ino", 0)) + file_size = int(stat.st_size) + log_file_path = str(log_file.resolve()) + + previous_offset = 0 + if not force_full_read: + row = conn.execute( + """ + SELECT inode, offset + FROM analytics_file_state + WHERE path = ? + """, + (log_file_path,), + ).fetchone() + if row is not None: + previous_inode = int(row["inode"]) + previous_offset = int(row["offset"]) + if previous_inode and inode and previous_inode != inode: + raise RuntimeError("inode changed") + if previous_offset > file_size: + raise RuntimeError("file shrunk") + + if previous_offset >= file_size and not force_full_read: + self._upsert_file_state_locked( + conn, + path=log_file_path, + inode=inode, + offset=file_size, + size=file_size, + ) + return False + + ( + end_offset, + minute_updates, + model_updates, + model_presence_updates, + error_type_updates, + error_events, + ) = self._collect_updates_from_file(log_file, previous_offset) + + self._apply_updates_locked( + conn=conn, + minute_updates=minute_updates, + model_updates=model_updates, + model_presence_updates=model_presence_updates, + error_type_updates=error_type_updates, + error_events=error_events, + ) + + latest_size = int(log_file.stat().st_size) + self._upsert_file_state_locked( + conn, + path=log_file_path, + inode=inode, + offset=end_offset, + size=latest_size, + ) + return end_offset != previous_offset + + def _upsert_file_state_locked( + self, + conn: sqlite3.Connection, + *, + path: str, + inode: int, + offset: int, + size: int, + ) -> None: + conn.execute( + """ + INSERT INTO analytics_file_state (path, inode, offset, size, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + inode = excluded.inode, + offset = excluded.offset, + size = excluded.size, + updated_at = excluded.updated_at + """, + (path, inode, offset, size, time.time()), + ) + + def _collect_updates_from_file( + self, log_file: Path, start_offset: int + ) -> tuple[ + int, + dict[str, dict[str, float]], + dict[tuple[str, str], dict[str, float]], + dict[tuple[str, str], int], + dict[tuple[str, str], int], + list[tuple[str, str, str, str, int, str]], + ]: + minute_updates: dict[str, dict[str, float]] = defaultdict( + self._new_minute_stats + ) + model_updates: dict[tuple[str, str], dict[str, float]] = defaultdict( + self._new_model_stats + ) + model_presence_updates: dict[tuple[str, str], int] = defaultdict(int) + error_type_updates: dict[tuple[str, str], int] = defaultdict(int) + error_events: list[tuple[str, str, str, str, int, str]] = [] + + end_offset = start_offset + with open(log_file, "rb") as f: + f.seek(start_offset) + + while True: + line_start = f.tell() + raw_line = f.readline() + if not raw_line: + break + + # If the writer is appending and we catch a partial line at EOF, + # do not advance beyond it. We'll parse it on the next refresh. + if not raw_line.endswith(b"\n"): + f.seek(line_start) + break + + end_offset = f.tell() + if not raw_line.strip(): + continue + + try: + entry = json.loads(raw_line) + except Exception: + continue + + if not isinstance(entry, dict): + continue + + minute_key = self._minute_key(entry.get("asctime")) + if minute_key is None: + continue + + bucket = minute_updates[minute_key] + bucket["total_entries"] += 1 + + message_value = entry.get("message", "") + message = str(message_value).lower() + level = str(entry.get("levelname", "")).upper() + + model_raw = entry.get("model", "unknown") + model = model_raw if isinstance(model_raw, str) else "unknown" + + if level == "ERROR": + bucket["errors"] += 1 + error_type = str(entry.get("error_type", "unknown")) + error_type_updates[(minute_key, error_type)] += 1 + + lineno_value = entry.get("lineno", 0) + try: + lineno = int(lineno_value) + except (TypeError, ValueError): + lineno = 0 + + error_events.append( + ( + str(entry.get("asctime", "")), + str(message_value), + error_type, + str(entry.get("pathname", "")), + lineno, + str(entry.get("request_id", "")), + ) + ) + elif level == "WARNING": + bucket["warnings"] += 1 + + completed = ( + "completed for streaming" in message + or "completed for non-streaming" in 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 + + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + cost_float = float(actual_cost) + bucket["revenue_msats"] += cost_float + model_bucket["revenue_msats"] += cost_float + + failed = ( + "upstream request failed" in message + or "revert payment" in message + ) + if failed: + bucket["total_requests"] += 1 + bucket["failed_requests"] += 1 + model_bucket = model_updates[(minute_key, model)] + model_bucket["requests"] += 1 + model_bucket["failed"] += 1 + + if "payment processed successfully" in message: + bucket["payment_processed"] += 1 + + if level == "ERROR" and "upstream" in message: + bucket["upstream_errors"] += 1 + + if model != "unknown": + model_presence_updates[(minute_key, model)] += 1 + + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + max_cost_float = float(max_cost) + bucket["refunds_msats"] += max_cost_float + model_updates[(minute_key, model)][ + "refunds_msats" + ] += max_cost_float + + return ( + end_offset, + minute_updates, + model_updates, + model_presence_updates, + error_type_updates, + error_events, + ) + + def _apply_updates_locked( + self, + *, + conn: sqlite3.Connection, + minute_updates: dict[str, dict[str, float]], + model_updates: dict[tuple[str, str], dict[str, float]], + model_presence_updates: dict[tuple[str, str], int], + error_type_updates: dict[tuple[str, str], int], + error_events: list[tuple[str, str, str, str, int, str]], + ) -> None: + if minute_updates: + rows = [ + ( + minute_ts, + int(stats["total_entries"]), + int(stats["total_requests"]), + int(stats["successful_chat_completions"]), + int(stats["failed_requests"]), + int(stats["errors"]), + int(stats["warnings"]), + int(stats["payment_processed"]), + int(stats["upstream_errors"]), + float(stats["revenue_msats"]), + float(stats["refunds_msats"]), + ) + for minute_ts, stats in minute_updates.items() + ] + conn.executemany( + """ + INSERT INTO analytics_minute ( + minute_ts, + total_entries, + total_requests, + successful_chat_completions, + failed_requests, + errors, + warnings, + payment_processed, + upstream_errors, + revenue_msats, + refunds_msats + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(minute_ts) DO UPDATE SET + total_entries = total_entries + excluded.total_entries, + total_requests = total_requests + excluded.total_requests, + successful_chat_completions = successful_chat_completions + excluded.successful_chat_completions, + failed_requests = failed_requests + excluded.failed_requests, + errors = errors + excluded.errors, + warnings = warnings + excluded.warnings, + 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 + """, + rows, + ) + + if model_updates: + rows = [ + ( + minute_ts, + model, + int(stats["requests"]), + int(stats["successful"]), + int(stats["failed"]), + float(stats["revenue_msats"]), + float(stats["refunds_msats"]), + ) + for (minute_ts, model), stats in model_updates.items() + ] + conn.executemany( + """ + INSERT INTO analytics_model_minute ( + minute_ts, + model, + requests, + successful, + failed, + revenue_msats, + refunds_msats + ) + 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 + """, + rows, + ) + + if model_presence_updates: + rows = [ + (minute_ts, model, count) + for (minute_ts, model), count in model_presence_updates.items() + ] + conn.executemany( + """ + INSERT INTO analytics_model_presence_minute ( + minute_ts, + model, + count + ) + VALUES (?, ?, ?) + ON CONFLICT(minute_ts, model) DO UPDATE SET + count = count + excluded.count + """, + rows, + ) + + if error_type_updates: + rows = [ + (minute_ts, error_type, count) + for (minute_ts, error_type), count in error_type_updates.items() + ] + conn.executemany( + """ + INSERT INTO analytics_error_type_minute ( + minute_ts, + error_type, + count + ) + VALUES (?, ?, ?) + ON CONFLICT(minute_ts, error_type) DO UPDATE SET + count = count + excluded.count + """, + rows, + ) + + if error_events: + conn.executemany( + """ + INSERT INTO analytics_error_events ( + timestamp, + message, + error_type, + pathname, + lineno, + request_id + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + error_events, + ) + + def _query_metrics_locked( + self, + conn: sqlite3.Connection, + *, + cutoff_timestamp: str, + interval_minutes: int, + hours_back: int, + max_points: int | None, + ) -> dict[str, Any]: + bucket_seconds = max(60, int(interval_minutes) * 60) + rows = conn.execute( + """ + SELECT + datetime( + (CAST(strftime('%s', minute_ts) AS INTEGER) / ?) * ?, + 'unixepoch' + ) AS bucket_ts, + COALESCE(SUM(total_requests), 0) AS total_requests, + COALESCE(SUM(successful_chat_completions), 0) AS successful_chat_completions, + COALESCE(SUM(failed_requests), 0) AS failed_requests, + COALESCE(SUM(errors), 0) AS errors, + COALESCE(SUM(warnings), 0) AS warnings, + 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 + FROM analytics_minute + WHERE minute_ts >= ? + GROUP BY bucket_ts + ORDER BY bucket_ts + """, + (bucket_seconds, bucket_seconds, cutoff_timestamp), + ).fetchall() + + totals: dict[str, float] = { + "total_requests": 0.0, + "successful_chat_completions": 0.0, + "failed_requests": 0.0, + "errors": 0.0, + "warnings": 0.0, + "payment_processed": 0.0, + "upstream_errors": 0.0, + "revenue_msats": 0.0, + "refunds_msats": 0.0, + } + + points: list[dict[str, Any]] = [] + for row in rows: + total_requests = int(row["total_requests"]) + successful = int(row["successful_chat_completions"]) + failed = int(row["failed_requests"]) + errors = int(row["errors"]) + warnings = int(row["warnings"]) + payment_processed = int(row["payment_processed"]) + upstream_errors = int(row["upstream_errors"]) + revenue_msats = float(row["revenue_msats"]) + refunds_msats = float(row["refunds_msats"]) + + totals["total_requests"] += total_requests + totals["successful_chat_completions"] += successful + totals["failed_requests"] += failed + totals["errors"] += errors + totals["warnings"] += warnings + totals["payment_processed"] += payment_processed + totals["upstream_errors"] += upstream_errors + totals["revenue_msats"] += revenue_msats + totals["refunds_msats"] += refunds_msats + + points.append( + { + "timestamp": str(row["bucket_ts"]), + "total_requests": total_requests, + "successful_chat_completions": successful, + "failed_requests": failed, + "errors": errors, + "warnings": warnings, + "payment_processed": payment_processed, + "upstream_errors": upstream_errors, + "revenue_msats": revenue_msats, + "refunds_msats": refunds_msats, + "requests": total_requests, + } + ) + + points = self._downsample_metric_points(points, max_points) + + normalized_totals: dict[str, int | float] = { + "total_requests": int(totals["total_requests"]), + "successful_chat_completions": int(totals["successful_chat_completions"]), + "failed_requests": int(totals["failed_requests"]), + "errors": int(totals["errors"]), + "warnings": int(totals["warnings"]), + "payment_processed": int(totals["payment_processed"]), + "upstream_errors": int(totals["upstream_errors"]), + "revenue_msats": float(totals["revenue_msats"]), + "refunds_msats": float(totals["refunds_msats"]), + } + + return { + "metrics": points, + "interval_minutes": interval_minutes, + "hours_back": hours_back, + "total_buckets": len(points), + "totals": normalized_totals, + } + + def _query_summary_locked( + self, conn: sqlite3.Connection, cutoff_timestamp: str + ) -> dict[str, Any]: + totals = conn.execute( + """ + SELECT + COALESCE(SUM(total_entries), 0) AS total_entries, + COALESCE(SUM(total_requests), 0) AS total_requests, + COALESCE(SUM(successful_chat_completions), 0) AS successful_chat_completions, + COALESCE(SUM(failed_requests), 0) AS failed_requests, + COALESCE(SUM(errors), 0) AS total_errors, + COALESCE(SUM(warnings), 0) AS total_warnings, + 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 + FROM analytics_minute + WHERE minute_ts >= ? + """, + (cutoff_timestamp,), + ).fetchone() + + unique_models = [ + str(row[0]) + for row in conn.execute( + """ + SELECT DISTINCT model + FROM analytics_model_presence_minute + WHERE minute_ts >= ? + ORDER BY model ASC + """, + (cutoff_timestamp,), + ).fetchall() + ] + + error_types = { + str(row[0]): int(row[1]) + for row in conn.execute( + """ + SELECT error_type, COALESCE(SUM(count), 0) AS total_count + FROM analytics_error_type_minute + WHERE minute_ts >= ? + GROUP BY error_type + """, + (cutoff_timestamp,), + ).fetchall() + } + + total_requests = int(totals["total_requests"]) + successful = int(totals["successful_chat_completions"]) + failed_requests = int(totals["failed_requests"]) + + revenue_msats = float(totals["revenue_msats"]) + refunds_msats = float(totals["refunds_msats"]) + net_revenue_msats = revenue_msats - refunds_msats + + revenue_sats = revenue_msats / 1000 + refunds_sats = refunds_msats / 1000 + net_revenue_sats = net_revenue_msats / 1000 + + return { + "total_entries": int(totals["total_entries"]), + "total_requests": total_requests, + "successful_chat_completions": successful, + "failed_requests": failed_requests, + "total_errors": int(totals["total_errors"]), + "total_warnings": int(totals["total_warnings"]), + "payment_processed": int(totals["payment_processed"]), + "upstream_errors": int(totals["upstream_errors"]), + "unique_models_count": len(unique_models), + "unique_models": unique_models, + "error_types": error_types, + "success_rate": (successful / total_requests * 100) + if total_requests > 0 + else 0, + "revenue_msats": revenue_msats, + "refunds_msats": refunds_msats, + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_msats": net_revenue_msats, + "net_revenue_sats": net_revenue_sats, + "avg_revenue_per_request_msats": (revenue_msats / successful) + if successful > 0 + else 0, + "refund_rate": (failed_requests / total_requests * 100) + if total_requests > 0 + else 0, + } + + def _query_error_details_locked( + self, + conn: sqlite3.Connection, + *, + cutoff_timestamp: str, + limit: int, + total_error_count: int | None = None, + ) -> dict[str, Any]: + rows = conn.execute( + """ + SELECT + timestamp, + message, + error_type, + pathname, + lineno, + request_id + FROM analytics_error_events + WHERE timestamp >= ? + ORDER BY timestamp DESC + LIMIT ? + """, + (cutoff_timestamp, limit), + ).fetchall() + + if total_error_count is None: + total_error_count_row = conn.execute( + """ + SELECT COALESCE(SUM(errors), 0) + FROM analytics_minute + WHERE minute_ts >= ? + """, + (cutoff_timestamp,), + ).fetchone() + total_error_count = int(total_error_count_row[0]) if total_error_count_row else 0 + + return { + "errors": [ + { + "timestamp": str(row["timestamp"]), + "message": str(row["message"]), + "error_type": str(row["error_type"]), + "pathname": str(row["pathname"]), + "lineno": int(row["lineno"]), + "request_id": str(row["request_id"]), + } + for row in rows + ], + "total_count": int(total_error_count), + } + + def _query_revenue_by_model_locked( + self, + conn: sqlite3.Connection, + *, + cutoff_timestamp: str, + limit: int, + ) -> dict[str, Any]: + rows = conn.execute( + """ + SELECT + model, + COALESCE(SUM(revenue_msats), 0) AS revenue_msats, + COALESCE(SUM(refunds_msats), 0) AS refunds_msats, + COALESCE(SUM(requests), 0) AS requests, + COALESCE(SUM(successful), 0) AS successful, + COALESCE(SUM(failed), 0) AS failed + FROM analytics_model_minute + WHERE minute_ts >= ? + GROUP BY model + ORDER BY (COALESCE(SUM(revenue_msats), 0) - COALESCE(SUM(refunds_msats), 0)) DESC + """, + (cutoff_timestamp,), + ).fetchall() + + models: list[dict[str, Any]] = [] + total_revenue_sats = 0.0 + + for row in rows: + revenue_msats = float(row["revenue_msats"]) + refunds_msats = float(row["refunds_msats"]) + revenue_sats = revenue_msats / 1000 + refunds_sats = refunds_msats / 1000 + net_revenue_sats = revenue_sats - refunds_sats + successful = int(row["successful"]) + + models.append( + { + "model": str(row["model"]), + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_sats": net_revenue_sats, + "requests": int(row["requests"]), + "successful": successful, + "failed": int(row["failed"]), + "avg_revenue_per_request": (revenue_sats / successful) + if successful > 0 + else 0, + } + ) + total_revenue_sats += net_revenue_sats + + return { + "models": models[:limit], + "total_revenue_sats": total_revenue_sats, + "total_models": len(models), + } + + 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") + + def _minute_key(self, timestamp: Any) -> str | None: + if not isinstance(timestamp, str) or len(timestamp) != 19: + return None + if timestamp[10] != " ": + return None + return f"{timestamp[:16]}:00" + + def _new_minute_stats(self) -> dict[str, float]: + return { + "total_entries": 0.0, + "total_requests": 0.0, + "successful_chat_completions": 0.0, + "failed_requests": 0.0, + "errors": 0.0, + "warnings": 0.0, + "payment_processed": 0.0, + "upstream_errors": 0.0, + "revenue_msats": 0.0, + "refunds_msats": 0.0, + } + + def _new_model_stats(self) -> dict[str, float]: + return { + "requests": 0.0, + "successful": 0.0, + "failed": 0.0, + "revenue_msats": 0.0, + "refunds_msats": 0.0, + } + + def _downsample_metric_points( + self, points: list[dict[str, Any]], max_points: int | None + ) -> list[dict[str, Any]]: + if max_points is None or max_points <= 0: + return points + if len(points) <= max_points: + return points + if max_points == 1: + return [points[-1]] + + step = (len(points) - 1) / (max_points - 1) + sampled: list[dict[str, Any]] = [] + last_index = -1 + + for i in range(max_points): + index = int(round(i * step)) + if index <= last_index: + index = min(last_index + 1, len(points) - 1) + sampled.append(points[index]) + last_index = index + + sampled[0] = points[0] + sampled[-1] = points[-1] + return sampled diff --git a/routstr/wallet.py b/routstr/wallet.py index 71ea18ac..f4817bb9 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,5 +1,6 @@ import asyncio import math +import time from typing import TypedDict from cashu.core.base import Proof, Token @@ -158,6 +159,14 @@ async def credit_balance( _wallets: dict[str, Wallet] = {} +_balances_cache_ttl_seconds = 300.0 +_balances_cache: dict[ + tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]] +] = {} +_balances_refresh_tasks: dict[ + tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]] +] = {} +_balances_cache_lock = asyncio.Lock() async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet: @@ -226,6 +235,12 @@ async def fetch_all_balances( """ if units is None: units = ["sat", "msat"] + units_key = tuple(units) + + now = time.time() + cached = _balances_cache.get(units_key) + if cached and cached[0] > now: + return cached[1] async def fetch_balance( session: db.AsyncSession, mint_url: str, unit: str @@ -261,47 +276,71 @@ async def fetch_all_balances( } return error_result - # Create tasks for all mint/unit combinations - async with db.create_session() as session: - tasks = [ - fetch_balance(session, mint_url, unit) - for mint_url in settings.cashu_mints - for unit in units - ] + async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]: + # Create tasks for all mint/unit combinations + async with db.create_session() as session: + tasks = [ + fetch_balance(session, mint_url, unit) + for mint_url in settings.cashu_mints + for unit in units + ] - # Run all tasks concurrently - balance_details = list(await asyncio.gather(*tasks)) + # Run all tasks concurrently + balance_details = list(await asyncio.gather(*tasks)) - # Calculate totals - total_wallet_balance_sats = 0 - total_user_balance_sats = 0 + # Calculate totals + total_wallet_balance_sats = 0 + total_user_balance_sats = 0 - for detail in balance_details: - if not detail.get("error"): - # Convert to sats for total calculation - unit = detail["unit"] - proofs_balance_sats = ( - detail["wallet_balance"] - if unit == "sat" - else detail["wallet_balance"] // 1000 - ) - user_balance_sats = ( - detail["user_balance"] - if unit == "sat" - else detail["user_balance"] // 1000 - ) + for detail in balance_details: + if not detail.get("error"): + # Convert to sats for total calculation + unit = detail["unit"] + proofs_balance_sats = ( + detail["wallet_balance"] + if unit == "sat" + else detail["wallet_balance"] // 1000 + ) + user_balance_sats = ( + detail["user_balance"] + if unit == "sat" + else detail["user_balance"] // 1000 + ) - total_wallet_balance_sats += proofs_balance_sats - total_user_balance_sats += user_balance_sats + total_wallet_balance_sats += proofs_balance_sats + total_user_balance_sats += user_balance_sats - owner_balance = total_wallet_balance_sats - total_user_balance_sats + owner_balance = total_wallet_balance_sats - total_user_balance_sats + return ( + balance_details, + total_wallet_balance_sats, + total_user_balance_sats, + owner_balance, + ) - return ( - balance_details, - total_wallet_balance_sats, - total_user_balance_sats, - owner_balance, - ) + async with _balances_cache_lock: + now = time.time() + cached = _balances_cache.get(units_key) + if cached and cached[0] > now: + return cached[1] + + refresh_task = _balances_refresh_tasks.get(units_key) + if refresh_task is None or refresh_task.done(): + refresh_task = asyncio.create_task(compute_balances()) + _balances_refresh_tasks[units_key] = refresh_task + + result = await refresh_task + + async with _balances_cache_lock: + _balances_cache[units_key] = ( + time.time() + _balances_cache_ttl_seconds, + result, + ) + current_task = _balances_refresh_tasks.get(units_key) + if current_task is refresh_task and refresh_task.done(): + _balances_refresh_tasks.pop(units_key, None) + + return result async def periodic_payout() -> None: diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx index 92c51b56..0ff625d7 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -3,7 +3,6 @@ import { GeistMono } from 'geist/font/mono'; import { GeistSans } from 'geist/font/sans'; import './globals.css'; import { Providers } from './providers'; -import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning'; export const metadata: Metadata = { title: 'Routstr', @@ -23,9 +22,7 @@ export default function RootLayout({ - - {children} - + {children} ); diff --git a/ui/app/page.tsx b/ui/app/page.tsx index 380f465e..76addfd7 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -9,6 +9,7 @@ import type { DateRange } from 'react-day-picker'; import { UsageMetricsChart } from '@/components/usage-metrics-chart'; import { UsageSummaryCards } from '@/components/usage-summary-cards'; import { ErrorDetailsTable } from '@/components/error-details-table'; +import { TopModelsUsageChart } from '@/components/top-models-usage-chart'; import { DashboardBalanceSummary } from '@/components/dashboard-balance-summary'; import { AdminService, @@ -79,6 +80,7 @@ const TIME_RANGE_PRESETS = [ { value: '3m', label: 'Last 3 Months', hours: 90 * 24 }, { value: '12m', label: 'Last 12 Months', hours: 365 * 24 }, ] as const; +const MAX_USAGE_RANGE_HOURS = 365 * 24; type TimeRangePresetValue = (typeof TIME_RANGE_PRESETS)[number]['value']; @@ -159,20 +161,6 @@ function getAutoIntervalMinutes(hours: number): number { ); } -function getQueryErrorMessage(error: unknown): string { - if ( - error && - typeof error === 'object' && - 'message' in error && - typeof error.message === 'string' && - error.message.trim().length > 0 - ) { - return error.message; - } - - return 'The analytics request failed. Refresh and try again.'; -} - function SectionLoading({ label }: { label: string }) { if (label === 'summary') { return ( @@ -554,19 +542,21 @@ export default function DashboardPage() { isCustomRangeActive && customRangeHours ? customRangeHours : activePreset.hours; - const autoInterval = getAutoIntervalMinutes(queryHours); + const safeQueryHours = Math.min(queryHours, MAX_USAGE_RANGE_HOURS); + const isUsageRangeCapped = safeQueryHours < queryHours; + const autoInterval = getAutoIntervalMinutes(safeQueryHours); const usageRefetchIntervalMs = useMemo(() => { - if (queryHours > 90 * 24) { + if (safeQueryHours > 90 * 24) { return 4 * 60 * 60_000; } - if (queryHours > 30 * 24) { + if (safeQueryHours > 30 * 24) { return 2 * 60 * 60_000; } - if (queryHours > 7 * 24) { + if (safeQueryHours > 7 * 24) { return 30 * 60_000; } return 60_000; - }, [queryHours]); + }, [safeQueryHours]); const revenueDisplayUnit: DisplayUnit = useMemo(() => { if (displayUnit === 'usd' && usdPerSat === null) { // Keep revenue charts meaningful while the USD rate is unavailable. @@ -584,43 +574,30 @@ export default function DashboardPage() { : revenueDisplayUnit; const { - data: metricsData, - isLoading: metricsLoading, - error: metricsError, - refetch: refetchMetrics, + data: usageDashboardData, + isLoading: usageDashboardLoading, + refetch: refetchUsageDashboard, } = useQuery({ - queryKey: ['usage-metrics', autoInterval, queryHours], - queryFn: () => AdminService.getUsageMetrics(autoInterval, queryHours), + queryKey: ['usage-dashboard', autoInterval, safeQueryHours], + queryFn: () => + AdminService.getUsageDashboard(safeQueryHours, autoInterval, 100, 20), enabled: isAuthenticated, refetchInterval: usageRefetchIntervalMs, staleTime: 30_000, }); - const { - data: summaryData, - isLoading: summaryLoading, - error: summaryError, - refetch: refetchSummary, - } = useQuery({ - queryKey: ['usage-summary', queryHours], - queryFn: () => AdminService.getUsageSummary(queryHours), - enabled: isAuthenticated, - refetchInterval: usageRefetchIntervalMs, - staleTime: 30_000, - }); + const metricsData = usageDashboardData?.metrics; + const summaryData = usageDashboardData?.summary; + const errorData = usageDashboardData?.error_details; + const modelUsageMixData = usageDashboardData?.model_usage_mix; + const hasModelUsageMixMetrics = + Array.isArray(modelUsageMixData?.metrics) && + modelUsageMixData.metrics.length > 0; - const { - data: errorData, - isLoading: errorLoading, - error: errorDetailsError, - refetch: refetchErrors, - } = useQuery({ - queryKey: ['usage-errors', queryHours], - queryFn: () => AdminService.getErrorDetails(queryHours, 100), - enabled: isAuthenticated, - refetchInterval: usageRefetchIntervalMs, - staleTime: 30_000, - }); + const metricsLoading = usageDashboardLoading; + const summaryLoading = usageDashboardLoading; + const errorLoading = usageDashboardLoading; + const metricsTotals = metricsData?.totals; const chartConfigs = useMemo(() => { if (!metricsData || metricsData.metrics.length === 0) { @@ -647,12 +624,6 @@ export default function DashboardPage() { }) ) as ChartDatum[]; - const hasTokenMetrics = metricPoints.some((metric) => - ['input_tokens', 'output_tokens', 'total_tokens'].some( - (key) => typeof metric[key] === 'number' - ) - ); - return [ { id: 'revenue', @@ -661,6 +632,11 @@ export default function DashboardPage() { description: 'Track collected revenue trends over time.', data: revenuePoints, metricType: 'currency', + totals: metricsTotals + ? { + revenue_display: convertRevenueMsats(metricsTotals.revenue_msats), + } + : undefined, dataKeys: [ { key: 'revenue_display', @@ -676,6 +652,14 @@ export default function DashboardPage() { description: 'Understand traffic and completion reliability over time.', data: metricPoints, metricType: 'count', + totals: metricsTotals + ? { + total_requests: metricsTotals.total_requests, + successful_chat_completions: + metricsTotals.successful_chat_completions, + failed_requests: metricsTotals.failed_requests, + } + : undefined, dataKeys: [ { key: 'total_requests', @@ -701,6 +685,13 @@ export default function DashboardPage() { description: 'Monitor warnings, handled errors, and upstream failures.', data: metricPoints, metricType: 'count', + totals: metricsTotals + ? { + errors: metricsTotals.errors, + warnings: metricsTotals.warnings, + upstream_errors: metricsTotals.upstream_errors, + } + : undefined, dataKeys: [ { key: 'errors', @@ -726,6 +717,11 @@ export default function DashboardPage() { description: 'Follow payment processing activity by interval.', data: metricPoints, metricType: 'count', + totals: metricsTotals + ? { + payment_processed: metricsTotals.payment_processed, + } + : undefined, dataKeys: [ { key: 'payment_processed', @@ -734,38 +730,41 @@ export default function DashboardPage() { }, ], }, - ...(hasTokenMetrics - ? [ - { - id: 'tokens', - title: 'Token Usage', - mobileTitle: 'Tokens', - description: - 'Track input, output, and total token throughput over time.', - data: metricPoints, - metricType: 'count' as const, - dataKeys: [ - { - key: 'total_tokens', - name: 'Total Tokens', - color: 'var(--chart-1)', - }, - { - key: 'input_tokens', - name: 'Input Tokens', - color: 'var(--chart-2)', - }, - { - key: 'output_tokens', - name: 'Output Tokens', - color: 'var(--chart-3)', - }, - ], - }, - ] - : []), + { + id: 'tokens', + title: 'Token Usage', + mobileTitle: 'Tokens', + description: + 'Track input, output, and total token throughput over time.', + data: metricPoints, + metricType: 'count', + totals: metricsTotals + ? { + input_tokens: metricsTotals.input_tokens, + output_tokens: metricsTotals.output_tokens, + total_tokens: metricsTotals.total_tokens, + } + : undefined, + dataKeys: [ + { + key: 'total_tokens', + name: 'Total Tokens', + color: 'var(--chart-1)', + }, + { + key: 'input_tokens', + name: 'Input Tokens', + color: 'var(--chart-2)', + }, + { + key: 'output_tokens', + name: 'Output Tokens', + color: 'var(--chart-3)', + }, + ], + }, ]; - }, [metricsData, revenueDisplayUnit, usdPerSat]); + }, [metricsData, metricsTotals, revenueDisplayUnit, usdPerSat]); useEffect(() => { if (chartConfigs.length === 0) { @@ -803,11 +802,7 @@ export default function DashboardPage() { setIsManualRefreshing(true); try { - await Promise.allSettled([ - refetchMetrics(), - refetchSummary(), - refetchErrors(), - ]); + await refetchUsageDashboard(); } finally { setIsManualRefreshing(false); } @@ -913,10 +908,16 @@ export default function DashboardPage() { All cards and charts in this section update from the selected range.

+ {isUsageRangeCapped ? ( +

+ Usage analytics are capped to the last{' '} + {MAX_USAGE_RANGE_HOURS / 24} days for server safety. +

+ ) : null} -
-
+
+
- - {isManualRefreshing ? 'Refreshing...' : 'Refresh'} - + {isManualRefreshing ? 'Refreshing...' : 'Refresh'}
{metricsLoading ? ( - ) : metricsError ? ( - - - - - - - - Unable to load analytics - - {getQueryErrorMessage(metricsError)} - - - - - ) : activeChartConfig ? ( )} + {!metricsLoading && modelUsageMixData && hasModelUsageMixMetrics ? ( + + ) : null} + {summaryLoading ? ( - ) : summaryError ? ( - - - - - Usage summary unavailable - - {getQueryErrorMessage(summaryError)} - - - - - ) : summaryData ? ( ) : null} @@ -1074,19 +1051,6 @@ export default function DashboardPage() { {errorLoading ? ( - ) : errorDetailsError ? ( - - - - - Error details unavailable - - {getQueryErrorMessage(errorDetailsError)} - - - - - ) : errorData ? ( ) : null} diff --git a/ui/components/revenue-by-model-table.tsx b/ui/components/revenue-by-model-table.tsx new file mode 100644 index 00000000..00183bab --- /dev/null +++ b/ui/components/revenue-by-model-table.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { ModelRevenueData } from '@/lib/api/services/admin'; +import { convertToMsat, formatFromMsat } from '@/lib/currency'; +import { useIsMobile } from '@/hooks/use-mobile'; +import type { DisplayUnit } from '@/lib/types/units'; + +interface RevenueByModelTableProps { + models: ModelRevenueData[]; + displayUnit: DisplayUnit; + usdPerSat: number | null; +} + +function truncateModelName(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 1)}…`; +} + +export function RevenueByModelTable({ + models, + displayUnit, + usdPerSat, +}: RevenueByModelTableProps) { + const isMobile = useIsMobile(); + + const revenueDisplayUnit: DisplayUnit = useMemo(() => { + if (displayUnit === 'usd' && usdPerSat === null) { + return 'sat'; + } + return displayUnit; + }, [displayUnit, usdPerSat]); + const unitLabel = revenueDisplayUnit === 'usd' ? 'USD' : revenueDisplayUnit; + + const compactNumber = useMemo( + () => + new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }), + [] + ); + + const convertSatsToDisplay = useCallback( + (sats: number): number => { + if (revenueDisplayUnit === 'msat') { + return sats * 1000; + } + if (revenueDisplayUnit === 'usd') { + return sats * (usdPerSat ?? 0); + } + return sats; + }, + [revenueDisplayUnit, usdPerSat] + ); + + const formatAmount = (sats: number) => + formatFromMsat(convertToMsat(sats, 'sat'), revenueDisplayUnit, usdPerSat); + + const formatCompactAmount = (value: number): string => { + const compact = compactNumber.format(value); + if (revenueDisplayUnit === 'usd') { + return `$${compact}`; + } + return `${compact} ${unitLabel}`; + }; + + const totalCollectedRevenue = models.reduce( + (sum, model) => sum + model.revenue_sats, + 0 + ); + const totalOperationalNet = models.reduce( + (sum, model) => sum + model.net_revenue_sats, + 0 + ); + + const chartData = useMemo( + () => + [...models] + .sort((a, b) => b.revenue_sats - a.revenue_sats) + .slice(0, 12) + .map((model) => ({ + model: model.model, + modelLabel: truncateModelName(model.model, isMobile ? 16 : 28), + revenueDisplay: convertSatsToDisplay(model.revenue_sats), + })), + [models, isMobile, convertSatsToDisplay] + ); + + const chartConfig: ChartConfig = { + revenueDisplay: { + label: 'Revenue', + color: 'var(--chart-1)', + }, + }; + + if (chartData.length === 0) { + return ( + + + Revenue by Model + + + No model data available + + + ); + } + + return ( + + + Revenue by Model +

+ Total Collected Revenue:{' '} + + {formatAmount(totalCollectedRevenue)} + +

+

+ Operational Net:{' '} + + {formatAmount(totalOperationalNet)} + +

+
+ + + + + + compactNumber.format( + typeof value === 'number' ? value : Number(value || 0) + ) + } + /> + + String(label)} + formatter={(value, name) => { + const numericValue = + typeof value === 'number' ? value : Number(value || 0); + return ( +
+ + {name} + + + {Number.isFinite(numericValue) + ? formatCompactAmount(numericValue) + : '-'} + +
+ ); + }} + /> + } + /> + +
+
+
+
+ ); +} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index d91e24cb..b3f7677f 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -20,7 +20,6 @@ export const UpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean(), provider_fee: z.number().optional(), - provider_settings: z.record(z.string(), z.any()).nullable().optional(), }); export const CreateUpstreamProviderSchema = z.object({ @@ -30,7 +29,6 @@ export const CreateUpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean().default(true), provider_fee: z.number().optional(), - provider_settings: z.record(z.string(), z.any()).nullable().optional(), }); export const UpdateUpstreamProviderSchema = z.object({ @@ -40,7 +38,6 @@ export const UpdateUpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean().optional(), provider_fee: z.number().optional(), - provider_settings: z.record(z.string(), z.any()).nullable().optional(), }); export const AdminModelPricingSchema = z.object({ @@ -845,6 +842,23 @@ export class AdminService { ); } + static async getUsageDashboard( + hours: number = 24, + interval: number = 15, + errorLimit: number = 100, + modelLimit: number = 20 + ): Promise { + const params = new URLSearchParams(); + params.set('interval', String(interval)); + params.set('hours', String(hours)); + params.set('error_limit', String(errorLimit)); + params.set('model_limit', String(modelLimit)); + + return await apiClient.get( + `/admin/api/usage/dashboard?${params.toString()}` + ); + } + static async getUsageSummary(hours: number = 24): Promise { return await apiClient.get( `/admin/api/usage/summary?hours=${hours}` @@ -895,17 +909,9 @@ export class AdminService { ok: boolean; topup_data: Record; message: string; - }>(`/admin/api/upstream-providers/${providerId}/topup`, { amount }); - } - - static async topupProviderWithToken( - providerId: number, - token: string - ): Promise<{ ok: boolean; message?: string }> { - return await apiClient.post<{ ok: boolean; message?: string }>( - `/admin/api/upstream-providers/${providerId}/topup-token`, - { token } - ); + }>(`/admin/api/upstream-providers/${providerId}/topup`, { + amount: amount, + }); } static async checkTopupStatus( @@ -952,9 +958,9 @@ export interface UsageMetricData { upstream_errors: number; revenue_msats: number; refunds_msats: number; - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; + input_tokens: number; + output_tokens: number; + total_tokens: number; [key: string]: unknown; } @@ -963,7 +969,20 @@ export interface UsageMetrics { interval_minutes: number; hours_back: number; total_buckets: number; - totals?: Partial>; + totals?: { + total_requests: number; + successful_chat_completions: number; + failed_requests: number; + errors: number; + warnings: number; + payment_processed: number; + upstream_errors: number; + revenue_msats: number; + refunds_msats: number; + input_tokens: number; + output_tokens: number; + total_tokens: number; + }; } export interface UsageSummary { @@ -978,6 +997,12 @@ export interface UsageSummary { unique_models_count: number; unique_models: string[]; error_types: Record; + input_tokens: number; + output_tokens: number; + total_tokens: number; + avg_input_tokens_per_completion: number; + avg_output_tokens_per_completion: number; + avg_total_tokens_per_completion: number; success_rate: number; revenue_msats: number; refunds_msats: number; @@ -987,8 +1012,6 @@ export interface UsageSummary { net_revenue_sats: number; avg_revenue_per_request_msats: number; refund_rate: number; - total_tokens?: number; - avg_total_tokens_per_completion?: number; } export interface ErrorDetail { @@ -1022,6 +1045,35 @@ export interface RevenueByModel { total_models: number; } +export interface ModelUsageMixMetric { + timestamp: string; + total_successful: number; + total_revenue_msats: number; + total_tokens: number; + others: number; + others_revenue_msats: number; + others_tokens: number; + model_counts: Record; + model_revenue_msats: Record; + model_tokens: Record; +} + +export interface ModelUsageMix { + top_models: string[]; + metrics: ModelUsageMixMetric[]; + interval_minutes: number; + hours_back: number; + total_buckets: number; +} + +export interface UsageDashboardResponse { + metrics: UsageMetrics; + summary: UsageSummary; + error_details: ErrorDetails; + revenue_by_model: RevenueByModel; + model_usage_mix?: ModelUsageMix; +} + export interface LogEntry { asctime: string; name: string; From e3bca39815e0599cbea42277b98ebb715a8298bd Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Tue, 24 Feb 2026 12:16:39 +0000 Subject: [PATCH 03/13] Remove max_points plumbing from dashboard analytics --- routstr/core/usage_analytics_store.py | 31 --------------------------- 1 file changed, 31 deletions(-) diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py index 809604e3..ccea4938 100644 --- a/routstr/core/usage_analytics_store.py +++ b/routstr/core/usage_analytics_store.py @@ -36,7 +36,6 @@ class UsageAnalyticsStore: hours_back: int, error_limit: int, model_limit: int, - max_points: int | None, ) -> dict[str, Any]: with self._lock: conn = self._get_connection_locked() @@ -49,7 +48,6 @@ class UsageAnalyticsStore: cutoff_timestamp=cutoff_timestamp, interval_minutes=interval_minutes, hours_back=hours_back, - max_points=max_points, ) error_details = self._query_error_details_locked( conn, @@ -92,7 +90,6 @@ class UsageAnalyticsStore: cutoff_timestamp=cutoff_timestamp, interval_minutes=interval_minutes, hours_back=hours_back, - max_points=None, ) def get_error_details(self, *, hours_back: int, limit: int) -> dict[str, Any]: @@ -687,7 +684,6 @@ class UsageAnalyticsStore: cutoff_timestamp: str, interval_minutes: int, hours_back: int, - max_points: int | None, ) -> dict[str, Any]: bucket_seconds = max(60, int(interval_minutes) * 60) rows = conn.execute( @@ -764,8 +760,6 @@ class UsageAnalyticsStore: } ) - points = self._downsample_metric_points(points, max_points) - normalized_totals: dict[str, int | float] = { "total_requests": int(totals["total_requests"]), "successful_chat_completions": int(totals["successful_chat_completions"]), @@ -1016,28 +1010,3 @@ class UsageAnalyticsStore: "revenue_msats": 0.0, "refunds_msats": 0.0, } - - def _downsample_metric_points( - self, points: list[dict[str, Any]], max_points: int | None - ) -> list[dict[str, Any]]: - if max_points is None or max_points <= 0: - return points - if len(points) <= max_points: - return points - if max_points == 1: - return [points[-1]] - - step = (len(points) - 1) / (max_points - 1) - sampled: list[dict[str, Any]] = [] - last_index = -1 - - for i in range(max_points): - index = int(round(i * step)) - if index <= last_index: - index = min(last_index + 1, len(points) - 1) - sampled.append(points[index]) - last_index = index - - sampled[0] = points[0] - sampled[-1] = points[-1] - return sampled From cb22968ff38baceead455e0ad5f7d4454a7e01e4 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Tue, 24 Feb 2026 20:59:49 +0800 Subject: [PATCH 04/13] Refactor log management and usage analytics --- logs/.gitkeep | 0 routstr/core/logging.py | 24 ++++++++-------- routstr/core/usage_analytics_store.py | 40 +++++++++++++++++++-------- ui/components/landing/cheat-sheet.tsx | 4 +-- 4 files changed, 42 insertions(+), 26 deletions(-) delete mode 100644 logs/.gitkeep diff --git a/logs/.gitkeep b/logs/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 00474949..d88debb8 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -3,36 +3,38 @@ Logging configuration for Routstr. CRITICAL LOG MESSAGES FOR USAGE STATISTICS: =========================================== -The following log messages are parsed by the usage tracking system (routstr/core/admin.py). +The following log messages are parsed by the usage tracking system +(routstr/core/usage_analytics_store.py and routstr/core/log_manager.py). DO NOT modify or remove these messages without updating the usage tracking logic: 1. "Received proxy request" (INFO) - routstr/proxy.py - Used to count total incoming requests - Includes model information in context - 2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py - "Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py +2. "Calculated token-based cost" (INFO) - routstr/auth.py - Used to track successful completions and revenue - - The 'cost_data.total_msats' field is extracted for revenue calculation - - Must include 'cost_data' in extra dict + - The 'token_cost' and 'model' fields are extracted for dashboard metrics -3. "Payment processed successfully" (INFO) - routstr/auth.py +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 + +4. "Payment processed successfully" (INFO) - routstr/auth.py - Used to count successful payment processing events - Tracks payment-related metrics -4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py +5. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py - Used to track failed requests and refunds - The 'max_cost_for_model' field is extracted for refund calculation - Must include 'max_cost_for_model' in extra dict -5. Any ERROR level logs with "upstream" in the message +6. Any ERROR level logs with "upstream" in the message - Used to count upstream provider errors - Helps identify service reliability issues If you need to modify these messages, ensure you also update the parsing logic in: -- routstr/core/admin.py:_aggregate_metrics_by_time() -- routstr/core/admin.py:_get_summary_stats() -- routstr/core/admin.py:get_revenue_by_model() +- routstr/core/usage_analytics_store.py +- routstr/core/log_manager.py """ import logging.config diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py index ccea4938..c54c0d6d 100644 --- a/routstr/core/usage_analytics_store.py +++ b/routstr/core/usage_analytics_store.py @@ -21,7 +21,7 @@ class UsageAnalyticsStore: bytes. """ - SCHEMA_VERSION = "2" + SCHEMA_VERSION = "3" def __init__(self, logs_dir: Path, db_path: Path | None = None): self.logs_dir = logs_dir @@ -473,10 +473,7 @@ class UsageAnalyticsStore: elif level == "WARNING": bucket["warnings"] += 1 - completed = ( - "completed for streaming" in message - or "completed for non-streaming" in message - ) + completed, revenue_msats = self._extract_success_metrics(entry, message) if completed: bucket["total_requests"] += 1 bucket["successful_chat_completions"] += 1 @@ -484,13 +481,9 @@ class UsageAnalyticsStore: model_bucket["requests"] += 1 model_bucket["successful"] += 1 - cost_data = entry.get("cost_data") - if isinstance(cost_data, dict): - actual_cost = cost_data.get("total_msats", 0) - if isinstance(actual_cost, (int, float)) and actual_cost > 0: - cost_float = float(actual_cost) - bucket["revenue_msats"] += cost_float - model_bucket["revenue_msats"] += cost_float + if revenue_msats > 0: + bucket["revenue_msats"] += revenue_msats + model_bucket["revenue_msats"] += revenue_msats failed = ( "upstream request failed" in message @@ -988,6 +981,29 @@ class UsageAnalyticsStore: return None return f"{timestamp[:16]}:00" + def _extract_success_metrics( + self, entry: dict[str, Any], message: str + ) -> tuple[bool, float]: + # 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 + + 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 + + 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 False, 0.0 + def _new_minute_stats(self) -> dict[str, float]: return { "total_entries": 0.0, diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx index 7004ec98..fa9aea87 100644 --- a/ui/components/landing/cheat-sheet.tsx +++ b/ui/components/landing/cheat-sheet.tsx @@ -66,9 +66,7 @@ function normalizeBaseUrl(url: string): string { } export function CheatSheet(): JSX.Element { - const [baseUrl, setBaseUrl] = useState(() => - typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() - ); + const [baseUrl, setBaseUrl] = useState(''); const [apiKeyInput, setApiKeyInput] = useState(''); const [walletInfo, setWalletInfo] = useState(null); const [refundReceipt, setRefundReceipt] = useState( From 9bc3feff6221176a9a62afe07a4d7aca457fa14e Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Tue, 24 Feb 2026 22:29:43 +0800 Subject: [PATCH 05/13] Enhance payment and analytics tracking by adding input and output token metrics across various components --- routstr/auth.py | 4 + routstr/core/logging.py | 4 +- routstr/core/usage_analytics_store.py | 358 +++++++++- routstr/payment/cost_calculation.py | 70 +- ui/components/top-models-usage-chart.tsx | 815 +++++++++++++++++++++++ ui/components/usage-summary-cards.tsx | 41 +- 6 files changed, 1217 insertions(+), 75 deletions(-) create mode 100644 ui/components/top-models-usage-chart.tsx diff --git a/routstr/auth.py b/routstr/auth.py index 07d83dff..62b6c5b8 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -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, }, ) diff --git a/routstr/core/logging.py b/routstr/core/logging.py index d88debb8..14b1b1ff 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -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 diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py index c54c0d6d..2df1f440 100644 --- a/routstr/core/usage_analytics_store.py +++ b/routstr/core/usage_analytics_store.py @@ -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, } diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index 1df4caf8..2ed7e4b8 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -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, ) diff --git a/ui/components/top-models-usage-chart.tsx b/ui/components/top-models-usage-chart.tsx new file mode 100644 index 00000000..87474803 --- /dev/null +++ b/ui/components/top-models-usage-chart.tsx @@ -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 = { + '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('requests'); + const [hoveredSeriesKey, setHoveredSeriesKey] = useState(null); + const [isChartPointerInside, setIsChartPointerInside] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const isMobile = useIsMobile(); + const containerRef = useRef(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 = { + 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(() => { + 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 ( +
+ + +
+
+ Model Usage +

+ Stacked requests, revenue, or tokens by model ( + {mix.interval_minutes}m buckets). +

+
+
+
+ + + +
+ +
+
+
+ + { + setHoveredSeriesKey(null); + setIsChartPointerInside(false); + }} + > + setIsChartPointerInside(true)} + onMouseMove={() => setIsChartPointerInside(true)} + onMouseLeave={() => { + setHoveredSeriesKey(null); + setIsChartPointerInside(false); + }} + margin={{ + top: 12, + right: isMobile ? 8 : 18, + left: isMobile ? 0 : 8, + bottom: 0, + }} + > + + + formatAxisTimestamp(String(value), hasMultipleDays) + } + /> + + formatValue( + typeof value === 'number' ? value : Number(value || 0) + ) + } + /> + { + 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 ( +
+

+ {formatTooltipTimestamp(String(label || ''))} +

+
+ {rows.map((row) => ( +
+ + + {row.label} + + + {formatValue(row.value)} + +
+ ))} +
+
+
+ Total + + {formatValue(total)} + +
+
+
+ ); + }} + /> + {activeSeries.map((item) => ( + setHoveredSeriesKey(item.dataKey)} + onMouseLeave={() => setHoveredSeriesKey(null)} + /> + ))} + setHoveredSeriesKey(othersKey)} + onMouseLeave={() => setHoveredSeriesKey(null)} + /> +
+
+ +
+
+

+ Top models +

+

+ Change vs prior period +

+
+ + {leaderboardRows.length > 0 ? ( +
+ {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 ( +
{ + if (row.chartDataKey) { + setHoveredSeriesKey(row.chartDataKey); + } + }} + onMouseLeave={() => { + if (row.chartDataKey) { + setHoveredSeriesKey(null); + } + }} + > + + {row.rank}. + +
+ + {row.displayName} + {' '} + + by {row.provider} + +
+ + {formatLeaderboardTotal(row.totalRaw)} + + + {trendLabel} + +
+ ); + })} +
+ ) : ( +

+ No model totals available for this range. +

+ )} +
+
+
+
+ ); +} diff --git a/ui/components/usage-summary-cards.tsx b/ui/components/usage-summary-cards.tsx index e578748a..7db46735 100644 --- a/ui/components/usage-summary-cards.tsx +++ b/ui/components/usage-summary-cards.tsx @@ -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), From 8fc1b6484c897ed00696b88e48c437eb97061e66 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Mon, 2 Mar 2026 16:52:39 +0800 Subject: [PATCH 06/13] Add Nostr analytics snapshots and expand stats model coverage --- .env.example | 1 + docs/provider/configuration.md | 2 + docs/provider/dashboard.md | 1 + routstr/core/main.py | 12 +- routstr/core/settings.py | 33 +- routstr/core/usage_analytics_store.py | 2 +- routstr/nostr/__init__.py | 3 +- routstr/nostr/analytics.py | 771 ++++++++++++++++++++++ tests/unit/test_nostr_analytics.py | 230 +++++++ tests/unit/test_settings.py | 38 +- ui/components/settings/admin-settings.tsx | 50 ++ ui/components/top-models-usage-chart.tsx | 6 +- 12 files changed, 1137 insertions(+), 12 deletions(-) create mode 100644 routstr/nostr/analytics.py create mode 100644 tests/unit/test_nostr_analytics.py diff --git a/.env.example b/.env.example index d093e006..d4c768d5 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ UPSTREAM_API_KEY=your-upstream-api-key # HTTP_URL=https://api.mynode.com # ONION_URL=http://mynode.onion (auto fetched from compose) # RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com" +# ENABLE_ANALYTICS_SHARING=true # CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me" # RECEIVE_LN_ADDRESS= diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index 633ccd10..20c0e70c 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -97,6 +97,7 @@ Announce your node on the network: | **Npub** | Your Nostr public key | | **Nsec** | Your Nostr private key (for signing) | | **Relays** | Relays to publish announcements | +| **Share Analytics** | Publish aggregate usage stats to Nostr | See [Discovery](discovery.md) for details. @@ -122,6 +123,7 @@ Use environment variables for: | `DESCRIPTION` | Node description | `A Routstr Node` | | `NPUB` | Nostr public key (bech32) | — | | `NSEC` | Nostr private key | — | +| `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` | | `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` | | `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — | | `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` | diff --git a/docs/provider/dashboard.md b/docs/provider/dashboard.md index aaa860bf..66852845 100644 --- a/docs/provider/dashboard.md +++ b/docs/provider/dashboard.md @@ -152,6 +152,7 @@ Manage which mints you accept payments from: |-------|-------------| | **Nsec** | Private key for signing announcements | | **Relays** | Where to publish your node advertisement | +| **Share Analytics** | Toggle publishing aggregate usage stats to Nostr | ### Security diff --git a/routstr/core/main.py b/routstr/core/main.py index 461f8b6d..d0769706 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -12,7 +12,11 @@ from starlette.exceptions import HTTPException from ..auth import periodic_key_reset from ..balance import balance_router, deprecated_wallet_router -from ..nostr import announce_provider, providers_cache_refresher +from ..nostr import ( + announce_provider, + providers_cache_refresher, + publish_usage_analytics, +) from ..nostr.discovery import providers_router from ..payment.models import models_router, update_sats_pricing from ..payment.price import update_prices_periodically @@ -45,6 +49,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: pricing_task = None payout_task = None nip91_task = None + analytics_task = None providers_task = None models_refresh_task = None model_maps_refresh_task = None @@ -103,6 +108,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: payout_task = asyncio.create_task(periodic_payout()) if global_settings.nsec: nip91_task = asyncio.create_task(announce_provider()) + analytics_task = asyncio.create_task(publish_usage_analytics()) if global_settings.providers_refresh_interval_seconds > 0: providers_task = asyncio.create_task(providers_cache_refresher()) key_reset_task = asyncio.create_task(periodic_key_reset()) @@ -130,6 +136,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: payout_task.cancel() if nip91_task is not None: nip91_task.cancel() + if analytics_task is not None: + analytics_task.cancel() if providers_task is not None: providers_task.cancel() if models_refresh_task is not None: @@ -151,6 +159,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(payout_task) if nip91_task is not None: tasks_to_wait.append(nip91_task) + if analytics_task is not None: + tasks_to_wait.append(analytics_task) if providers_task is not None: tasks_to_wait.append(providers_task) if models_refresh_task is not None: diff --git a/routstr/core/settings.py b/routstr/core/settings.py index fa1464ac..20a3053c 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -92,6 +92,20 @@ class Settings(BaseSettings): # Discovery relays: list[str] = Field(default_factory=list, env="RELAYS") + enable_analytics_sharing: bool = Field( + default=True, env="ENABLE_ANALYTICS_SHARING" + ) + +def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]: + """Discard unknown keys from persisted settings.""" + normalized: dict[str, Any] = {} + known_fields = Settings.__fields__ + + for key, value in data.items(): + if key in known_fields: + normalized[key] = value + + return normalized def _compute_primary_mint(cashu_mints: list[str]) -> str: @@ -231,16 +245,20 @@ class SettingsService: db_id, db_data, _updated_at = row try: - db_json = ( + db_json_raw = ( json.loads(db_data) if isinstance(db_data, str) else dict(db_data) ) + if not isinstance(db_json_raw, dict): + db_json_raw = {} except Exception: - db_json = {} + db_json_raw = {} + db_json = _normalize_settings_data(db_json_raw) merged_dict: dict[str, Any] = dict(env_resolved.dict()) merged_dict.update( {k: v for k, v in db_json.items() if v not in (None, "", [], {})} ) + merged_dict = Settings(**merged_dict).dict() # Ensure primary_mint is consistent with cashu_mints if not explicitly set if not merged_dict.get("primary_mint"): @@ -248,7 +266,7 @@ class SettingsService: merged_dict.get("cashu_mints", []) ) - if any(k not in db_json for k in merged_dict.keys()): + if db_json_raw != merged_dict: await db_session.exec( # type: ignore text( "UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1" @@ -271,7 +289,7 @@ class SettingsService: ) -> Settings: async with cls._lock: current = cls.get() - candidate_dict = {**current.dict(), **partial} + candidate_dict = {**current.dict(), **_normalize_settings_data(partial)} candidate = Settings(**candidate_dict) from sqlmodel import text @@ -304,7 +322,12 @@ class SettingsService: if row is None: raise RuntimeError("Settings row missing") (data_str,) = row - data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str) + data_raw = ( + json.loads(data_str) if isinstance(data_str, str) else dict(data_str) + ) + if not isinstance(data_raw, dict): + data_raw = {} + data = _normalize_settings_data(data_raw) # Update in-place for k, v in data.items(): setattr(settings, k, v) diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py index 2df1f440..c5ce3023 100644 --- a/routstr/core/usage_analytics_store.py +++ b/routstr/core/usage_analytics_store.py @@ -1115,7 +1115,7 @@ class UsageAnalyticsStore: hours_back: int, limit: int, ) -> dict[str, Any]: - top_limit = max(1, min(int(limit), 10)) + top_limit = max(1, min(int(limit), 20)) top_rows = conn.execute( """ SELECT diff --git a/routstr/nostr/__init__.py b/routstr/nostr/__init__.py index b19039a7..afd165f5 100644 --- a/routstr/nostr/__init__.py +++ b/routstr/nostr/__init__.py @@ -1,4 +1,5 @@ +from .analytics import publish_usage_analytics from .discovery import providers_cache_refresher from .listing import announce_provider -__all__ = ["providers_cache_refresher", "announce_provider"] +__all__ = ["providers_cache_refresher", "announce_provider", "publish_usage_analytics"] diff --git a/routstr/nostr/analytics.py b/routstr/nostr/analytics.py new file mode 100644 index 00000000..137040cb --- /dev/null +++ b/routstr/nostr/analytics.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +""" +Nostr usage analytics publisher. +Publishes routstr analytics snapshots for latest/day/month plus daily checkpoints. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import math +import time +from datetime import datetime, timezone +from typing import Any + +from nostr.event import Event +from nostr.key import PrivateKey + +from ..core import get_logger +from ..core.log_manager import log_manager +from ..core.settings import settings +from .listing import nsec_to_keypair, publish_to_relay + +logger = get_logger(__name__) + +ANALYTICS_KIND = 38422 +ANALYTICS_SCHEMA = "routstr.analytics.usage.v2" +ANALYTICS_CHECKPOINT_SCHEMA = "routstr.analytics.checkpoint.v1" +DEFAULT_RELAYS = [ + "wss://relay.nostr.band", + "wss://relay.damus.io", + "wss://relay.routstr.com", + "wss://nos.lol", +] +PUBLISH_INTERVAL_SECONDS = 15 * 60 +DISABLED_POLL_SECONDS = 60 +DASHBOARD_WINDOW_HOURS = 24 +DASHBOARD_INTERVAL_MINUTES = 60 +MODEL_LIMIT = 20 + +WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = ( + ("24h", 24, 60), + ("7d", 7 * 24, 6 * 60), + ("30d", 30 * 24, 24 * 60), +) + + +def _event_to_dict(ev: Event) -> dict[str, Any]: + return { + "id": ev.id, + "pubkey": ev.public_key, + "created_at": ev.created_at, + "kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind, + "tags": ev.tags, + "content": ev.content, + "sig": ev.signature, + } + + +def _resolve_provider_id(public_key_hex: str) -> str: + explicit_provider_id = (settings.provider_id or "").strip() + if explicit_provider_id: + return explicit_provider_id + return public_key_hex[:12] + + +def _resolve_endpoint_urls() -> list[str]: + urls: list[str] = [] + http_url = (settings.http_url or "").strip() + onion_url = (settings.onion_url or "").strip() + + if http_url and http_url != "http://localhost:8000": + urls.append(http_url) + + if onion_url: + if onion_url.endswith(".onion") and not ( + onion_url.startswith("http://") or onion_url.startswith("https://") + ): + onion_url = f"http://{onion_url}" + urls.append(onion_url) + + return urls + + +def _resolve_relays() -> list[str]: + configured = [url.strip() for url in settings.relays if url.strip()] + return configured if configured else list(DEFAULT_RELAYS) + + +def _to_int(value: Any) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(float(value)) + except ValueError: + return 0 + return 0 + + +def _to_float(value: Any) -> float: + if isinstance(value, bool): + return float(int(value)) + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return 0.0 + return 0.0 + + +def _utc_day_key(unix_ts: int) -> str: + return datetime.fromtimestamp(unix_ts, tz=timezone.utc).strftime("%Y-%m-%d") + + +def _utc_month_key(unix_ts: int) -> str: + return datetime.fromtimestamp(unix_ts, tz=timezone.utc).strftime("%Y-%m") + + +def _utc_day_start_ts(unix_ts: int) -> int: + dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc) + start = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc) + return int(start.timestamp()) + + +def _utc_month_start_ts(unix_ts: int) -> int: + dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc) + start = datetime(dt.year, dt.month, 1, tzinfo=timezone.utc) + return int(start.timestamp()) + + +def _hours_since(start_ts: int, end_ts: int) -> int: + elapsed = max(1, end_ts - start_ts) + return max(1, int(math.ceil(elapsed / 3600))) + + +def _aggregate_top_model_usage( + model_usage_mix: dict[str, Any], +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + top_models_raw = model_usage_mix.get("top_models", []) + mix_metrics_raw = model_usage_mix.get("metrics", []) + + top_models = [model for model in top_models_raw if isinstance(model, str)] + metrics = [row for row in mix_metrics_raw if isinstance(row, dict)] + + model_totals: dict[str, dict[str, float | int]] = { + model: { + "successful_requests": 0, + "revenue_msats": 0.0, + "total_tokens": 0, + } + for model in top_models + } + others = { + "successful_requests": 0, + "revenue_msats": 0.0, + "total_tokens": 0, + } + + for metric in metrics: + model_counts = metric.get("model_counts", {}) + model_revenue = metric.get("model_revenue_msats", {}) + model_tokens = metric.get("model_tokens", {}) + + if isinstance(model_counts, dict): + for model, count in model_counts.items(): + if model in model_totals: + model_totals[model]["successful_requests"] += _to_int(count) + + if isinstance(model_revenue, dict): + for model, amount in model_revenue.items(): + if model in model_totals: + model_totals[model]["revenue_msats"] += _to_float(amount) + + if isinstance(model_tokens, dict): + for model, token_count in model_tokens.items(): + if model in model_totals: + model_totals[model]["total_tokens"] += _to_int(token_count) + + others["successful_requests"] += _to_int(metric.get("others", 0)) + others["revenue_msats"] += _to_float(metric.get("others_revenue_msats", 0.0)) + others["total_tokens"] += _to_int(metric.get("others_tokens", 0)) + + model_rows = [ + { + "model": model, + "successful_requests": int(values["successful_requests"]), + "revenue_msats": float(values["revenue_msats"]), + "total_tokens": int(values["total_tokens"]), + } + for model, values in model_totals.items() + ] + model_rows.sort(key=lambda row: row["successful_requests"], reverse=True) + + return model_rows, others + + +def _build_summary_payload(summary: dict[str, Any]) -> dict[str, Any]: + return { + "total_requests": _to_int(summary.get("total_requests", 0)), + "successful_chat_completions": _to_int( + summary.get("successful_chat_completions", 0) + ), + "failed_requests": _to_int(summary.get("failed_requests", 0)), + "success_rate": _to_float(summary.get("success_rate", 0.0)), + "unique_models_count": _to_int(summary.get("unique_models_count", 0)), + "input_tokens": _to_int(summary.get("input_tokens", 0)), + "output_tokens": _to_int(summary.get("output_tokens", 0)), + "total_tokens": _to_int(summary.get("total_tokens", 0)), + "revenue_msats": _to_float(summary.get("revenue_msats", 0.0)), + "refunds_msats": _to_float(summary.get("refunds_msats", 0.0)), + "net_revenue_msats": _to_float(summary.get("net_revenue_msats", 0.0)), + "revenue_sats": _to_float(summary.get("revenue_sats", 0.0)), + "refunds_sats": _to_float(summary.get("refunds_sats", 0.0)), + "net_revenue_sats": _to_float(summary.get("net_revenue_sats", 0.0)), + } + + +def _build_model_revenue_rows(revenue_by_model: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + models_raw = revenue_by_model.get("models", []) + if not isinstance(models_raw, list): + return rows + + for row in models_raw: + if not isinstance(row, dict): + continue + model_name = str(row.get("model", "unknown")) + rows.append( + { + "model": model_name, + "requests": _to_int(row.get("requests", 0)), + "successful": _to_int(row.get("successful", 0)), + "failed": _to_int(row.get("failed", 0)), + "revenue_sats": _to_float(row.get("revenue_sats", 0.0)), + "refunds_sats": _to_float(row.get("refunds_sats", 0.0)), + "net_revenue_sats": _to_float(row.get("net_revenue_sats", 0.0)), + } + ) + return rows + + +def _build_window_payload( + *, + hours: int, + interval: int, + model_limit: int, +) -> dict[str, Any]: + dashboard = log_manager.get_usage_dashboard( + interval=interval, + hours=hours, + error_limit=1, + model_limit=model_limit, + ) + summary = dashboard.get("summary", {}) + revenue_by_model = dashboard.get("revenue_by_model", {}) + model_usage_mix = dashboard.get("model_usage_mix", {}) + + summary_payload = _build_summary_payload(summary if isinstance(summary, dict) else {}) + model_revenue_rows = _build_model_revenue_rows( + revenue_by_model if isinstance(revenue_by_model, dict) else {} + ) + usage_mix_payload = model_usage_mix if isinstance(model_usage_mix, dict) else {} + top_model_usage, others_usage = _aggregate_top_model_usage(usage_mix_payload) + + return { + "window_hours": hours, + "interval_minutes": interval, + "summary": summary_payload, + "model_revenue": model_revenue_rows, + "top_model_usage": top_model_usage, + "others_usage": others_usage, + "model_usage_mix": usage_mix_payload, + } + + +def build_latest_usage_analytics_payload( + provider_id: str, + *, + public_key_hex: str, + generated_at: int, + model_limit: int = MODEL_LIMIT, +) -> dict[str, Any]: + windows: dict[str, dict[str, Any]] = {} + for key, window_hours, window_interval in WINDOW_DEFINITIONS: + windows[key] = _build_window_payload( + hours=window_hours, + interval=window_interval, + model_limit=model_limit, + ) + + primary_window = windows.get("24h", {}) + return { + "schema": ANALYTICS_SCHEMA, + "generated_at": generated_at, + "provider_id": provider_id, + "pubkey": public_key_hex, + "npub": settings.npub or "", + "endpoint_urls": _resolve_endpoint_urls(), + "period_type": "latest", + "period_key": "latest", + "period_start_unix": generated_at - (24 * 3600), + "period_end_unix": generated_at, + "summary": primary_window.get("summary", {}), + "model_revenue": primary_window.get("model_revenue", []), + "top_model_usage": primary_window.get("top_model_usage", []), + "others_usage": primary_window.get("others_usage", {}), + "model_usage_mix": primary_window.get("model_usage_mix", {}), + "windows": windows, + } + + +def _build_period_payload( + provider_id: str, + *, + public_key_hex: str, + generated_at: int, + period_type: str, + period_key: str, + period_start_unix: int, + interval_minutes: int, + model_limit: int = MODEL_LIMIT, +) -> dict[str, Any]: + hours = _hours_since(period_start_unix, generated_at) + window = _build_window_payload( + hours=hours, + interval=interval_minutes, + model_limit=model_limit, + ) + return { + "schema": ANALYTICS_SCHEMA, + "generated_at": generated_at, + "provider_id": provider_id, + "pubkey": public_key_hex, + "npub": settings.npub or "", + "endpoint_urls": _resolve_endpoint_urls(), + "period_type": period_type, + "period_key": period_key, + "period_start_unix": period_start_unix, + "period_end_unix": generated_at, + "window_hours": window.get("window_hours", hours), + "interval_minutes": window.get("interval_minutes", interval_minutes), + "summary": window.get("summary", {}), + "model_revenue": window.get("model_revenue", []), + "top_model_usage": window.get("top_model_usage", []), + "others_usage": window.get("others_usage", {}), + "model_usage_mix": window.get("model_usage_mix", {}), + } + + +def build_day_usage_analytics_payload( + provider_id: str, + *, + public_key_hex: str, + generated_at: int, + model_limit: int = MODEL_LIMIT, +) -> dict[str, Any]: + day_key = _utc_day_key(generated_at) + day_start = _utc_day_start_ts(generated_at) + payload = _build_period_payload( + provider_id, + public_key_hex=public_key_hex, + generated_at=generated_at, + period_type="day", + period_key=day_key, + period_start_unix=day_start, + interval_minutes=60, + model_limit=model_limit, + ) + payload["day"] = day_key + return payload + + +def build_month_usage_analytics_payload( + provider_id: str, + *, + public_key_hex: str, + generated_at: int, + model_limit: int = MODEL_LIMIT, +) -> dict[str, Any]: + month_key = _utc_month_key(generated_at) + month_start = _utc_month_start_ts(generated_at) + payload = _build_period_payload( + provider_id, + public_key_hex=public_key_hex, + generated_at=generated_at, + period_type="month", + period_key=month_key, + period_start_unix=month_start, + interval_minutes=24 * 60, + model_limit=model_limit, + ) + payload["month"] = month_key + return payload + + +def build_usage_analytics_payload( + provider_id: str, + *, + public_key_hex: str, + hours: int = DASHBOARD_WINDOW_HOURS, + interval: int = DASHBOARD_INTERVAL_MINUTES, + model_limit: int = MODEL_LIMIT, +) -> dict[str, Any]: + # Backward-compatible helper kept for existing tests/callers. + _ = (hours, interval) + return build_latest_usage_analytics_payload( + provider_id, + public_key_hex=public_key_hex, + generated_at=int(time.time()), + model_limit=model_limit, + ) + + +def create_usage_analytics_event( + private_key_hex: str, + provider_id: str, + payload_json: str, + *, + period_type: str, + period_key: str, + d_tag: str, +) -> dict[str, Any]: + private_key = PrivateKey(bytes.fromhex(private_key_hex)) + tags = [ + ["d", d_tag], + ["provider", provider_id], + ["schema", ANALYTICS_SCHEMA], + ["period", period_type], + ["period_key", period_key], + ] + if period_type == "day": + tags.append(["day", period_key]) + elif period_type == "month": + tags.append(["month", period_key]) + + event = Event( + public_key=private_key.public_key.hex(), + content=payload_json, + kind=ANALYTICS_KIND, + tags=tags, + ) + private_key.sign_event(event) + return _event_to_dict(event) + + +def _fingerprint_payload(payload: dict[str, Any]) -> str: + normalized = dict(payload) + # Ignore volatile timestamps for deduping semantically identical snapshots. + normalized.pop("generated_at", None) + normalized.pop("period_end_unix", None) + payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(payload_json.encode("utf-8")).hexdigest() + + +def _stable_hash(data: dict[str, Any]) -> str: + encoded = json.dumps(data, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def build_analytics_checkpoint_payload( + provider_id: str, + *, + public_key_hex: str, + generated_at: int, + day_utc: str, + refs: dict[str, dict[str, str]], + previous_checkpoint_hash: str | None, +) -> dict[str, Any]: + base = { + "schema": ANALYTICS_CHECKPOINT_SCHEMA, + "generated_at": generated_at, + "provider_id": provider_id, + "pubkey": public_key_hex, + "npub": settings.npub or "", + "day_utc": day_utc, + "refs": refs, + "previous_checkpoint_hash": previous_checkpoint_hash or "", + } + checkpoint_hash = _stable_hash( + { + "provider_id": provider_id, + "day_utc": day_utc, + "refs": refs, + "previous_checkpoint_hash": previous_checkpoint_hash or "", + } + ) + base["checkpoint_hash"] = checkpoint_hash + return base + + +def create_analytics_checkpoint_event( + private_key_hex: str, + provider_id: str, + payload_json: str, + *, + day_utc: str, + previous_checkpoint_hash: str | None, +) -> dict[str, Any]: + private_key = PrivateKey(bytes.fromhex(private_key_hex)) + tags = [ + ["d", f"{provider_id}:usage:checkpoint:{day_utc}"], + ["provider", provider_id], + ["schema", ANALYTICS_CHECKPOINT_SCHEMA], + ["day", day_utc], + ] + if previous_checkpoint_hash: + tags.append(["prev", previous_checkpoint_hash]) + + event = Event( + public_key=private_key.public_key.hex(), + content=payload_json, + kind=ANALYTICS_KIND, + tags=tags, + ) + private_key.sign_event(event) + return _event_to_dict(event) + + +async def publish_usage_analytics() -> None: + last_period_state: dict[str, tuple[str, str]] = {} + last_checkpoint_state: tuple[str, str] | None = None + checkpoint_day: str | None = None + checkpoint_hash_for_day: str | None = None + previous_checkpoint_hash: str | None = None + + parsed_nsec: str | None = None + private_key_hex: str | None = None + public_key_hex: str | None = None + provider_id: str | None = None + warned_missing_nsec = False + + logger.info("Usage analytics sharing task started") + + while True: + try: + if not settings.enable_analytics_sharing: + await asyncio.sleep(DISABLED_POLL_SECONDS) + continue + + nsec = (settings.nsec or "").strip() + if not nsec: + if not warned_missing_nsec: + logger.info("NSEC is not configured; skipping analytics sharing to Nostr") + warned_missing_nsec = True + await asyncio.sleep(DISABLED_POLL_SECONDS) + continue + + warned_missing_nsec = False + if nsec != parsed_nsec or private_key_hex is None or public_key_hex is None: + keypair = nsec_to_keypair(nsec) + if not keypair: + logger.error("Invalid NSEC; analytics sharing is paused") + await asyncio.sleep(DISABLED_POLL_SECONDS) + continue + private_key_hex, public_key_hex = keypair + parsed_nsec = nsec + provider_id = _resolve_provider_id(public_key_hex) + last_period_state = {} + last_checkpoint_state = None + checkpoint_day = None + checkpoint_hash_for_day = None + previous_checkpoint_hash = None + + if private_key_hex is None or public_key_hex is None: + await asyncio.sleep(DISABLED_POLL_SECONDS) + continue + + relay_urls = _resolve_relays() + if not relay_urls: + logger.warning("No Nostr relays configured; analytics sharing skipped") + await asyncio.sleep(DISABLED_POLL_SECONDS) + continue + + resolved_provider_id = provider_id or _resolve_provider_id(public_key_hex) + now_ts = int(time.time()) + day_key = _utc_day_key(now_ts) + month_key = _utc_month_key(now_ts) + + latest_payload = build_latest_usage_analytics_payload( + resolved_provider_id, + public_key_hex=public_key_hex, + generated_at=now_ts, + ) + day_payload = build_day_usage_analytics_payload( + resolved_provider_id, + public_key_hex=public_key_hex, + generated_at=now_ts, + ) + month_payload = build_month_usage_analytics_payload( + resolved_provider_id, + public_key_hex=public_key_hex, + generated_at=now_ts, + ) + + payload_specs = [ + { + "period_type": "latest", + "period_key": "latest", + "d_tag": f"{resolved_provider_id}:usage:latest", + "payload": latest_payload, + }, + { + "period_type": "day", + "period_key": day_key, + "d_tag": f"{resolved_provider_id}:usage:day:{day_key}", + "payload": day_payload, + }, + { + "period_type": "month", + "period_key": month_key, + "d_tag": f"{resolved_provider_id}:usage:month:{month_key}", + "payload": month_payload, + }, + ] + + to_publish: list[dict[str, Any]] = [] + refs: dict[str, dict[str, str]] = {} + for spec in payload_specs: + payload = spec["payload"] + payload_hash = _fingerprint_payload(payload) + d_tag = str(spec["d_tag"]) + period_type = str(spec["period_type"]) + + refs[period_type] = {"d": d_tag, "payload_hash": payload_hash} + last_state = last_period_state.get(period_type) + if last_state is not None and last_state[0] == d_tag and last_state[1] == payload_hash: + continue + + payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True) + event = create_usage_analytics_event( + private_key_hex, + resolved_provider_id, + payload_json, + period_type=period_type, + period_key=str(spec["period_key"]), + d_tag=d_tag, + ) + to_publish.append( + { + "period_type": period_type, + "d_tag": d_tag, + "payload_hash": payload_hash, + "event": event, + } + ) + + period_attempted = {str(item["period_type"]) for item in to_publish} + period_successes = {period_type: 0 for period_type in period_attempted} + if to_publish: + for relay_url in relay_urls: + for item in to_publish: + if await publish_to_relay(relay_url, item["event"]): + period_successes[item["period_type"]] += 1 + + for item in to_publish: + period_type = item["period_type"] + if period_successes.get(period_type, 0) > 0: + last_period_state[period_type] = ( + item["d_tag"], + item["payload_hash"], + ) + + if checkpoint_day is None: + checkpoint_day = day_key + elif checkpoint_day != day_key: + if checkpoint_hash_for_day: + previous_checkpoint_hash = checkpoint_hash_for_day + checkpoint_day = day_key + checkpoint_hash_for_day = None + last_checkpoint_state = None + + checkpoint_payload = build_analytics_checkpoint_payload( + resolved_provider_id, + public_key_hex=public_key_hex, + generated_at=now_ts, + day_utc=day_key, + refs=refs, + previous_checkpoint_hash=previous_checkpoint_hash, + ) + checkpoint_d = f"{resolved_provider_id}:usage:checkpoint:{day_key}" + checkpoint_hash = _fingerprint_payload(checkpoint_payload) + + checkpoint_attempted = False + checkpoint_success_count = 0 + if ( + last_checkpoint_state is None + or last_checkpoint_state[0] != checkpoint_d + or last_checkpoint_state[1] != checkpoint_hash + ): + checkpoint_attempted = True + checkpoint_payload_json = json.dumps( + checkpoint_payload, + separators=(",", ":"), + sort_keys=True, + ) + checkpoint_event = create_analytics_checkpoint_event( + private_key_hex, + resolved_provider_id, + checkpoint_payload_json, + day_utc=day_key, + previous_checkpoint_hash=previous_checkpoint_hash, + ) + for relay_url in relay_urls: + if await publish_to_relay(relay_url, checkpoint_event): + checkpoint_success_count += 1 + + if checkpoint_success_count > 0: + last_checkpoint_state = (checkpoint_d, checkpoint_hash) + checkpoint_hash_for_day = str( + checkpoint_payload.get("checkpoint_hash", "") + ) or None + + relay_total = len(relay_urls) + latest_result = ( + f"{period_successes.get('latest', 0)}/{relay_total}" + if "latest" in period_attempted + else "skip" + ) + day_result = ( + f"{period_successes.get('day', 0)}/{relay_total}" + if "day" in period_attempted + else "skip" + ) + month_result = ( + f"{period_successes.get('month', 0)}/{relay_total}" + if "month" in period_attempted + else "skip" + ) + checkpoint_result = ( + f"{checkpoint_success_count}/{relay_total}" + if checkpoint_attempted + else "skip" + ) + logger.info( + "Published analytics snapshots " + "(latest=%s day=%s month=%s checkpoint=%s day_utc=%s month_utc=%s)", + latest_result, + day_result, + month_result, + checkpoint_result, + day_key, + month_key, + extra={ + "latest_relays": period_successes.get("latest", 0), + "day_relays": period_successes.get("day", 0), + "month_relays": period_successes.get("month", 0), + "checkpoint_relays": checkpoint_success_count, + "relay_total": relay_total, + "day": day_key, + "month": month_key, + }, + ) + await asyncio.sleep(PUBLISH_INTERVAL_SECONDS) + + except asyncio.CancelledError: + logger.info("Usage analytics sharing task cancelled") + break + except Exception as e: + logger.error( + "Usage analytics sharing error", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + await asyncio.sleep(DISABLED_POLL_SECONDS) diff --git a/tests/unit/test_nostr_analytics.py b/tests/unit/test_nostr_analytics.py new file mode 100644 index 00000000..adc0248d --- /dev/null +++ b/tests/unit/test_nostr_analytics.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from typing import Any + +from routstr.nostr import analytics + + +def test_aggregate_top_model_usage_sums_metrics() -> None: + model_usage_mix = { + "top_models": ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"], + "metrics": [ + { + "model_counts": { + "openai/gpt-4o": 4, + "anthropic/claude-3.5-sonnet": 2, + }, + "model_revenue_msats": { + "openai/gpt-4o": 1500, + "anthropic/claude-3.5-sonnet": 700, + }, + "model_tokens": { + "openai/gpt-4o": 1200, + "anthropic/claude-3.5-sonnet": 600, + }, + "others": 1, + "others_revenue_msats": 300, + "others_tokens": 200, + }, + { + "model_counts": { + "openai/gpt-4o": 3, + "anthropic/claude-3.5-sonnet": 1, + }, + "model_revenue_msats": { + "openai/gpt-4o": 1000, + "anthropic/claude-3.5-sonnet": 500, + }, + "model_tokens": { + "openai/gpt-4o": 800, + "anthropic/claude-3.5-sonnet": 300, + }, + "others": 2, + "others_revenue_msats": 450, + "others_tokens": 350, + }, + ], + } + + rows, others = analytics._aggregate_top_model_usage(model_usage_mix) + assert rows == [ + { + "model": "openai/gpt-4o", + "successful_requests": 7, + "revenue_msats": 2500.0, + "total_tokens": 2000, + }, + { + "model": "anthropic/claude-3.5-sonnet", + "successful_requests": 3, + "revenue_msats": 1200.0, + "total_tokens": 900, + }, + ] + assert others == { + "successful_requests": 3, + "revenue_msats": 750.0, + "total_tokens": 550, + } + + +def test_build_latest_payload_contains_windows_and_v2_schema(monkeypatch: Any) -> None: + seen_windows: set[tuple[int, int]] = set() + + def fake_usage_dashboard( + *, interval: int, hours: int, error_limit: int, model_limit: int + ) -> dict[str, Any]: + seen_windows.add((hours, interval)) + assert error_limit == 1 + assert model_limit == 20 + return { + "summary": { + "total_requests": 20, + "successful_chat_completions": 18, + "failed_requests": 2, + "success_rate": 90.0, + "unique_models_count": 2, + "input_tokens": 2000, + "output_tokens": 1000, + "total_tokens": 3000, + "revenue_msats": 9000.0, + "refunds_msats": 1000.0, + "net_revenue_msats": 8000.0, + "revenue_sats": 9.0, + "refunds_sats": 1.0, + "net_revenue_sats": 8.0, + }, + "revenue_by_model": { + "models": [ + { + "model": "openai/gpt-4o", + "requests": 15, + "successful": 14, + "failed": 1, + "revenue_sats": 7.2, + "refunds_sats": 0.3, + "net_revenue_sats": 6.9, + } + ] + }, + "model_usage_mix": { + "top_models": ["openai/gpt-4o"], + "metrics": [ + { + "timestamp": "2026-03-02 10:00:00", + "model_counts": {"openai/gpt-4o": 14}, + "model_revenue_msats": {"openai/gpt-4o": 7200.0}, + "model_tokens": {"openai/gpt-4o": 2600}, + "others": 4, + "others_revenue_msats": 1800.0, + "others_tokens": 400, + } + ], + }, + } + + monkeypatch.setattr( + analytics.log_manager, "get_usage_dashboard", fake_usage_dashboard + ) + monkeypatch.setattr(analytics.settings, "npub", "npub1example") + monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com") + monkeypatch.setattr(analytics.settings, "onion_url", "") + + payload = analytics.build_latest_usage_analytics_payload( + "provider123", + public_key_hex="ab" * 32, + generated_at=1772451600, + model_limit=20, + ) + assert seen_windows == {(24, 60), (7 * 24, 6 * 60), (30 * 24, 24 * 60)} + assert payload["schema"] == analytics.ANALYTICS_SCHEMA + assert payload["provider_id"] == "provider123" + assert payload["period_type"] == "latest" + assert payload["period_key"] == "latest" + assert payload["endpoint_urls"] == ["https://node.example.com"] + assert set(payload["windows"].keys()) == {"24h", "7d", "30d"} + + +def test_day_and_month_payload_keys(monkeypatch: Any) -> None: + def fake_usage_dashboard( + *, interval: int, hours: int, error_limit: int, model_limit: int + ) -> dict[str, Any]: + _ = (error_limit, model_limit) + return { + "summary": { + "total_requests": max(1, hours), + "successful_chat_completions": max(1, hours), + "failed_requests": 0, + "total_tokens": max(1, hours) * 100, + "revenue_sats": float(max(1, hours)), + }, + "revenue_by_model": {"models": []}, + "model_usage_mix": {"top_models": [], "metrics": []}, + } + + monkeypatch.setattr( + analytics.log_manager, "get_usage_dashboard", fake_usage_dashboard + ) + monkeypatch.setattr(analytics.settings, "npub", "npub1example") + monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com") + monkeypatch.setattr(analytics.settings, "onion_url", "") + + generated_at = 1772451600 # 2026-03-02 + day_payload = analytics.build_day_usage_analytics_payload( + "provider123", + public_key_hex="ab" * 32, + generated_at=generated_at, + ) + month_payload = analytics.build_month_usage_analytics_payload( + "provider123", + public_key_hex="ab" * 32, + generated_at=generated_at, + ) + + assert day_payload["period_type"] == "day" + assert day_payload["period_key"] == "2026-03-02" + assert day_payload["day"] == "2026-03-02" + assert month_payload["period_type"] == "month" + assert month_payload["period_key"] == "2026-03" + assert month_payload["month"] == "2026-03" + + +def test_create_usage_analytics_event_tags() -> None: + private_key_hex = "11" * 32 + event = analytics.create_usage_analytics_event( + private_key_hex, + "provider123", + payload_json='{"schema":"routstr.analytics.usage.v2"}', + period_type="day", + period_key="2026-03-02", + d_tag="provider123:usage:day:2026-03-02", + ) + + tags = event["tags"] + assert ["d", "provider123:usage:day:2026-03-02"] in tags + assert ["provider", "provider123"] in tags + assert ["schema", analytics.ANALYTICS_SCHEMA] in tags + assert ["period", "day"] in tags + assert ["period_key", "2026-03-02"] in tags + assert ["day", "2026-03-02"] in tags + + +def test_checkpoint_payload_contains_chain_hash() -> None: + payload = analytics.build_analytics_checkpoint_payload( + "provider123", + public_key_hex="ab" * 32, + generated_at=1772451600, + day_utc="2026-03-02", + refs={ + "latest": {"d": "provider123:usage:latest", "payload_hash": "a"}, + "day": {"d": "provider123:usage:day:2026-03-02", "payload_hash": "b"}, + "month": {"d": "provider123:usage:month:2026-03", "payload_hash": "c"}, + }, + previous_checkpoint_hash="prev-hash", + ) + + assert payload["schema"] == analytics.ANALYTICS_CHECKPOINT_SCHEMA + assert payload["day_utc"] == "2026-03-02" + assert payload["previous_checkpoint_hash"] == "prev-hash" + assert isinstance(payload["checkpoint_hash"], str) + assert len(payload["checkpoint_hash"]) == 64 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 5c5cb048..770af976 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -2,6 +2,7 @@ import os import pytest from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import text from sqlmodel.ext.asyncio.session import AsyncSession from routstr.core.settings import SettingsService @@ -11,6 +12,7 @@ from routstr.core.settings import SettingsService async def test_settings_seed_from_env_and_persist() -> None: os.environ["UPSTREAM_BASE_URL"] = "https://api.test/v1" os.environ.pop("ONION_URL", None) + os.environ.pop("ENABLE_ANALYTICS_SHARING", None) engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with AsyncSession(engine, expire_on_commit=False) as session: @@ -19,19 +21,53 @@ async def test_settings_seed_from_env_and_persist() -> None: assert settings.upstream_base_url == "https://api.test/v1" # ONION_URL may be empty if not discoverable assert isinstance(settings.onion_url, str) + assert settings.enable_analytics_sharing is True @pytest.mark.asyncio async def test_settings_db_precedence_over_env() -> None: os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1" + os.environ["ENABLE_ANALYTICS_SHARING"] = "true" engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with AsyncSession(engine, expire_on_commit=False) as session: _ = await SettingsService.initialize(session) - updated = await SettingsService.update({"name": "DBName"}, session) + updated = await SettingsService.update( + {"name": "DBName", "enable_analytics_sharing": False}, session + ) assert updated.name == "DBName" + assert updated.enable_analytics_sharing is False # Change env and re-initialize; DB should still win os.environ["NAME"] = "EnvName" + os.environ["ENABLE_ANALYTICS_SHARING"] = "true" again = await SettingsService.initialize(session) assert again.name == "DBName" + assert again.enable_analytics_sharing is False + + +@pytest.mark.asyncio +async def test_settings_initialize_discards_unknown_keys() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + _ = await SettingsService.initialize(session) + + # Simulate older persisted key name and an unknown key. + await session.exec( # type: ignore + text( + "UPDATE settings SET data = :data WHERE id = 1" + ).bindparams( + data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}' + ) + ) + await session.commit() + + reloaded = await SettingsService.initialize(session) + assert reloaded.name == "LegacyNode" + assert reloaded.enable_analytics_sharing is True + + row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore + stored_data = row.first()[0] + assert '"enable_analytics_sharing": true' in stored_data + assert "nostr_analytics_enabled" not in stored_data + assert "unknown_key" not in stored_data diff --git a/ui/components/settings/admin-settings.tsx b/ui/components/settings/admin-settings.tsx index ceab6128..30e0f79d 100644 --- a/ui/components/settings/admin-settings.tsx +++ b/ui/components/settings/admin-settings.tsx @@ -26,6 +26,7 @@ interface SettingsData { description?: string; npub?: string; nsec?: string; + enable_analytics_sharing?: boolean; upstream_api_key?: string; http_url?: string; onion_url?: string; @@ -43,6 +44,7 @@ const HANDLED_KEYS = [ 'nsec', 'cashu_mints', 'relays', + 'enable_analytics_sharing', 'admin_password', 'id', 'updated_at', @@ -366,6 +368,7 @@ export function AdminSettings() { const nostrChanged = ['npub', 'nsec'].some(hasFieldChanged); const cashuMintsChanged = hasFieldChanged('cashu_mints'); const relaysChanged = hasFieldChanged('relays'); + const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing'); const advancedKeys = Object.keys(settings).filter( (key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key) ); @@ -397,6 +400,7 @@ export function AdminSettings() { resetFields(['relays']); setNewRelay(''); }; + const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']); const resetAdvanced = () => resetFields(advancedKeys); if (loading) { @@ -686,6 +690,52 @@ export function AdminSettings() { ) : null} + {/* Analytics Sharing */} + + + Analytics Sharing + + Publish aggregate usage stats to Nostr for external dashboards + + + +
+
+ +

+ When enabled, Routstr periodically publishes aggregate model + usage and revenue stats. +

+
+ + handleInputChange('enable_analytics_sharing', checked) + } + /> +
+
+ {analyticsSharingChanged ? ( + +
+ + +
+
+ ) : null} +
+ {/* Other Settings */} diff --git a/ui/components/top-models-usage-chart.tsx b/ui/components/top-models-usage-chart.tsx index 87474803..26eb5330 100644 --- a/ui/components/top-models-usage-chart.tsx +++ b/ui/components/top-models-usage-chart.tsx @@ -218,11 +218,11 @@ export function TopModelsUsageChart({ ); const chartModels = useMemo( - () => mixTopModels.slice(0, 10), + () => mixTopModels.slice(0, 20), [mixTopModels] ); const leaderboardModels = useMemo( - () => mixTopModels.slice(0, 10), + () => mixTopModels.slice(0, 20), [mixTopModels] ); const revenueDisplayUnit: DisplayUnit = useMemo(() => { @@ -496,7 +496,7 @@ export function TopModelsUsageChart({ }) .filter((row) => row.totalRaw > 0) .sort((a, b) => b.totalRaw - a.totalRaw) - .slice(0, 10) + .slice(0, 20) .map((row, index) => ({ ...row, rank: index + 1, From a63e81db060ef2c839ca9c7c4e07c7c4c889906a Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Mon, 2 Mar 2026 22:00:31 +0800 Subject: [PATCH 07/13] fix(ui): improve admin usage chart timestamp precision --- ui/components/top-models-usage-chart.tsx | 57 ++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/ui/components/top-models-usage-chart.tsx b/ui/components/top-models-usage-chart.tsx index 26eb5330..90b3cf77 100644 --- a/ui/components/top-models-usage-chart.tsx +++ b/ui/components/top-models-usage-chart.tsx @@ -86,11 +86,25 @@ function getSeriesColor(model: string, index: number): string { return `hsl(${hue} 70% 56%)`; } -function formatTooltipTimestamp(label: string): string { +function formatTooltipTimestamp( + label: string, + intervalMinutes: number, + hoursBack: number +): string { const date = parseBucketDate(label); if (!date) { return label; } + const shouldShowTime = intervalMinutes <= 6 * 60 || hoursBack <= 48; + if (shouldShowTime) { + return date.toLocaleString([], { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } return date.toLocaleString([], { month: 'long', day: 'numeric', @@ -100,13 +114,39 @@ function formatTooltipTimestamp(label: string): string { function formatAxisTimestamp( timestamp: string, - hasMultipleDays: boolean + hasMultipleDays: boolean, + intervalMinutes: number, + hoursBack: number ): string { const date = parseBucketDate(timestamp); if (!date) { return ''; } + const shouldShowTime = intervalMinutes <= 6 * 60 || hoursBack <= 48; + if (shouldShowTime && hasMultipleDays) { + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + if (shouldShowTime) { + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } + + if (intervalMinutes >= 24 * 60 && hoursBack >= 24 * 180) { + return date.toLocaleDateString([], { + month: 'short', + year: '2-digit', + }); + } + if (hasMultipleDays) { return date.toLocaleDateString([], { month: 'short', @@ -610,7 +650,12 @@ export function TopModelsUsageChart({ axisLine={false} minTickGap={isMobile ? 14 : 24} tickFormatter={(value) => - formatAxisTimestamp(String(value), hasMultipleDays) + formatAxisTimestamp( + String(value), + hasMultipleDays, + mix.interval_minutes, + mix.hours_back + ) } />

- {formatTooltipTimestamp(String(label || ''))} + {formatTooltipTimestamp( + String(label || ''), + mix.interval_minutes, + mix.hours_back + )}

{rows.map((row) => ( From 11eb20a2d1f38a7fc5fd73caed1dd25cef8fdb98 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Tue, 3 Mar 2026 13:00:19 +0800 Subject: [PATCH 08/13] fix(ci): resolve backend type/lint and ui format issues --- routstr/core/usage_analytics_store.py | 16 ++++----- routstr/nostr/analytics.py | 18 +++++++--- ui/components/top-models-usage-chart.tsx | 44 ++++++++++++++---------- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py index c5ce3023..2e05d6ae 100644 --- a/routstr/core/usage_analytics_store.py +++ b/routstr/core/usage_analytics_store.py @@ -673,7 +673,7 @@ class UsageAnalyticsStore: ) if model_updates: - rows = [ + model_rows = [ ( minute_ts, model, @@ -713,11 +713,11 @@ class UsageAnalyticsStore: output_tokens = output_tokens + excluded.output_tokens, total_tokens = total_tokens + excluded.total_tokens """, - rows, + model_rows, ) if model_presence_updates: - rows = [ + presence_rows = [ (minute_ts, model, count) for (minute_ts, model), count in model_presence_updates.items() ] @@ -732,11 +732,11 @@ class UsageAnalyticsStore: ON CONFLICT(minute_ts, model) DO UPDATE SET count = count + excluded.count """, - rows, + presence_rows, ) if error_type_updates: - rows = [ + error_type_rows = [ (minute_ts, error_type, count) for (minute_ts, error_type), count in error_type_updates.items() ] @@ -751,7 +751,7 @@ class UsageAnalyticsStore: ON CONFLICT(minute_ts, error_type) DO UPDATE SET count = count + excluded.count """, - rows, + error_type_rows, ) if error_events: @@ -1215,9 +1215,9 @@ class UsageAnalyticsStore: for row in top_model_rows: bucket_ts = str(row["bucket_ts"]) - bucket = bucket_index.get(bucket_ts) - if bucket is None: + if bucket_ts not in bucket_index: continue + bucket = bucket_index[bucket_ts] model = str(row["model"]) successful = int(row["successful"]) diff --git a/routstr/nostr/analytics.py b/routstr/nostr/analytics.py index 137040cb..1c33f8ff 100644 --- a/routstr/nostr/analytics.py +++ b/routstr/nostr/analytics.py @@ -12,7 +12,7 @@ import json import math import time from datetime import datetime, timezone -from typing import Any +from typing import Any, TypedDict from nostr.event import Event from nostr.key import PrivateKey @@ -46,6 +46,13 @@ WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = ( ) +class PayloadSpec(TypedDict): + period_type: str + period_key: str + d_tag: str + payload: dict[str, Any] + + def _event_to_dict(ev: Event) -> dict[str, Any]: return { "id": ev.id, @@ -197,7 +204,10 @@ def _aggregate_top_model_usage( } for model, values in model_totals.items() ] - model_rows.sort(key=lambda row: row["successful_requests"], reverse=True) + model_rows.sort( + key=lambda row: _to_int(row.get("successful_requests", 0)), + reverse=True, + ) return model_rows, others @@ -600,7 +610,7 @@ async def publish_usage_analytics() -> None: generated_at=now_ts, ) - payload_specs = [ + payload_specs: list[PayloadSpec] = [ { "period_type": "latest", "period_key": "latest", @@ -624,7 +634,7 @@ async def publish_usage_analytics() -> None: to_publish: list[dict[str, Any]] = [] refs: dict[str, dict[str, str]] = {} for spec in payload_specs: - payload = spec["payload"] + payload: dict[str, Any] = spec["payload"] payload_hash = _fingerprint_payload(payload) d_tag = str(spec["d_tag"]) period_type = str(spec["period_type"]) diff --git a/ui/components/top-models-usage-chart.tsx b/ui/components/top-models-usage-chart.tsx index 90b3cf77..f6a64110 100644 --- a/ui/components/top-models-usage-chart.tsx +++ b/ui/components/top-models-usage-chart.tsx @@ -198,7 +198,11 @@ function detectProviderFromModel(model: string): string { 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')) { + if ( + value.includes('grok') || + value.includes('x-ai') || + value.includes('xai') + ) { return 'x-ai'; } if (value.includes('deepseek')) return 'deepseek'; @@ -206,15 +210,20 @@ function detectProviderFromModel(model: string): string { 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')) { + 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 } { +function getModelPresentation(model: string): { + displayName: string; + provider: string; +} { const trimmed = model.trim(); const slashIndex = trimmed.indexOf('/'); if (slashIndex > 0 && slashIndex < trimmed.length - 1) { @@ -257,10 +266,7 @@ export function TopModelsUsageChart({ [mix.metrics] ); - const chartModels = useMemo( - () => mixTopModels.slice(0, 20), - [mixTopModels] - ); + const chartModels = useMemo(() => mixTopModels.slice(0, 20), [mixTopModels]); const leaderboardModels = useMemo( () => mixTopModels.slice(0, 20), [mixTopModels] @@ -472,9 +478,7 @@ export function TopModelsUsageChart({ const windowSize = Math.floor(mixMetrics.length / 2); const previousMetrics = - windowSize > 0 - ? mixMetrics.slice(-windowSize * 2, -windowSize) - : []; + windowSize > 0 ? mixMetrics.slice(-windowSize * 2, -windowSize) : []; const currentMetrics = windowSize > 0 ? mixMetrics.slice(-windowSize) : mixMetrics; @@ -482,10 +486,10 @@ export function TopModelsUsageChart({ .map((model) => { const readMetric = (metric: (typeof mixMetrics)[number]): number => mode === 'requests' - ? (metric.model_counts ?? {})[model] ?? 0 + ? ((metric.model_counts ?? {})[model] ?? 0) : mode === 'revenue' - ? (metric.model_revenue_msats ?? {})[model] ?? 0 - : (metric.model_tokens ?? {})[model] ?? 0; + ? ((metric.model_revenue_msats ?? {})[model] ?? 0) + : ((metric.model_tokens ?? {})[model] ?? 0); const totalRaw = mixMetrics.reduce( (sum, metric) => sum + readMetric(metric), @@ -557,7 +561,9 @@ export function TopModelsUsageChart({
- Model Usage + + Model Usage +

Stacked requests, revenue, or tokens by model ( {mix.interval_minutes}m buckets). @@ -689,7 +695,9 @@ export function TopModelsUsageChart({ value, } satisfies TooltipRow; }) - .filter((row) => Number.isFinite(row.value) && row.value > 0) + .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); @@ -814,7 +822,7 @@ export function TopModelsUsageChart({ 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', + 'hover:bg-muted/25 cursor-pointer transition', rowIsActive && 'bg-muted/30', rowIsDimmed && 'opacity-45' )} From 9cd4ff5c2148a2c4d930c8d9cd497672831c21ad Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Thu, 5 Mar 2026 15:50:54 +0800 Subject: [PATCH 09/13] Simplify analytics sharing to single snapshot with multi-window payloads --- routstr/core/main.py | 2 +- routstr/nostr/analytics.py | 508 +++++------------------------ tests/unit/test_nostr_analytics.py | 231 +++++++------ 3 files changed, 208 insertions(+), 533 deletions(-) diff --git a/routstr/core/main.py b/routstr/core/main.py index d0769706..c906204e 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -108,7 +108,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: payout_task = asyncio.create_task(periodic_payout()) if global_settings.nsec: nip91_task = asyncio.create_task(announce_provider()) - analytics_task = asyncio.create_task(publish_usage_analytics()) + analytics_task = asyncio.create_task(publish_usage_analytics()) if global_settings.providers_refresh_interval_seconds > 0: providers_task = asyncio.create_task(providers_cache_refresher()) key_reset_task = asyncio.create_task(periodic_key_reset()) diff --git a/routstr/nostr/analytics.py b/routstr/nostr/analytics.py index 1c33f8ff..eb148664 100644 --- a/routstr/nostr/analytics.py +++ b/routstr/nostr/analytics.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Nostr usage analytics publisher. -Publishes routstr analytics snapshots for latest/day/month plus daily checkpoints. +Publishes a single replaceable analytics snapshot for each provider. """ from __future__ import annotations @@ -9,10 +9,8 @@ from __future__ import annotations import asyncio import hashlib import json -import math import time -from datetime import datetime, timezone -from typing import Any, TypedDict +from typing import Any from nostr.event import Event from nostr.key import PrivateKey @@ -25,8 +23,7 @@ from .listing import nsec_to_keypair, publish_to_relay logger = get_logger(__name__) ANALYTICS_KIND = 38422 -ANALYTICS_SCHEMA = "routstr.analytics.usage.v2" -ANALYTICS_CHECKPOINT_SCHEMA = "routstr.analytics.checkpoint.v1" +ANALYTICS_SCHEMA = "routstr.analytics.snapshot.v1" DEFAULT_RELAYS = [ "wss://relay.nostr.band", "wss://relay.damus.io", @@ -37,22 +34,16 @@ PUBLISH_INTERVAL_SECONDS = 15 * 60 DISABLED_POLL_SECONDS = 60 DASHBOARD_WINDOW_HOURS = 24 DASHBOARD_INTERVAL_MINUTES = 60 -MODEL_LIMIT = 20 - +MODEL_LIMIT = 10 WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = ( ("24h", 24, 60), ("7d", 7 * 24, 6 * 60), ("30d", 30 * 24, 24 * 60), + ("3m", 90 * 24, 24 * 60), + ("1y", 365 * 24, 7 * 24 * 60), ) -class PayloadSpec(TypedDict): - period_type: str - period_key: str - d_tag: str - payload: dict[str, Any] - - def _event_to_dict(ev: Event) -> dict[str, Any]: return { "id": ev.id, @@ -123,31 +114,6 @@ def _to_float(value: Any) -> float: return 0.0 -def _utc_day_key(unix_ts: int) -> str: - return datetime.fromtimestamp(unix_ts, tz=timezone.utc).strftime("%Y-%m-%d") - - -def _utc_month_key(unix_ts: int) -> str: - return datetime.fromtimestamp(unix_ts, tz=timezone.utc).strftime("%Y-%m") - - -def _utc_day_start_ts(unix_ts: int) -> int: - dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc) - start = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc) - return int(start.timestamp()) - - -def _utc_month_start_ts(unix_ts: int) -> int: - dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc) - start = datetime(dt.year, dt.month, 1, tzinfo=timezone.utc) - return int(start.timestamp()) - - -def _hours_since(start_ts: int, end_ts: int) -> int: - elapsed = max(1, end_ts - start_ts) - return max(1, int(math.ceil(elapsed / 3600))) - - def _aggregate_top_model_usage( model_usage_mix: dict[str, Any], ) -> tuple[list[dict[str, Any]], dict[str, Any]]: @@ -233,80 +199,76 @@ def _build_summary_payload(summary: dict[str, Any]) -> dict[str, Any]: } -def _build_model_revenue_rows(revenue_by_model: dict[str, Any]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - models_raw = revenue_by_model.get("models", []) - if not isinstance(models_raw, list): - return rows - - for row in models_raw: - if not isinstance(row, dict): - continue - model_name = str(row.get("model", "unknown")) - rows.append( - { - "model": model_name, - "requests": _to_int(row.get("requests", 0)), - "successful": _to_int(row.get("successful", 0)), - "failed": _to_int(row.get("failed", 0)), - "revenue_sats": _to_float(row.get("revenue_sats", 0.0)), - "refunds_sats": _to_float(row.get("refunds_sats", 0.0)), - "net_revenue_sats": _to_float(row.get("net_revenue_sats", 0.0)), - } - ) - return rows - - def _build_window_payload( *, hours: int, - interval: int, + interval_minutes: int, model_limit: int, ) -> dict[str, Any]: dashboard = log_manager.get_usage_dashboard( - interval=interval, + interval=interval_minutes, hours=hours, error_limit=1, model_limit=model_limit, ) + summary = dashboard.get("summary", {}) - revenue_by_model = dashboard.get("revenue_by_model", {}) model_usage_mix = dashboard.get("model_usage_mix", {}) summary_payload = _build_summary_payload(summary if isinstance(summary, dict) else {}) - model_revenue_rows = _build_model_revenue_rows( - revenue_by_model if isinstance(revenue_by_model, dict) else {} - ) usage_mix_payload = model_usage_mix if isinstance(model_usage_mix, dict) else {} top_model_usage, others_usage = _aggregate_top_model_usage(usage_mix_payload) return { "window_hours": hours, - "interval_minutes": interval, + "interval_minutes": interval_minutes, "summary": summary_payload, - "model_revenue": model_revenue_rows, + "model_usage_mix": usage_mix_payload, "top_model_usage": top_model_usage, "others_usage": others_usage, - "model_usage_mix": usage_mix_payload, } -def build_latest_usage_analytics_payload( +def build_stats_snapshot_payload( provider_id: str, *, public_key_hex: str, generated_at: int, + window_hours: int = DASHBOARD_WINDOW_HOURS, + interval_minutes: int = DASHBOARD_INTERVAL_MINUTES, model_limit: int = MODEL_LIMIT, ) -> dict[str, Any]: + _ = (window_hours, interval_minutes) windows: dict[str, dict[str, Any]] = {} - for key, window_hours, window_interval in WINDOW_DEFINITIONS: + for key, hours, window_interval_minutes in WINDOW_DEFINITIONS: windows[key] = _build_window_payload( - hours=window_hours, - interval=window_interval, + hours=hours, + interval_minutes=window_interval_minutes, model_limit=model_limit, ) primary_window = windows.get("24h", {}) + summary_payload = ( + primary_window.get("summary", {}) + if isinstance(primary_window.get("summary", {}), dict) + else {} + ) + usage_mix_payload = ( + primary_window.get("model_usage_mix", {}) + if isinstance(primary_window.get("model_usage_mix", {}), dict) + else {} + ) + top_model_usage = ( + primary_window.get("top_model_usage", []) + if isinstance(primary_window.get("top_model_usage", []), list) + else [] + ) + others_usage = ( + primary_window.get("others_usage", {}) + if isinstance(primary_window.get("others_usage", {}), dict) + else {} + ) + return { "schema": ANALYTICS_SCHEMA, "generated_at": generated_at, @@ -314,128 +276,21 @@ def build_latest_usage_analytics_payload( "pubkey": public_key_hex, "npub": settings.npub or "", "endpoint_urls": _resolve_endpoint_urls(), - "period_type": "latest", - "period_key": "latest", - "period_start_unix": generated_at - (24 * 3600), - "period_end_unix": generated_at, - "summary": primary_window.get("summary", {}), - "model_revenue": primary_window.get("model_revenue", []), - "top_model_usage": primary_window.get("top_model_usage", []), - "others_usage": primary_window.get("others_usage", {}), - "model_usage_mix": primary_window.get("model_usage_mix", {}), + "window_hours": DASHBOARD_WINDOW_HOURS, + "interval_minutes": DASHBOARD_INTERVAL_MINUTES, + "summary": summary_payload, + "model_usage_mix": usage_mix_payload, + "top_model_usage": top_model_usage, + "others_usage": others_usage, "windows": windows, } -def _build_period_payload( - provider_id: str, - *, - public_key_hex: str, - generated_at: int, - period_type: str, - period_key: str, - period_start_unix: int, - interval_minutes: int, - model_limit: int = MODEL_LIMIT, -) -> dict[str, Any]: - hours = _hours_since(period_start_unix, generated_at) - window = _build_window_payload( - hours=hours, - interval=interval_minutes, - model_limit=model_limit, - ) - return { - "schema": ANALYTICS_SCHEMA, - "generated_at": generated_at, - "provider_id": provider_id, - "pubkey": public_key_hex, - "npub": settings.npub or "", - "endpoint_urls": _resolve_endpoint_urls(), - "period_type": period_type, - "period_key": period_key, - "period_start_unix": period_start_unix, - "period_end_unix": generated_at, - "window_hours": window.get("window_hours", hours), - "interval_minutes": window.get("interval_minutes", interval_minutes), - "summary": window.get("summary", {}), - "model_revenue": window.get("model_revenue", []), - "top_model_usage": window.get("top_model_usage", []), - "others_usage": window.get("others_usage", {}), - "model_usage_mix": window.get("model_usage_mix", {}), - } - - -def build_day_usage_analytics_payload( - provider_id: str, - *, - public_key_hex: str, - generated_at: int, - model_limit: int = MODEL_LIMIT, -) -> dict[str, Any]: - day_key = _utc_day_key(generated_at) - day_start = _utc_day_start_ts(generated_at) - payload = _build_period_payload( - provider_id, - public_key_hex=public_key_hex, - generated_at=generated_at, - period_type="day", - period_key=day_key, - period_start_unix=day_start, - interval_minutes=60, - model_limit=model_limit, - ) - payload["day"] = day_key - return payload - - -def build_month_usage_analytics_payload( - provider_id: str, - *, - public_key_hex: str, - generated_at: int, - model_limit: int = MODEL_LIMIT, -) -> dict[str, Any]: - month_key = _utc_month_key(generated_at) - month_start = _utc_month_start_ts(generated_at) - payload = _build_period_payload( - provider_id, - public_key_hex=public_key_hex, - generated_at=generated_at, - period_type="month", - period_key=month_key, - period_start_unix=month_start, - interval_minutes=24 * 60, - model_limit=model_limit, - ) - payload["month"] = month_key - return payload - - -def build_usage_analytics_payload( - provider_id: str, - *, - public_key_hex: str, - hours: int = DASHBOARD_WINDOW_HOURS, - interval: int = DASHBOARD_INTERVAL_MINUTES, - model_limit: int = MODEL_LIMIT, -) -> dict[str, Any]: - # Backward-compatible helper kept for existing tests/callers. - _ = (hours, interval) - return build_latest_usage_analytics_payload( - provider_id, - public_key_hex=public_key_hex, - generated_at=int(time.time()), - model_limit=model_limit, - ) - - -def create_usage_analytics_event( +def create_stats_snapshot_event( private_key_hex: str, provider_id: str, payload_json: str, *, - period_type: str, - period_key: str, d_tag: str, ) -> dict[str, Any]: private_key = PrivateKey(bytes.fromhex(private_key_hex)) @@ -443,13 +298,7 @@ def create_usage_analytics_event( ["d", d_tag], ["provider", provider_id], ["schema", ANALYTICS_SCHEMA], - ["period", period_type], - ["period_key", period_key], ] - if period_type == "day": - tags.append(["day", period_key]) - elif period_type == "month": - tags.append(["month", period_key]) event = Event( public_key=private_key.public_key.hex(), @@ -463,83 +312,14 @@ def create_usage_analytics_event( def _fingerprint_payload(payload: dict[str, Any]) -> str: normalized = dict(payload) - # Ignore volatile timestamps for deduping semantically identical snapshots. + # Ignore generated timestamp for semantic dedupe. normalized.pop("generated_at", None) - normalized.pop("period_end_unix", None) payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True) return hashlib.sha256(payload_json.encode("utf-8")).hexdigest() -def _stable_hash(data: dict[str, Any]) -> str: - encoded = json.dumps(data, separators=(",", ":"), sort_keys=True) - return hashlib.sha256(encoded.encode("utf-8")).hexdigest() - - -def build_analytics_checkpoint_payload( - provider_id: str, - *, - public_key_hex: str, - generated_at: int, - day_utc: str, - refs: dict[str, dict[str, str]], - previous_checkpoint_hash: str | None, -) -> dict[str, Any]: - base = { - "schema": ANALYTICS_CHECKPOINT_SCHEMA, - "generated_at": generated_at, - "provider_id": provider_id, - "pubkey": public_key_hex, - "npub": settings.npub or "", - "day_utc": day_utc, - "refs": refs, - "previous_checkpoint_hash": previous_checkpoint_hash or "", - } - checkpoint_hash = _stable_hash( - { - "provider_id": provider_id, - "day_utc": day_utc, - "refs": refs, - "previous_checkpoint_hash": previous_checkpoint_hash or "", - } - ) - base["checkpoint_hash"] = checkpoint_hash - return base - - -def create_analytics_checkpoint_event( - private_key_hex: str, - provider_id: str, - payload_json: str, - *, - day_utc: str, - previous_checkpoint_hash: str | None, -) -> dict[str, Any]: - private_key = PrivateKey(bytes.fromhex(private_key_hex)) - tags = [ - ["d", f"{provider_id}:usage:checkpoint:{day_utc}"], - ["provider", provider_id], - ["schema", ANALYTICS_CHECKPOINT_SCHEMA], - ["day", day_utc], - ] - if previous_checkpoint_hash: - tags.append(["prev", previous_checkpoint_hash]) - - event = Event( - public_key=private_key.public_key.hex(), - content=payload_json, - kind=ANALYTICS_KIND, - tags=tags, - ) - private_key.sign_event(event) - return _event_to_dict(event) - - async def publish_usage_analytics() -> None: - last_period_state: dict[str, tuple[str, str]] = {} - last_checkpoint_state: tuple[str, str] | None = None - checkpoint_day: str | None = None - checkpoint_hash_for_day: str | None = None - previous_checkpoint_hash: str | None = None + last_payload_hash: str | None = None parsed_nsec: str | None = None private_key_hex: str | None = None @@ -573,11 +353,7 @@ async def publish_usage_analytics() -> None: private_key_hex, public_key_hex = keypair parsed_nsec = nsec provider_id = _resolve_provider_id(public_key_hex) - last_period_state = {} - last_checkpoint_state = None - checkpoint_day = None - checkpoint_hash_for_day = None - previous_checkpoint_hash = None + last_payload_hash = None if private_key_hex is None or public_key_hex is None: await asyncio.sleep(DISABLED_POLL_SECONDS) @@ -591,181 +367,43 @@ async def publish_usage_analytics() -> None: resolved_provider_id = provider_id or _resolve_provider_id(public_key_hex) now_ts = int(time.time()) - day_key = _utc_day_key(now_ts) - month_key = _utc_month_key(now_ts) - - latest_payload = build_latest_usage_analytics_payload( - resolved_provider_id, - public_key_hex=public_key_hex, - generated_at=now_ts, - ) - day_payload = build_day_usage_analytics_payload( - resolved_provider_id, - public_key_hex=public_key_hex, - generated_at=now_ts, - ) - month_payload = build_month_usage_analytics_payload( + payload = build_stats_snapshot_payload( resolved_provider_id, public_key_hex=public_key_hex, generated_at=now_ts, ) - payload_specs: list[PayloadSpec] = [ - { - "period_type": "latest", - "period_key": "latest", - "d_tag": f"{resolved_provider_id}:usage:latest", - "payload": latest_payload, - }, - { - "period_type": "day", - "period_key": day_key, - "d_tag": f"{resolved_provider_id}:usage:day:{day_key}", - "payload": day_payload, - }, - { - "period_type": "month", - "period_key": month_key, - "d_tag": f"{resolved_provider_id}:usage:month:{month_key}", - "payload": month_payload, - }, - ] + payload_hash = _fingerprint_payload(payload) + if last_payload_hash == payload_hash: + await asyncio.sleep(PUBLISH_INTERVAL_SECONDS) + continue - to_publish: list[dict[str, Any]] = [] - refs: dict[str, dict[str, str]] = {} - for spec in payload_specs: - payload: dict[str, Any] = spec["payload"] - payload_hash = _fingerprint_payload(payload) - d_tag = str(spec["d_tag"]) - period_type = str(spec["period_type"]) - - refs[period_type] = {"d": d_tag, "payload_hash": payload_hash} - last_state = last_period_state.get(period_type) - if last_state is not None and last_state[0] == d_tag and last_state[1] == payload_hash: - continue - - payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True) - event = create_usage_analytics_event( - private_key_hex, - resolved_provider_id, - payload_json, - period_type=period_type, - period_key=str(spec["period_key"]), - d_tag=d_tag, - ) - to_publish.append( - { - "period_type": period_type, - "d_tag": d_tag, - "payload_hash": payload_hash, - "event": event, - } - ) - - period_attempted = {str(item["period_type"]) for item in to_publish} - period_successes = {period_type: 0 for period_type in period_attempted} - if to_publish: - for relay_url in relay_urls: - for item in to_publish: - if await publish_to_relay(relay_url, item["event"]): - period_successes[item["period_type"]] += 1 - - for item in to_publish: - period_type = item["period_type"] - if period_successes.get(period_type, 0) > 0: - last_period_state[period_type] = ( - item["d_tag"], - item["payload_hash"], - ) - - if checkpoint_day is None: - checkpoint_day = day_key - elif checkpoint_day != day_key: - if checkpoint_hash_for_day: - previous_checkpoint_hash = checkpoint_hash_for_day - checkpoint_day = day_key - checkpoint_hash_for_day = None - last_checkpoint_state = None - - checkpoint_payload = build_analytics_checkpoint_payload( + payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True) + d_tag = f"{resolved_provider_id}:stats" + event = create_stats_snapshot_event( + private_key_hex, resolved_provider_id, - public_key_hex=public_key_hex, - generated_at=now_ts, - day_utc=day_key, - refs=refs, - previous_checkpoint_hash=previous_checkpoint_hash, + payload_json, + d_tag=d_tag, ) - checkpoint_d = f"{resolved_provider_id}:usage:checkpoint:{day_key}" - checkpoint_hash = _fingerprint_payload(checkpoint_payload) - checkpoint_attempted = False - checkpoint_success_count = 0 - if ( - last_checkpoint_state is None - or last_checkpoint_state[0] != checkpoint_d - or last_checkpoint_state[1] != checkpoint_hash - ): - checkpoint_attempted = True - checkpoint_payload_json = json.dumps( - checkpoint_payload, - separators=(",", ":"), - sort_keys=True, - ) - checkpoint_event = create_analytics_checkpoint_event( - private_key_hex, - resolved_provider_id, - checkpoint_payload_json, - day_utc=day_key, - previous_checkpoint_hash=previous_checkpoint_hash, - ) - for relay_url in relay_urls: - if await publish_to_relay(relay_url, checkpoint_event): - checkpoint_success_count += 1 + success_count = 0 + for relay_url in relay_urls: + if await publish_to_relay(relay_url, event): + success_count += 1 - if checkpoint_success_count > 0: - last_checkpoint_state = (checkpoint_d, checkpoint_hash) - checkpoint_hash_for_day = str( - checkpoint_payload.get("checkpoint_hash", "") - ) or None + if success_count > 0: + last_payload_hash = payload_hash - relay_total = len(relay_urls) - latest_result = ( - f"{period_successes.get('latest', 0)}/{relay_total}" - if "latest" in period_attempted - else "skip" - ) - day_result = ( - f"{period_successes.get('day', 0)}/{relay_total}" - if "day" in period_attempted - else "skip" - ) - month_result = ( - f"{period_successes.get('month', 0)}/{relay_total}" - if "month" in period_attempted - else "skip" - ) - checkpoint_result = ( - f"{checkpoint_success_count}/{relay_total}" - if checkpoint_attempted - else "skip" - ) logger.info( - "Published analytics snapshots " - "(latest=%s day=%s month=%s checkpoint=%s day_utc=%s month_utc=%s)", - latest_result, - day_result, - month_result, - checkpoint_result, - day_key, - month_key, + "Published analytics snapshot (success=%s/%s provider=%s)", + success_count, + len(relay_urls), + resolved_provider_id, extra={ - "latest_relays": period_successes.get("latest", 0), - "day_relays": period_successes.get("day", 0), - "month_relays": period_successes.get("month", 0), - "checkpoint_relays": checkpoint_success_count, - "relay_total": relay_total, - "day": day_key, - "month": month_key, + "relay_success_count": success_count, + "relay_total": len(relay_urls), + "provider_id": resolved_provider_id, }, ) await asyncio.sleep(PUBLISH_INTERVAL_SECONDS) diff --git a/tests/unit/test_nostr_analytics.py b/tests/unit/test_nostr_analytics.py index adc0248d..577d5ca0 100644 --- a/tests/unit/test_nostr_analytics.py +++ b/tests/unit/test_nostr_analytics.py @@ -1,7 +1,10 @@ from __future__ import annotations +import asyncio from typing import Any +import pytest + from routstr.nostr import analytics @@ -68,7 +71,7 @@ def test_aggregate_top_model_usage_sums_metrics() -> None: } -def test_build_latest_payload_contains_windows_and_v2_schema(monkeypatch: Any) -> None: +def test_build_stats_snapshot_payload_schema_and_shape(monkeypatch: Any) -> None: seen_windows: set[tuple[int, int]] = set() def fake_usage_dashboard( @@ -76,11 +79,11 @@ def test_build_latest_payload_contains_windows_and_v2_schema(monkeypatch: Any) - ) -> dict[str, Any]: seen_windows.add((hours, interval)) assert error_limit == 1 - assert model_limit == 20 + assert model_limit == 10 return { "summary": { - "total_requests": 20, - "successful_chat_completions": 18, + "total_requests": hours, + "successful_chat_completions": max(1, hours - 1), "failed_requests": 2, "success_rate": 90.0, "unique_models_count": 2, @@ -94,27 +97,14 @@ def test_build_latest_payload_contains_windows_and_v2_schema(monkeypatch: Any) - "refunds_sats": 1.0, "net_revenue_sats": 8.0, }, - "revenue_by_model": { - "models": [ - { - "model": "openai/gpt-4o", - "requests": 15, - "successful": 14, - "failed": 1, - "revenue_sats": 7.2, - "refunds_sats": 0.3, - "net_revenue_sats": 6.9, - } - ] - }, "model_usage_mix": { "top_models": ["openai/gpt-4o"], "metrics": [ { "timestamp": "2026-03-02 10:00:00", - "model_counts": {"openai/gpt-4o": 14}, - "model_revenue_msats": {"openai/gpt-4o": 7200.0}, - "model_tokens": {"openai/gpt-4o": 2600}, + "model_counts": {"openai/gpt-4o": hours}, + "model_revenue_msats": {"openai/gpt-4o": float(hours * 100)}, + "model_tokens": {"openai/gpt-4o": hours * 10}, "others": 4, "others_revenue_msats": 1800.0, "others_tokens": 400, @@ -130,101 +120,148 @@ def test_build_latest_payload_contains_windows_and_v2_schema(monkeypatch: Any) - monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com") monkeypatch.setattr(analytics.settings, "onion_url", "") - payload = analytics.build_latest_usage_analytics_payload( + payload = analytics.build_stats_snapshot_payload( "provider123", public_key_hex="ab" * 32, generated_at=1772451600, - model_limit=20, ) - assert seen_windows == {(24, 60), (7 * 24, 6 * 60), (30 * 24, 24 * 60)} + assert payload["schema"] == analytics.ANALYTICS_SCHEMA assert payload["provider_id"] == "provider123" - assert payload["period_type"] == "latest" - assert payload["period_key"] == "latest" + assert payload["window_hours"] == 24 + assert payload["interval_minutes"] == 60 assert payload["endpoint_urls"] == ["https://node.example.com"] - assert set(payload["windows"].keys()) == {"24h", "7d", "30d"} - - -def test_day_and_month_payload_keys(monkeypatch: Any) -> None: - def fake_usage_dashboard( - *, interval: int, hours: int, error_limit: int, model_limit: int - ) -> dict[str, Any]: - _ = (error_limit, model_limit) - return { - "summary": { - "total_requests": max(1, hours), - "successful_chat_completions": max(1, hours), - "failed_requests": 0, - "total_tokens": max(1, hours) * 100, - "revenue_sats": float(max(1, hours)), - }, - "revenue_by_model": {"models": []}, - "model_usage_mix": {"top_models": [], "metrics": []}, + assert seen_windows == { + (24, 60), + (7 * 24, 6 * 60), + (30 * 24, 24 * 60), + (90 * 24, 24 * 60), + (365 * 24, 7 * 24 * 60), + } + assert set(payload["windows"].keys()) == {"24h", "7d", "30d", "3m", "1y"} + assert payload["windows"]["1y"]["interval_minutes"] == 7 * 24 * 60 + assert payload["summary"]["total_requests"] == 24 + assert payload["top_model_usage"] == [ + { + "model": "openai/gpt-4o", + "successful_requests": 24, + "revenue_msats": 2400.0, + "total_tokens": 240, } - - monkeypatch.setattr( - analytics.log_manager, "get_usage_dashboard", fake_usage_dashboard - ) - monkeypatch.setattr(analytics.settings, "npub", "npub1example") - monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com") - monkeypatch.setattr(analytics.settings, "onion_url", "") - - generated_at = 1772451600 # 2026-03-02 - day_payload = analytics.build_day_usage_analytics_payload( - "provider123", - public_key_hex="ab" * 32, - generated_at=generated_at, - ) - month_payload = analytics.build_month_usage_analytics_payload( - "provider123", - public_key_hex="ab" * 32, - generated_at=generated_at, - ) - - assert day_payload["period_type"] == "day" - assert day_payload["period_key"] == "2026-03-02" - assert day_payload["day"] == "2026-03-02" - assert month_payload["period_type"] == "month" - assert month_payload["period_key"] == "2026-03" - assert month_payload["month"] == "2026-03" + ] + assert payload["others_usage"] == { + "successful_requests": 4, + "revenue_msats": 1800.0, + "total_tokens": 400, + } -def test_create_usage_analytics_event_tags() -> None: +def test_create_stats_snapshot_event_tags() -> None: private_key_hex = "11" * 32 - event = analytics.create_usage_analytics_event( + event = analytics.create_stats_snapshot_event( private_key_hex, "provider123", - payload_json='{"schema":"routstr.analytics.usage.v2"}', - period_type="day", - period_key="2026-03-02", - d_tag="provider123:usage:day:2026-03-02", + payload_json='{"schema":"routstr.analytics.snapshot.v1"}', + d_tag="provider123:stats", ) tags = event["tags"] - assert ["d", "provider123:usage:day:2026-03-02"] in tags + assert ["d", "provider123:stats"] in tags assert ["provider", "provider123"] in tags assert ["schema", analytics.ANALYTICS_SCHEMA] in tags - assert ["period", "day"] in tags - assert ["period_key", "2026-03-02"] in tags - assert ["day", "2026-03-02"] in tags + assert all(tag[0] != "period" for tag in tags) -def test_checkpoint_payload_contains_chain_hash() -> None: - payload = analytics.build_analytics_checkpoint_payload( - "provider123", - public_key_hex="ab" * 32, - generated_at=1772451600, - day_utc="2026-03-02", - refs={ - "latest": {"d": "provider123:usage:latest", "payload_hash": "a"}, - "day": {"d": "provider123:usage:day:2026-03-02", "payload_hash": "b"}, - "month": {"d": "provider123:usage:month:2026-03", "payload_hash": "c"}, - }, - previous_checkpoint_hash="prev-hash", - ) +def test_fingerprint_payload_ignores_generated_at() -> None: + a = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 1000, "summary": {"x": 1}} + b = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 2000, "summary": {"x": 1}} - assert payload["schema"] == analytics.ANALYTICS_CHECKPOINT_SCHEMA - assert payload["day_utc"] == "2026-03-02" - assert payload["previous_checkpoint_hash"] == "prev-hash" - assert isinstance(payload["checkpoint_hash"], str) - assert len(payload["checkpoint_hash"]) == 64 + assert analytics._fingerprint_payload(a) == analytics._fingerprint_payload(b) + + +@pytest.mark.asyncio +async def test_publish_usage_analytics_skips_when_disabled(monkeypatch: Any) -> None: + delays: list[int] = [] + + async def fake_sleep(seconds: int) -> None: + delays.append(seconds) + raise asyncio.CancelledError() + + def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]: + raise AssertionError("build_stats_snapshot_payload should not be called") + + monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", False) + monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build) + monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep) + + await analytics.publish_usage_analytics() + + assert delays == [analytics.DISABLED_POLL_SECONDS] + + +@pytest.mark.asyncio +async def test_publish_usage_analytics_skips_without_nsec(monkeypatch: Any) -> None: + delays: list[int] = [] + + async def fake_sleep(seconds: int) -> None: + delays.append(seconds) + raise asyncio.CancelledError() + + def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]: + raise AssertionError("build_stats_snapshot_payload should not be called") + + monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True) + monkeypatch.setattr(analytics.settings, "nsec", "") + monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build) + monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep) + + await analytics.publish_usage_analytics() + + assert delays == [analytics.DISABLED_POLL_SECONDS] + + +@pytest.mark.asyncio +async def test_publish_usage_analytics_dedupes_unchanged_payload(monkeypatch: Any) -> None: + published_events: list[dict[str, Any]] = [] + sleep_calls = 0 + + async def fake_sleep(seconds: int) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError() + + def fake_build_payload( + provider_id: str, + *, + public_key_hex: str, + generated_at: int, + window_hours: int = 24, + interval_minutes: int = 60, + model_limit: int = 10, + ) -> dict[str, Any]: + _ = (public_key_hex, generated_at, window_hours, interval_minutes, model_limit) + return { + "schema": analytics.ANALYTICS_SCHEMA, + "generated_at": generated_at, + "provider_id": provider_id, + "summary": {"total_requests": 1}, + } + + async def fake_publish(relay_url: str, event: dict[str, Any]) -> bool: + _ = relay_url + published_events.append(event) + return True + + monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True) + monkeypatch.setattr(analytics.settings, "nsec", "11" * 32) + monkeypatch.setattr(analytics.settings, "relays", ["wss://relay.example.com"]) + monkeypatch.setattr(analytics.settings, "provider_id", "") + monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fake_build_payload) + monkeypatch.setattr(analytics, "publish_to_relay", fake_publish) + monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep) + + await analytics.publish_usage_analytics() + + assert len(published_events) == 1 + assert ["schema", analytics.ANALYTICS_SCHEMA] in published_events[0].get("tags", []) From d9d082ad5c5e8405272cb1d396db3ac177e3f265 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Thu, 5 Mar 2026 16:02:51 +0800 Subject: [PATCH 10/13] Use per-metric top-model selection for usage mix --- routstr/core/usage_analytics_store.py | 62 ++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/routstr/core/usage_analytics_store.py b/routstr/core/usage_analytics_store.py index 2e05d6ae..7ba90e24 100644 --- a/routstr/core/usage_analytics_store.py +++ b/routstr/core/usage_analytics_store.py @@ -1116,7 +1116,7 @@ class UsageAnalyticsStore: limit: int, ) -> dict[str, Any]: top_limit = max(1, min(int(limit), 20)) - top_rows = conn.execute( + top_rows_requests = conn.execute( """ SELECT model, @@ -1130,12 +1130,55 @@ class UsageAnalyticsStore: """, (cutoff_timestamp, top_limit), ).fetchall() + top_rows_revenue = conn.execute( + """ + SELECT + model, + COALESCE(SUM(revenue_msats), 0) AS total_revenue_msats + FROM analytics_model_minute + WHERE minute_ts >= ? + AND model != 'unknown' + GROUP BY model + ORDER BY total_revenue_msats DESC + LIMIT ? + """, + (cutoff_timestamp, top_limit), + ).fetchall() + top_rows_tokens = conn.execute( + """ + SELECT + model, + COALESCE(SUM(total_tokens), 0) AS total_tokens + FROM analytics_model_minute + WHERE minute_ts >= ? + AND model != 'unknown' + GROUP BY model + ORDER BY total_tokens DESC + LIMIT ? + """, + (cutoff_timestamp, top_limit), + ).fetchall() - top_models = [ + top_models_requests = [ str(row["model"]) - for row in top_rows + for row in top_rows_requests if int(row["total_successful"] or 0) > 0 ] + top_models_revenue = [ + str(row["model"]) + for row in top_rows_revenue + if float(row["total_revenue_msats"] or 0.0) > 0 + ] + top_models_tokens = [ + str(row["model"]) + for row in top_rows_tokens + if int(row["total_tokens"] or 0) > 0 + ] + + selected_models: list[str] = [] + for model in top_models_requests + top_models_revenue + top_models_tokens: + if model not in selected_models: + selected_models.append(model) bucket_seconds = max(60, int(interval_minutes) * 60) total_rows = conn.execute( @@ -1191,8 +1234,8 @@ class UsageAnalyticsStore: bucket["others_revenue_msats"] = total_revenue_msats bucket["others_tokens"] = total_tokens - if top_models and bucket_index: - placeholders = ",".join("?" for _ in top_models) + if selected_models and bucket_index: + placeholders = ",".join("?" for _ in selected_models) top_model_rows = conn.execute( f""" SELECT @@ -1210,7 +1253,7 @@ class UsageAnalyticsStore: GROUP BY bucket_ts, model ORDER BY bucket_ts """, - (bucket_seconds, bucket_seconds, cutoff_timestamp, *top_models), + (bucket_seconds, bucket_seconds, cutoff_timestamp, *selected_models), ).fetchall() for row in top_model_rows: @@ -1244,7 +1287,12 @@ class UsageAnalyticsStore: metrics = sorted(bucket_index.values(), key=lambda item: str(item["timestamp"])) return { - "top_models": top_models, + "top_models": top_models_requests, + "top_models_by_metric": { + "requests": top_models_requests, + "revenue": top_models_revenue, + "tokens": top_models_tokens, + }, "metrics": metrics, "hours_back": hours_back, "interval_minutes": interval_minutes, From 09e7f1f0bf69504272cdffda956f1abb7d7f3197 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Thu, 5 Mar 2026 16:23:58 +0800 Subject: [PATCH 11/13] Increase snapshot model limit to 20 --- routstr/nostr/analytics.py | 2 +- tests/unit/test_nostr_analytics.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/routstr/nostr/analytics.py b/routstr/nostr/analytics.py index eb148664..e568b5e0 100644 --- a/routstr/nostr/analytics.py +++ b/routstr/nostr/analytics.py @@ -34,7 +34,7 @@ PUBLISH_INTERVAL_SECONDS = 15 * 60 DISABLED_POLL_SECONDS = 60 DASHBOARD_WINDOW_HOURS = 24 DASHBOARD_INTERVAL_MINUTES = 60 -MODEL_LIMIT = 10 +MODEL_LIMIT = 20 WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = ( ("24h", 24, 60), ("7d", 7 * 24, 6 * 60), diff --git a/tests/unit/test_nostr_analytics.py b/tests/unit/test_nostr_analytics.py index 577d5ca0..9e159dd3 100644 --- a/tests/unit/test_nostr_analytics.py +++ b/tests/unit/test_nostr_analytics.py @@ -79,7 +79,7 @@ def test_build_stats_snapshot_payload_schema_and_shape(monkeypatch: Any) -> None ) -> dict[str, Any]: seen_windows.add((hours, interval)) assert error_limit == 1 - assert model_limit == 10 + assert model_limit == 20 return { "summary": { "total_requests": hours, From 8a89a388645f4275db68285556833e4378e2c63b Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Fri, 13 Mar 2026 16:39:44 +0800 Subject: [PATCH 12/13] fix: restore analytics branch test and admin flows --- routstr/core/admin.py | 59 +++++++++++++++++++ .../test_general_info_endpoints.py | 4 +- ui/app/providers/page.tsx | 1 - ui/components/provider-card.tsx | 2 - .../providers/RoutstrCreateKeySection.tsx | 3 +- ui/lib/api/services/admin.ts | 20 +++++++ 6 files changed, 82 insertions(+), 7 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index cdfb2e86..8dc643c5 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -822,6 +822,10 @@ class TopupRequest(BaseModel): amount: int +class CashuTopupRequest(BaseModel): + cashu_token: str + + @admin_router.post( "/api/upstream-providers/{provider_id}/topup", dependencies=[Depends(require_admin_api)], @@ -885,6 +889,61 @@ async def initiate_provider_topup( raise HTTPException(status_code=500, detail=str(e)) +@admin_router.post( + "/api/upstream-providers/{provider_id}/topup/token", + dependencies=[Depends(require_admin_api)], +) +async def topup_provider_with_token( + provider_id: int, payload: CashuTopupRequest +) -> dict[str, object]: + """Top up the upstream provider account with a Cashu token.""" + from ..upstream.helpers import _instantiate_provider + + async with create_session() as session: + provider = await session.get(UpstreamProviderRow, provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + upstream_instance = _instantiate_provider(provider) + if not upstream_instance: + raise HTTPException( + status_code=400, detail="Could not instantiate provider" + ) + + cashu_token = payload.cashu_token.strip() + if not cashu_token: + raise HTTPException(status_code=400, detail="Cashu token is required") + + if not hasattr(upstream_instance, "topup"): + raise HTTPException( + status_code=400, detail="Provider does not support token top-up" + ) + + try: + topup_data = await upstream_instance.topup(cashu_token) + if isinstance(topup_data, dict) and topup_data.get("error"): + raise HTTPException(status_code=400, detail=str(topup_data["error"])) + + return { + "ok": True, + "topup_data": topup_data if isinstance(topup_data, dict) else {}, + "message": "Token redeemed successfully", + } + except NotImplementedError as e: + raise HTTPException( + status_code=400, + detail=f"Provider does not support token top-up: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to top up provider {provider_id} with token: {e}", + extra={"error_type": type(e).__name__}, + ) + raise HTTPException(status_code=500, detail=str(e)) + + @admin_router.get( "/api/upstream-providers/{provider_id}/topup/{invoice_id}/status", dependencies=[Depends(require_admin_api)], diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index d9399f09..17710c83 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -264,12 +264,12 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) - async def test_admin_endpoint_unauthenticated( integration_client: AsyncClient, db_snapshot: Any ) -> None: - """Test GET /admin/ endpoint redirects to /""" + """Test admin API requires authentication.""" await db_snapshot.capture() response = await integration_client.get("/admin/api/settings") - assert response.status_code == 403 + assert response.status_code == 401 diff = await db_snapshot.diff() assert len(diff["api_keys"]["added"]) == 0 diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx index 4a7046a8..77836126 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -451,7 +451,6 @@ export default function ProvidersPage() { data: { api_key: newKey }, }); }} - availableMints={availableMints} /> ))}

diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index d239547e..d5ed95af 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -50,7 +50,6 @@ interface ProviderCardProps { onDeleteModel: (modelId: string) => void; onOverrideModel: (model: AdminModel) => void; onUpdateApiKey: (newKey: string) => void; - availableMints: string[]; } export function ProviderCard({ @@ -70,7 +69,6 @@ export function ProviderCard({ onDeleteModel, onOverrideModel, onUpdateApiKey, - availableMints, }: ProviderCardProps) { const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const hasDetails = Boolean(provider.api_version) || isExpanded; diff --git a/ui/components/providers/RoutstrCreateKeySection.tsx b/ui/components/providers/RoutstrCreateKeySection.tsx index 0d9de579..2fecf5eb 100644 --- a/ui/components/providers/RoutstrCreateKeySection.tsx +++ b/ui/components/providers/RoutstrCreateKeySection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useState } from 'react'; import Image from 'next/image'; import { Copy, Loader2, Zap, KeyRound } from 'lucide-react'; import { toast } from 'sonner'; @@ -10,7 +10,6 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; interface RoutstrCreateKeySectionProps { diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index b3f7677f..d14e939a 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -20,6 +20,7 @@ export const UpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean(), provider_fee: z.number().optional(), + provider_settings: z.record(z.string(), z.unknown()).optional(), }); export const CreateUpstreamProviderSchema = z.object({ @@ -29,6 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean().default(true), provider_fee: z.number().optional(), + provider_settings: z.record(z.string(), z.unknown()).optional(), }); export const UpdateUpstreamProviderSchema = z.object({ @@ -38,6 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean().optional(), provider_fee: z.number().optional(), + provider_settings: z.record(z.string(), z.unknown()).optional(), }); export const AdminModelPricingSchema = z.object({ @@ -914,6 +917,23 @@ export class AdminService { }); } + static async topupProviderWithToken( + providerId: number, + cashuToken: string + ): Promise<{ + ok: boolean; + topup_data: Record; + message: string; + }> { + return await apiClient.post<{ + ok: boolean; + topup_data: Record; + message: string; + }>(`/admin/api/upstream-providers/${providerId}/topup/token`, { + cashu_token: cashuToken, + }); + } + static async checkTopupStatus( providerId: number, invoiceId: string From c5cb562165c6ae1455939095138829cad9a76070 Mon Sep 17 00:00:00 2001 From: Evan Yang Date: Fri, 13 Mar 2026 17:00:15 +0800 Subject: [PATCH 13/13] chore: drop non-analytics branch drift --- logs/.gitkeep | 1 + routstr/core/admin.py | 330 ++++++++++++------ routstr/wallet.py | 109 ++---- .../test_general_info_endpoints.py | 4 +- ui/app/layout.tsx | 5 +- ui/app/providers/page.tsx | 1 + ui/components/landing/cheat-sheet.tsx | 4 +- ui/components/provider-card.tsx | 2 + .../providers/RoutstrCreateKeySection.tsx | 3 +- ui/components/revenue-by-model-table.tsx | 202 ----------- ui/lib/api/services/admin.ts | 29 +- 11 files changed, 286 insertions(+), 404 deletions(-) create mode 100644 logs/.gitkeep delete mode 100644 ui/components/revenue-by-model-table.tsx diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/logs/.gitkeep @@ -0,0 +1 @@ + diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 8dc643c5..b278c00d 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -2,7 +2,6 @@ import json import secrets from datetime import datetime, timezone from pathlib import Path -from typing import NoReturn from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel @@ -27,55 +26,20 @@ logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) admin_sessions: dict[str, int] = {} -ADMIN_SESSION_DURATION = 12 * 60 * 60 +ADMIN_SESSION_DURATION = 3600 # Usage analytics remain queryable up to 12 months. MAX_USAGE_ANALYTICS_HOURS = 365 * 24 -def _current_timestamp() -> int: - return int(datetime.now(timezone.utc).timestamp()) - - -def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None: - current_timestamp = ( - now_timestamp if now_timestamp is not None else _current_timestamp() - ) - expired_tokens = [ - token - for token, expiry_timestamp in admin_sessions.items() - if expiry_timestamp <= current_timestamp - ] - for token in expired_tokens: - admin_sessions.pop(token, None) - - -def _raise_unauthorized(detail: str) -> NoReturn: - raise HTTPException( - status_code=401, - detail=detail, - headers={"WWW-Authenticate": "Bearer"}, - ) - - def require_admin_api(request: Request) -> None: - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - _raise_unauthorized("Missing bearer token") + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1] + expiry = admin_sessions.get(token) + if expiry and expiry > int(datetime.now(timezone.utc).timestamp()): + return - token = auth_header.split(" ", 1)[1].strip() - if not token: - _raise_unauthorized("Missing bearer token") - - now_timestamp = _current_timestamp() - expiry_timestamp = admin_sessions.get(token) - if expiry_timestamp is None: - _raise_unauthorized("Invalid session token") - - if expiry_timestamp <= now_timestamp: - admin_sessions.pop(token, None) - _raise_unauthorized("Session expired") - - _cleanup_expired_admin_sessions(now_timestamp) + raise HTTPException(status_code=403, detail="Unauthorized") @admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)]) @@ -244,10 +208,18 @@ async def admin_login( raise HTTPException(status_code=401, detail="Invalid password") token = secrets.token_urlsafe(32) - expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION + expiry_timestamp = ( + int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION + ) admin_sessions[token] = expiry_timestamp - _cleanup_expired_admin_sessions() + expired_tokens = [ + t + for t, exp in admin_sessions.items() + if exp <= int(datetime.now(timezone.utc).timestamp()) + ] + for t in expired_tokens: + del admin_sessions[t] return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION} @@ -574,6 +546,7 @@ class UpstreamProviderCreate(BaseModel): api_version: str | None = None enabled: bool = True provider_fee: float = 1.01 + provider_settings: dict | None = None class UpstreamProviderUpdate(BaseModel): @@ -583,6 +556,7 @@ class UpstreamProviderUpdate(BaseModel): api_version: str | None = None enabled: bool | None = None provider_fee: float | None = None + provider_settings: dict | None = None @admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)]) @@ -599,6 +573,9 @@ async def get_upstream_providers() -> list[dict[str, object]]: "api_version": p.api_version, "enabled": p.enabled, "provider_fee": p.provider_fee, + "provider_settings": json.loads(p.provider_settings) + if p.provider_settings + else None, } for p in providers ] @@ -628,6 +605,9 @@ async def create_upstream_provider( api_version=payload.api_version, enabled=payload.enabled, provider_fee=payload.provider_fee, + provider_settings=json.dumps(payload.provider_settings) + if payload.provider_settings + else None, ) session.add(provider) await session.commit() @@ -643,6 +623,7 @@ async def create_upstream_provider( "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, + "provider_settings": payload.provider_settings, } @@ -662,6 +643,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]: "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, + "provider_settings": json.loads(provider.provider_settings) + if provider.provider_settings + else None, } @@ -688,6 +672,8 @@ async def update_upstream_provider( provider.enabled = payload.enabled if payload.provider_fee is not None: provider.provider_fee = payload.provider_fee + if payload.provider_settings is not None: + provider.provider_settings = json.dumps(payload.provider_settings) session.add(provider) await session.commit() @@ -703,6 +689,9 @@ async def update_upstream_provider( "api_version": provider.api_version, "enabled": provider.enabled, "provider_fee": provider.provider_fee, + "provider_settings": json.loads(provider.provider_settings) + if provider.provider_settings + else None, } @@ -822,8 +811,45 @@ class TopupRequest(BaseModel): amount: int -class CashuTopupRequest(BaseModel): - cashu_token: str +class TopupTokenRequest(BaseModel): + token: str + + +@admin_router.post( + "/api/upstream-providers/{provider_id}/topup-token", + dependencies=[Depends(require_admin_api)], +) +async def topup_provider_with_token( + provider_id: int, payload: TopupTokenRequest +) -> dict: + """Redeem a Cashu token for an upstream provider.""" + async with create_session() as session: + provider = await session.get(UpstreamProviderRow, provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + headers = {} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + resp = await client.post( + f"{clean_url}/v1/balance/topup", + json={"cashu_token": payload.token}, + headers=headers, + ) + + if resp.status_code == 200: + return {"ok": True, "message": "Token redeemed successfully"} + else: + logger.error(f"Upstream token topup failed: {resp.text}") + try: + error_detail = resp.json() + except Exception: + error_detail = resp.text + return {"ok": False, "message": f"Upstream error: {error_detail}"} @admin_router.post( @@ -852,7 +878,49 @@ async def initiate_provider_topup( f"Initiating top-up for provider {provider_id}", extra={"amount": payload.amount}, ) + + # For Routstr providers, we might be doing a Lightning top-up or a direct token transfer + if provider.provider_type == "routstr": + # UI sends sats for Routstr topup + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + # Proxy the request to upstream Routstr + # Use the actual API key from the database + resp = await client.post( + f"{clean_url}/v1/balance/lightning/invoice", + json={ + "amount_sats": int(payload.amount), + "purpose": "topup", + "api_key": provider.api_key, + }, + headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + ) + + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "topup_data": { + "payment_request": data.get("bolt11"), + "invoice_id": data.get("invoice_id"), + "status": "pending", + }, + } + else: + logger.error(f"Upstream topup request failed: {resp.text}") + # Check if it's JSON error + try: + error_detail = resp.json() + except Exception: + error_detail = resp.text + raise HTTPException( + status_code=resp.status_code, detail=error_detail + ) + topup_data = await upstream_instance.initiate_topup(payload.amount) + logger.info( "Top-up initiated successfully", extra={ @@ -889,61 +957,6 @@ async def initiate_provider_topup( raise HTTPException(status_code=500, detail=str(e)) -@admin_router.post( - "/api/upstream-providers/{provider_id}/topup/token", - dependencies=[Depends(require_admin_api)], -) -async def topup_provider_with_token( - provider_id: int, payload: CashuTopupRequest -) -> dict[str, object]: - """Top up the upstream provider account with a Cashu token.""" - from ..upstream.helpers import _instantiate_provider - - async with create_session() as session: - provider = await session.get(UpstreamProviderRow, provider_id) - if not provider: - raise HTTPException(status_code=404, detail="Provider not found") - - upstream_instance = _instantiate_provider(provider) - if not upstream_instance: - raise HTTPException( - status_code=400, detail="Could not instantiate provider" - ) - - cashu_token = payload.cashu_token.strip() - if not cashu_token: - raise HTTPException(status_code=400, detail="Cashu token is required") - - if not hasattr(upstream_instance, "topup"): - raise HTTPException( - status_code=400, detail="Provider does not support token top-up" - ) - - try: - topup_data = await upstream_instance.topup(cashu_token) - if isinstance(topup_data, dict) and topup_data.get("error"): - raise HTTPException(status_code=400, detail=str(topup_data["error"])) - - return { - "ok": True, - "topup_data": topup_data if isinstance(topup_data, dict) else {}, - "message": "Token redeemed successfully", - } - except NotImplementedError as e: - raise HTTPException( - status_code=400, - detail=f"Provider does not support token top-up: {str(e)}", - ) - except HTTPException: - raise - except Exception as e: - logger.error( - f"Failed to top up provider {provider_id} with token: {e}", - extra={"error_type": type(e).__name__}, - ) - raise HTTPException(status_code=500, detail=str(e)) - - @admin_router.get( "/api/upstream-providers/{provider_id}/topup/{invoice_id}/status", dependencies=[Depends(require_admin_api)], @@ -958,6 +971,23 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj if not provider: raise HTTPException(status_code=404, detail="Provider not found") + # For Routstr providers, proxy the status check + if provider.provider_type == "routstr": + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + resp = await client.get( + f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status", + headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + ) + if resp.status_code == 200: + status_data = resp.json() + return {"ok": True, "paid": status_data.get("status") == "paid"} + else: + logger.error(f"Upstream status check failed: {resp.text}") + return {"ok": False, "paid": False} + upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -985,7 +1015,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj dependencies=[Depends(require_admin_api)], ) async def get_provider_balance(provider_id: int) -> dict[str, object]: - """Get the current account balance for the upstream provider.""" + """Get the current balance for an upstream provider account.""" from ..upstream.helpers import _instantiate_provider async with create_session() as session: @@ -993,6 +1023,30 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") + # For Routstr providers, proxy the balance check + if provider.provider_type == "routstr": + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + headers = {} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + resp = await client.get( + f"{clean_url}/v1/balance/info", + headers=headers, + ) + if resp.status_code == 200: + data = resp.json() + # Return balance in sats + balance = data.get("balance", 0) + if isinstance(balance, (int, float)): + return {"ok": True, "balance_data": balance // 1000} + return {"ok": True, "balance_data": balance} + else: + logger.error(f"Failed to fetch Routstr balance: {resp.text}") + return {"ok": False, "balance_data": None} + upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1205,3 +1259,71 @@ async def get_log_dates_api(request: Request) -> dict[str, object]: continue return {"dates": dates} + + +@admin_router.post( + "/api/upstream-providers/{provider_id}/routstr/refund", + dependencies=[Depends(require_admin_api)], +) +async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]: + """Refund balance from an upstream Routstr provider back to the local wallet.""" + from ..upstream.helpers import _instantiate_provider + from ..upstream.routstr import RoutstrUpstreamProvider + + async with create_session() as session: + provider_row = await session.get(UpstreamProviderRow, provider_id) + if not provider_row: + raise HTTPException(status_code=404, detail="Provider not found") + + if provider_row.provider_type != "routstr": + raise HTTPException( + status_code=400, detail="Refund only supported for Routstr providers" + ) + + provider = _instantiate_provider(provider_row) + if not isinstance(provider, RoutstrUpstreamProvider): + raise HTTPException(status_code=400, detail="Invalid provider instance") + + try: + # Request refund from upstream + data = await provider.refund_balance() + if "error" in data: + # If the upstream returned an OpenAI-style error (like the model unknown error) + # it means the request likely didn't even reach the refund endpoint handler + # but was intercepted by the proxy layer. + error_info = data.get("error", {}) + message = ( + error_info.get("message") + if isinstance(error_info, dict) + else str(error_info) + ) + return { + "ok": False, + "message": f"Upstream refund failed: {message}", + } + + token = data.get("token") + if not token: + return {"ok": False, "message": "Upstream did not return a token"} + + # Receive token into local wallet + from ..wallet import recieve_token + + try: + # Use current wallet to receive + await recieve_token(token) + return { + "ok": True, + "message": "Successfully received refund from upstream provider", + } + except Exception as e: + logger.error(f"Failed to receive refund token: {e}") + return { + "ok": False, + "message": f"Failed to receive refund token: {str(e)}", + "token": token, + } + + except Exception as e: + logger.exception(f"Refund failed for provider {provider_id}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routstr/wallet.py b/routstr/wallet.py index f4817bb9..71ea18ac 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,6 +1,5 @@ import asyncio import math -import time from typing import TypedDict from cashu.core.base import Proof, Token @@ -159,14 +158,6 @@ async def credit_balance( _wallets: dict[str, Wallet] = {} -_balances_cache_ttl_seconds = 300.0 -_balances_cache: dict[ - tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]] -] = {} -_balances_refresh_tasks: dict[ - tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]] -] = {} -_balances_cache_lock = asyncio.Lock() async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet: @@ -235,12 +226,6 @@ async def fetch_all_balances( """ if units is None: units = ["sat", "msat"] - units_key = tuple(units) - - now = time.time() - cached = _balances_cache.get(units_key) - if cached and cached[0] > now: - return cached[1] async def fetch_balance( session: db.AsyncSession, mint_url: str, unit: str @@ -276,71 +261,47 @@ async def fetch_all_balances( } return error_result - async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]: - # Create tasks for all mint/unit combinations - async with db.create_session() as session: - tasks = [ - fetch_balance(session, mint_url, unit) - for mint_url in settings.cashu_mints - for unit in units - ] + # Create tasks for all mint/unit combinations + async with db.create_session() as session: + tasks = [ + fetch_balance(session, mint_url, unit) + for mint_url in settings.cashu_mints + for unit in units + ] - # Run all tasks concurrently - balance_details = list(await asyncio.gather(*tasks)) + # Run all tasks concurrently + balance_details = list(await asyncio.gather(*tasks)) - # Calculate totals - total_wallet_balance_sats = 0 - total_user_balance_sats = 0 + # Calculate totals + total_wallet_balance_sats = 0 + total_user_balance_sats = 0 - for detail in balance_details: - if not detail.get("error"): - # Convert to sats for total calculation - unit = detail["unit"] - proofs_balance_sats = ( - detail["wallet_balance"] - if unit == "sat" - else detail["wallet_balance"] // 1000 - ) - user_balance_sats = ( - detail["user_balance"] - if unit == "sat" - else detail["user_balance"] // 1000 - ) + for detail in balance_details: + if not detail.get("error"): + # Convert to sats for total calculation + unit = detail["unit"] + proofs_balance_sats = ( + detail["wallet_balance"] + if unit == "sat" + else detail["wallet_balance"] // 1000 + ) + user_balance_sats = ( + detail["user_balance"] + if unit == "sat" + else detail["user_balance"] // 1000 + ) - total_wallet_balance_sats += proofs_balance_sats - total_user_balance_sats += user_balance_sats + total_wallet_balance_sats += proofs_balance_sats + total_user_balance_sats += user_balance_sats - owner_balance = total_wallet_balance_sats - total_user_balance_sats - return ( - balance_details, - total_wallet_balance_sats, - total_user_balance_sats, - owner_balance, - ) + owner_balance = total_wallet_balance_sats - total_user_balance_sats - async with _balances_cache_lock: - now = time.time() - cached = _balances_cache.get(units_key) - if cached and cached[0] > now: - return cached[1] - - refresh_task = _balances_refresh_tasks.get(units_key) - if refresh_task is None or refresh_task.done(): - refresh_task = asyncio.create_task(compute_balances()) - _balances_refresh_tasks[units_key] = refresh_task - - result = await refresh_task - - async with _balances_cache_lock: - _balances_cache[units_key] = ( - time.time() + _balances_cache_ttl_seconds, - result, - ) - current_task = _balances_refresh_tasks.get(units_key) - if current_task is refresh_task and refresh_task.done(): - _balances_refresh_tasks.pop(units_key, None) - - return result + return ( + balance_details, + total_wallet_balance_sats, + total_user_balance_sats, + owner_balance, + ) async def periodic_payout() -> None: diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index 17710c83..d9399f09 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -264,12 +264,12 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) - async def test_admin_endpoint_unauthenticated( integration_client: AsyncClient, db_snapshot: Any ) -> None: - """Test admin API requires authentication.""" + """Test GET /admin/ endpoint redirects to /""" await db_snapshot.capture() response = await integration_client.get("/admin/api/settings") - assert response.status_code == 401 + assert response.status_code == 403 diff = await db_snapshot.diff() assert len(diff["api_keys"]["added"]) == 0 diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx index 0ff625d7..92c51b56 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -3,6 +3,7 @@ import { GeistMono } from 'geist/font/mono'; import { GeistSans } from 'geist/font/sans'; import './globals.css'; import { Providers } from './providers'; +import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning'; export const metadata: Metadata = { title: 'Routstr', @@ -22,7 +23,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx index 77836126..4a7046a8 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -451,6 +451,7 @@ export default function ProvidersPage() { data: { api_key: newKey }, }); }} + availableMints={availableMints} /> ))}
diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx index fa9aea87..7004ec98 100644 --- a/ui/components/landing/cheat-sheet.tsx +++ b/ui/components/landing/cheat-sheet.tsx @@ -66,7 +66,9 @@ function normalizeBaseUrl(url: string): string { } export function CheatSheet(): JSX.Element { - const [baseUrl, setBaseUrl] = useState(''); + const [baseUrl, setBaseUrl] = useState(() => + typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() + ); const [apiKeyInput, setApiKeyInput] = useState(''); const [walletInfo, setWalletInfo] = useState(null); const [refundReceipt, setRefundReceipt] = useState( diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index d5ed95af..d239547e 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -50,6 +50,7 @@ interface ProviderCardProps { onDeleteModel: (modelId: string) => void; onOverrideModel: (model: AdminModel) => void; onUpdateApiKey: (newKey: string) => void; + availableMints: string[]; } export function ProviderCard({ @@ -69,6 +70,7 @@ export function ProviderCard({ onDeleteModel, onOverrideModel, onUpdateApiKey, + availableMints, }: ProviderCardProps) { const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const hasDetails = Boolean(provider.api_version) || isExpanded; diff --git a/ui/components/providers/RoutstrCreateKeySection.tsx b/ui/components/providers/RoutstrCreateKeySection.tsx index 2fecf5eb..0d9de579 100644 --- a/ui/components/providers/RoutstrCreateKeySection.tsx +++ b/ui/components/providers/RoutstrCreateKeySection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import Image from 'next/image'; import { Copy, Loader2, Zap, KeyRound } from 'lucide-react'; import { toast } from 'sonner'; @@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; interface RoutstrCreateKeySectionProps { diff --git a/ui/components/revenue-by-model-table.tsx b/ui/components/revenue-by-model-table.tsx deleted file mode 100644 index 00183bab..00000000 --- a/ui/components/revenue-by-model-table.tsx +++ /dev/null @@ -1,202 +0,0 @@ -'use client'; - -import { useCallback, useMemo } from 'react'; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/components/ui/chart'; -import { ModelRevenueData } from '@/lib/api/services/admin'; -import { convertToMsat, formatFromMsat } from '@/lib/currency'; -import { useIsMobile } from '@/hooks/use-mobile'; -import type { DisplayUnit } from '@/lib/types/units'; - -interface RevenueByModelTableProps { - models: ModelRevenueData[]; - displayUnit: DisplayUnit; - usdPerSat: number | null; -} - -function truncateModelName(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - return `${value.slice(0, maxLength - 1)}…`; -} - -export function RevenueByModelTable({ - models, - displayUnit, - usdPerSat, -}: RevenueByModelTableProps) { - const isMobile = useIsMobile(); - - const revenueDisplayUnit: DisplayUnit = useMemo(() => { - if (displayUnit === 'usd' && usdPerSat === null) { - return 'sat'; - } - return displayUnit; - }, [displayUnit, usdPerSat]); - const unitLabel = revenueDisplayUnit === 'usd' ? 'USD' : revenueDisplayUnit; - - const compactNumber = useMemo( - () => - new Intl.NumberFormat('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - }), - [] - ); - - const convertSatsToDisplay = useCallback( - (sats: number): number => { - if (revenueDisplayUnit === 'msat') { - return sats * 1000; - } - if (revenueDisplayUnit === 'usd') { - return sats * (usdPerSat ?? 0); - } - return sats; - }, - [revenueDisplayUnit, usdPerSat] - ); - - const formatAmount = (sats: number) => - formatFromMsat(convertToMsat(sats, 'sat'), revenueDisplayUnit, usdPerSat); - - const formatCompactAmount = (value: number): string => { - const compact = compactNumber.format(value); - if (revenueDisplayUnit === 'usd') { - return `$${compact}`; - } - return `${compact} ${unitLabel}`; - }; - - const totalCollectedRevenue = models.reduce( - (sum, model) => sum + model.revenue_sats, - 0 - ); - const totalOperationalNet = models.reduce( - (sum, model) => sum + model.net_revenue_sats, - 0 - ); - - const chartData = useMemo( - () => - [...models] - .sort((a, b) => b.revenue_sats - a.revenue_sats) - .slice(0, 12) - .map((model) => ({ - model: model.model, - modelLabel: truncateModelName(model.model, isMobile ? 16 : 28), - revenueDisplay: convertSatsToDisplay(model.revenue_sats), - })), - [models, isMobile, convertSatsToDisplay] - ); - - const chartConfig: ChartConfig = { - revenueDisplay: { - label: 'Revenue', - color: 'var(--chart-1)', - }, - }; - - if (chartData.length === 0) { - return ( - - - Revenue by Model - - - No model data available - - - ); - } - - return ( - - - Revenue by Model -

- Total Collected Revenue:{' '} - - {formatAmount(totalCollectedRevenue)} - -

-

- Operational Net:{' '} - - {formatAmount(totalOperationalNet)} - -

-
- - - - - - compactNumber.format( - typeof value === 'number' ? value : Number(value || 0) - ) - } - /> - - String(label)} - formatter={(value, name) => { - const numericValue = - typeof value === 'number' ? value : Number(value || 0); - return ( -
- - {name} - - - {Number.isFinite(numericValue) - ? formatCompactAmount(numericValue) - : '-'} - -
- ); - }} - /> - } - /> - -
-
-
-
- ); -} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index d14e939a..f91f7c15 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -20,7 +20,7 @@ export const UpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean(), provider_fee: z.number().optional(), - provider_settings: z.record(z.string(), z.unknown()).optional(), + provider_settings: z.record(z.string(), z.any()).nullable().optional(), }); export const CreateUpstreamProviderSchema = z.object({ @@ -30,7 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean().default(true), provider_fee: z.number().optional(), - provider_settings: z.record(z.string(), z.unknown()).optional(), + provider_settings: z.record(z.string(), z.any()).nullable().optional(), }); export const UpdateUpstreamProviderSchema = z.object({ @@ -40,7 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({ api_version: z.string().nullable().optional(), enabled: z.boolean().optional(), provider_fee: z.number().optional(), - provider_settings: z.record(z.string(), z.unknown()).optional(), + provider_settings: z.record(z.string(), z.any()).nullable().optional(), }); export const AdminModelPricingSchema = z.object({ @@ -912,26 +912,17 @@ export class AdminService { ok: boolean; topup_data: Record; message: string; - }>(`/admin/api/upstream-providers/${providerId}/topup`, { - amount: amount, - }); + }>(`/admin/api/upstream-providers/${providerId}/topup`, { amount }); } static async topupProviderWithToken( providerId: number, - cashuToken: string - ): Promise<{ - ok: boolean; - topup_data: Record; - message: string; - }> { - return await apiClient.post<{ - ok: boolean; - topup_data: Record; - message: string; - }>(`/admin/api/upstream-providers/${providerId}/topup/token`, { - cashu_token: cashuToken, - }); + token: string + ): Promise<{ ok: boolean; message?: string }> { + return await apiClient.post<{ ok: boolean; message?: string }>( + `/admin/api/upstream-providers/${providerId}/topup-token`, + { token } + ); } static async checkTopupStatus(