Compare commits

...

37 Commits

Author SHA1 Message Date
Evan Yang
f2a0473fb3 Fix admin models route conflict 2026-03-23 20:29:18 +08:00
9qeklajc
f1c3b515fe Merge pull request #413 from Routstr/fix-linting-issue
fix linting error
2026-03-22 20:02:03 +01:00
9qeklajc
74ddc48ff5 fix linting error 2026-03-22 19:59:39 +01:00
9qeklajc
6db11ed88f update python 2026-03-18 23:40:34 +01:00
9qeklajc
4d302baeb5 update to v0.4.0 2026-03-18 23:31:25 +01:00
9qeklajc
063df51ced Merge pull request #411 from Routstr/408-fix-swap
fix swapping the primary mint
2026-03-18 22:17:29 +01:00
9qeklajc
e97fa60b27 Merge pull request #407 from Routstr/v0.4.0-analytics
V0.4.0 analytics
2026-03-18 21:48:44 +01:00
9qeklajc
42b3840df6 Merge branch 'v0.4.0' into v0.4.0-analytics
# Conflicts:
#	routstr/core/settings.py
2026-03-18 21:43:18 +01:00
9qeklajc
a0b2b9466c Merge pull request #409 from Routstr/cache-x-cashu-tokens
Cache x cashu tokens
2026-03-16 23:07:38 +01:00
9qeklajc
9a553de011 Merge pull request #410 from Routstr/allow-x-cashu-header
Allow x cashu header
2026-03-16 22:18:01 +01:00
9qeklajc
901a6a9ba2 fix swapping the primary mint 2026-03-16 21:40:43 +01:00
9qeklajc
691927a996 expose x-cashu header 2026-03-16 21:16:13 +01:00
9qeklajc
8fcecf2c1f Merge branch 'v0.4.0' into cache-x-cashu-tokens
# Conflicts:
#	routstr/core/admin.py
2026-03-16 21:07:44 +01:00
9qeklajc
aed967dc44 update ui 2026-03-16 21:06:13 +01:00
9qeklajc
ed8102d533 update ui 2026-03-14 16:59:42 +01:00
9qeklajc
8fa8475cca lint&fmt 2026-03-14 16:59:32 +01:00
9qeklajc
4723b9db4d fix storing in/out trans. 2026-03-14 16:56:44 +01:00
9qeklajc
358ff25899 Merge pull request #406 from Routstr/enforce-cheapest-model-prices
make sure the models with lowest prices are forwarded
2026-03-13 23:07:30 +01:00
9qeklajc
7267bb87b9 fix tests 2026-03-13 23:04:17 +01:00
9qeklajc
173f5fbcbd make sure the models with lowest prices are forwarded 2026-03-13 22:59:09 +01:00
9qeklajc
9006709f8d Merge pull request #404 from Routstr/add-missing-refund-button
add missing refund button
2026-03-13 22:39:39 +01:00
Evan Yang
c5cb562165 chore: drop non-analytics branch drift 2026-03-13 17:00:15 +08:00
Evan Yang
8a89a38864 fix: restore analytics branch test and admin flows 2026-03-13 16:39:44 +08:00
Evan Yang
09e7f1f0bf Increase snapshot model limit to 20 2026-03-13 15:55:29 +08:00
Evan Yang
d9d082ad5c Use per-metric top-model selection for usage mix 2026-03-13 15:55:29 +08:00
Evan Yang
9cd4ff5c21 Simplify analytics sharing to single snapshot with multi-window payloads 2026-03-13 15:55:29 +08:00
Evan Yang
11eb20a2d1 fix(ci): resolve backend type/lint and ui format issues 2026-03-13 15:55:29 +08:00
Evan Yang
a63e81db06 fix(ui): improve admin usage chart timestamp precision 2026-03-13 15:55:29 +08:00
Evan Yang
8fc1b6484c Add Nostr analytics snapshots and expand stats model coverage 2026-03-13 15:55:29 +08:00
Evan Yang
9bc3feff62 Enhance payment and analytics tracking by adding input and output token metrics across various components 2026-03-13 15:55:21 +08:00
Evan Yang
cb22968ff3 Refactor log management and usage analytics 2026-03-13 15:55:16 +08:00
Evan Yang
e3bca39815 Remove max_points plumbing from dashboard analytics 2026-03-13 15:55:09 +08:00
Evan Yang
958f28fd82 Optimize dashboard analytics pipeline and clean UI data flow 2026-03-13 15:54:59 +08:00
Evan Yang
c8c30d7cfd Implement caching in LogManager for improved performance and optimize log entry retrieval 2026-03-13 15:53:52 +08:00
9qeklajc
2c2124952f add transaction view 2026-03-11 23:39:22 +01:00
9qeklajc
f56ba92ae8 Merge branch 'v0.4.0' into cache-x-cashu-tokens 2026-03-11 22:53:15 +01:00
9qeklajc
8a373276ce cache x-cashu tokens 2026-03-11 22:03:05 +01:00
38 changed files with 5201 additions and 418 deletions

View File

@@ -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=

View File

@@ -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` |

View File

@@ -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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,42 @@
"""add cashu_transactions table
Revision ID: a776ca70e5fe
Revises: 614c0a740e68
Create Date: 2026-03-11 22:00:01.554762
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "a776ca70e5fe"
down_revision = "614c0a740e68"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"cashu_transactions",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("amount", sa.Integer(), nullable=False),
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"type",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="out",
),
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("collected", sa.Boolean(), nullable=False),
sa.Column("swept", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("cashu_transactions")

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.3.0"
version = "0.4.0"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -126,11 +126,24 @@ def create_model_mappings(
if isinstance(db_id, int):
providers_by_db_id[db_id] = upstream
# Group upstreams by URL and keep only the one with the lowest fee for each URL
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
for upstream in upstreams:
url = getattr(upstream, "base_url", "")
if url not in upstreams_by_url:
upstreams_by_url[url] = []
upstreams_by_url[url].append(upstream)
filtered_upstreams: list["BaseUpstreamProvider"] = []
for providers in upstreams_by_url.values():
best_provider = min(providers, key=lambda p: p.provider_fee)
filtered_upstreams.append(best_provider)
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
for upstream in filtered_upstreams:
base_url = getattr(upstream, "base_url", "")
if base_url == "https://openrouter.ai/api/v1":
openrouter = upstream

View File

@@ -372,12 +372,12 @@ async def validate_bearer_key(
},
)
key_preview = bearer_key[:10] + "..." if len(bearer_key) > 10 else bearer_key
logger.error(
"Invalid API key format",
f"Invalid API key format: preview={key_preview!r} length={len(bearer_key)} "
f"(expected 'sk-...' or 'cashu...' token)",
extra={
"key_preview": bearer_key[:10] + "..."
if len(bearer_key) > 10
else bearer_key,
"key_preview": key_preview,
"key_length": len(bearer_key),
},
)
@@ -386,7 +386,7 @@ async def validate_bearer_key(
status_code=401,
detail={
"error": {
"message": "Invalid API key",
"message": "Invalid API key format. Expected an 'sk-...' API key or a 'cashu...' token.",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
@@ -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,
},
)

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel
from sqlmodel import select
from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
@@ -154,9 +154,23 @@ async def topup_wallet_endpoint(
raise HTTPException(status_code=400, detail="Token already spent")
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
raise HTTPException(status_code=400, detail="Invalid token format")
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
raise HTTPException(
status_code=400,
detail=f"Token value is too small to cover swap fees. {error_msg}",
)
elif "failed to melt" in error_msg.lower():
raise HTTPException(
status_code=400,
detail=f"Failed to swap foreign mint token. {error_msg}",
)
else:
raise HTTPException(status_code=400, detail="Failed to redeem token")
except Exception:
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
except Exception as e:
logger.error(
"topup_wallet_endpoint: unhandled error",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise HTTPException(status_code=500, detail="Internal server error")
return {"msats": amount_msats}
@@ -401,6 +415,27 @@ async def reset_child_key_spent(
return {"success": True, "message": "Child key balance reset successfully."}
@router.get("/cashu-refund/{payment_token_hash}")
async def get_cashu_refund(
payment_token_hash: str,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
result.collected = True
session.add(result)
await session.commit()
return {
"refund_token": result.token,
"amount": result.amount,
"unit": result.unit,
}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],

View File

@@ -17,7 +17,13 @@ from ..wallet import (
send_token,
slow_filter_spend_proofs,
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from .db import (
ApiKey,
CashuTransaction,
ModelRow,
UpstreamProviderRow,
create_session,
)
from .log_manager import log_manager
from .logging import get_logger
from .settings import SettingsService, settings
@@ -28,6 +34,8 @@ admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 3600
# Usage analytics remain queryable up to 12 months.
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
def require_admin_api(request: Request) -> None:
@@ -748,7 +756,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
)
db_model_ids = {model.id for model in db_models}
filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids]
filtered_remote_models = [
m for m in upstream_models if m.id not in db_model_ids
]
return {
"provider": {
@@ -1002,7 +1012,9 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
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 {},
headers={"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {},
)
if resp.status_code == 200:
status_data = resp.json()
@@ -1137,16 +1149,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)
@@ -1155,7 +1208,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"
),
@@ -1169,7 +1227,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"
),
@@ -1264,6 +1327,49 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
return {"dates": dates}
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
async def get_transactions_api(
type: str | None = None,
status: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict:
async with create_session() as session:
from sqlmodel import col
stmt = select(CashuTransaction)
if type:
stmt = stmt.where(CashuTransaction.type == type)
if status:
if status == "collected":
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
elif status == "swept":
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
elif status == "pending":
stmt = stmt.where(
CashuTransaction.collected == False, # noqa: E712
CashuTransaction.swept == False, # noqa: E712
)
if search:
search_pattern = f"%{search}%"
stmt = stmt.where(
(col(CashuTransaction.id).like(search_pattern))
| (col(CashuTransaction.token).like(search_pattern))
| (col(CashuTransaction.request_id).like(search_pattern))
)
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
results = await session.exec(stmt)
transactions = results.all()
return {
"transactions": [tx.dict() for tx in transactions],
"total": len(transactions),
}
@admin_router.post(
"/api/upstream-providers/{provider_id}/routstr/refund",
dependencies=[Depends(require_admin_api)],

View File

@@ -2,11 +2,13 @@ import os
import pathlib
import sqlite3
import time
import uuid
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
@@ -128,6 +130,59 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class CashuTransaction(SQLModel, table=True): # type: ignore
__tablename__ = "cashu_transactions"
id: str = Field(
primary_key=True,
default_factory=lambda: uuid.uuid4().hex,
description="Unique transaction identifier",
)
token: str = Field(description="Serialized Cashu token")
amount: int = Field(description="Amount in the token's unit")
unit: str = Field(description="Token unit (sat or msat)")
mint_url: str | None = Field(default=None, description="Mint URL for the token")
type: str = Field(default="out", description="Transaction type: in or out")
request_id: str | None = Field(default=None, description="Associated request ID")
created_at: int = Field(
default_factory=lambda: int(time.time()),
description="Unix timestamp",
)
collected: bool = Field(default=False)
swept: bool = Field(default=False)
async def store_cashu_transaction(
token: str,
amount: int,
unit: str,
mint_url: str | None = None,
typ: str = "out",
request_id: str | None = None,
collected: bool = False,
created_at: int | None = None,
) -> None:
try:
async with create_session() as session:
tx = CashuTransaction(
token=token,
amount=amount,
unit=unit,
mint_url=mint_url,
type=typ,
request_id=request_id,
collected=collected,
created_at=created_at or int(time.time()),
)
session.add(tx)
await session.commit()
except Exception as e:
logger.warning(
f"Failed to store cashu transaction: {e} (type={typ})",
extra={"error": str(e), "type": typ},
)
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
__table_args__ = (
@@ -227,6 +282,17 @@ def fix_cashu_migrations() -> None:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def _clear_alembic_version() -> None:
"""Clear the alembic_version table so stamp/upgrade can proceed."""
sync_url = DATABASE_URL.replace("+aiosqlite", "")
from sqlalchemy import create_engine, text
eng = create_engine(sync_url)
with eng.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
eng.dispose()
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
try:
@@ -248,8 +314,19 @@ def run_migrations() -> None:
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
command.upgrade(alembic_cfg, "head")
try:
command.upgrade(alembic_cfg, "head")
except CommandError as e:
if "Can't locate revision" in str(e):
logger.warning(
"Database stamped with unknown revision (likely from another branch). "
"Re-stamping to current head.",
extra={"error": str(e)},
)
_clear_alembic_version()
command.stamp(alembic_cfg, "head")
else:
raise
logger.info("Database migrations completed successfully")

View File

@@ -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,128 +327,208 @@ 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)
)
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),
compute,
)
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)
)
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),
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,
)
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:
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}"
)
for entry in self._yield_log_entries(hours_back=hours):
if str(entry.get("levelname", "")).upper() != "ERROR":
continue
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.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: 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)
)
)
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:
def compute() -> dict:
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
return self._usage_store.get_revenue_by_model(
hours_back=hours, limit=limit
)
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
except Exception as e:
logger.error(
f"Usage analytics index failed, falling back to log scan: {e}"
)
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
entries = self._get_cached_entries(hours)
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
@@ -555,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:

View File

@@ -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', 'model', 'input_tokens', and 'output_tokens' 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', '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
- 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

View File

@@ -12,13 +12,17 @@ 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
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..upstream.auto_topup import periodic_auto_topup
from ..wallet import periodic_payout
from ..wallet import periodic_payout, periodic_refund_sweep
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
@@ -32,9 +36,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.3.0"
__version__ = "0.4.0"
@asynccontextmanager
@@ -45,11 +49,13 @@ 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
key_reset_task = None
auto_topup_task = None
refund_sweep_task = None
try:
# Run database migrations on startup
@@ -103,10 +109,12 @@ 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())
auto_topup_task = asyncio.create_task(periodic_auto_topup())
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
yield
@@ -130,6 +138,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:
@@ -140,6 +150,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
key_reset_task.cancel()
if auto_topup_task is not None:
auto_topup_task.cancel()
if refund_sweep_task is not None:
refund_sweep_task.cancel()
try:
tasks_to_wait = []
@@ -151,6 +163,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:
@@ -161,6 +175,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(key_reset_task)
if auto_topup_task is not None:
tasks_to_wait.append(auto_topup_task)
if refund_sweep_task is not None:
tasks_to_wait.append(refund_sweep_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -181,7 +197,7 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["x-routstr-request-id"],
expose_headers=["x-routstr-request-id", "x-cashu"],
)
# Add logging middleware

View File

@@ -74,6 +74,7 @@ class Settings(BaseSettings):
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
refund_sweep_ttl_seconds: int = Field(default=86400, env="REFUND_SWEEP_TTL_SECONDS")
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@@ -92,6 +93,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 +246,21 @@ 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)
valid_fields = set(env_resolved.dict().keys())
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, "", [], {})}
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
)
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 +268,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 +291,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
@@ -305,8 +325,10 @@ class SettingsService:
raise RuntimeError("Settings row missing")
(data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
valid_fields = set(settings.dict().keys())
# Update in-place
for k, v in data.items():
setattr(settings, k, v)
if k in valid_fields:
setattr(settings, k, v)
cls._current = settings
return settings

File diff suppressed because it is too large Load Diff

View File

@@ -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"]

419
routstr/nostr/analytics.py Normal file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
Nostr usage analytics publisher.
Publishes a single replaceable analytics snapshot for each provider.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import time
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.snapshot.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),
("3m", 90 * 24, 24 * 60),
("1y", 365 * 24, 7 * 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 _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: _to_int(row.get("successful_requests", 0)),
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_window_payload(
*,
hours: int,
interval_minutes: int,
model_limit: int,
) -> dict[str, Any]:
dashboard = log_manager.get_usage_dashboard(
interval=interval_minutes,
hours=hours,
error_limit=1,
model_limit=model_limit,
)
summary = dashboard.get("summary", {})
model_usage_mix = dashboard.get("model_usage_mix", {})
summary_payload = _build_summary_payload(summary if isinstance(summary, 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_minutes,
"summary": summary_payload,
"model_usage_mix": usage_mix_payload,
"top_model_usage": top_model_usage,
"others_usage": others_usage,
}
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, hours, window_interval_minutes in WINDOW_DEFINITIONS:
windows[key] = _build_window_payload(
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,
"provider_id": provider_id,
"pubkey": public_key_hex,
"npub": settings.npub or "",
"endpoint_urls": _resolve_endpoint_urls(),
"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 create_stats_snapshot_event(
private_key_hex: str,
provider_id: str,
payload_json: 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],
]
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 generated timestamp for semantic dedupe.
normalized.pop("generated_at", None)
payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True)
return hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
async def publish_usage_analytics() -> None:
last_payload_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_payload_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())
payload = build_stats_snapshot_payload(
resolved_provider_id,
public_key_hex=public_key_hex,
generated_at=now_ts,
)
payload_hash = _fingerprint_payload(payload)
if last_payload_hash == payload_hash:
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
continue
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,
payload_json,
d_tag=d_tag,
)
success_count = 0
for relay_url in relay_urls:
if await publish_to_relay(relay_url, event):
success_count += 1
if success_count > 0:
last_payload_hash = payload_hash
logger.info(
"Published analytics snapshot (success=%s/%s provider=%s)",
success_count,
len(relay_urls),
resolved_provider_id,
extra={
"relay_success_count": success_count,
"relay_total": len(relay_urls),
"provider_id": resolved_provider_id,
},
)
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)

View File

@@ -16,6 +16,8 @@ class CostData(BaseModel):
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
class MaxCostData(CostData):
@@ -63,10 +65,49 @@ async def calculate_cost( # todo: can be sync
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
)
usage_data = response_data["usage"]
def parse_token_count(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(usage_data.get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(usage_data.get("output_tokens", 0))
)
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
)
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
@@ -104,6 +145,8 @@ async def calculate_cost( # todo: can be sync
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
logger.warning(
@@ -184,31 +227,10 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=max_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
@@ -234,4 +256,6 @@ async def calculate_cost( # todo: can be sync
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,
output_tokens=output_tokens,
)

View File

@@ -455,15 +455,14 @@ async def get_bearer_token_key(
)
return key
except Exception as e:
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
logger.error(
"Bearer token validation failed",
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} key={key_preview!r}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"bearer_key_preview": key_preview,
},
)
raise

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import re
import traceback
@@ -15,7 +16,13 @@ from sqlmodel import select
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
from ..core.db import (
ApiKey,
AsyncSession,
UpstreamProviderRow,
create_session,
store_cashu_transaction,
)
from ..core.exceptions import UpstreamError
from ..payment.cost_calculation import (
CostData,
@@ -203,9 +210,7 @@ class BaseUpstreamProvider:
return path.replace("v1/", "", 1)
return path
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
def get_request_base_url(self, path: str, model_obj: Model | None = None) -> str:
"""Get upstream base URL used when building forwarding URL."""
return self.base_url.rstrip("/")
@@ -1325,9 +1330,7 @@ class BaseUpstreamProvider:
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError(
"An unexpected server error occurred", status_code=500
)
raise UpstreamError("An unexpected server error occurred", status_code=500)
async def forward_responses_request(
self,
@@ -1539,9 +1542,7 @@ class BaseUpstreamProvider:
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError(
"An unexpected server error occurred", status_code=500
)
raise UpstreamError("An unexpected server error occurred", status_code=500)
async def forward_get_request(
self,
@@ -1679,13 +1680,22 @@ class BaseUpstreamProvider:
)
return None
async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str:
async def send_refund(
self,
amount: int,
unit: str,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> str:
"""Create and send a refund token to the user.
Args:
amount: Refund amount
unit: Unit of the refund (sat or msat)
mint: Optional mint URL for the refund token
payment_token_hash: Optional SHA-256 hash of the original payment token for storage
request_id: Optional HTTP request ID for tracking
Returns:
Refund token string
@@ -1715,6 +1725,18 @@ class BaseUpstreamProvider:
},
)
try:
await store_cashu_transaction(
token=refund_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass # store_cashu_transaction already logs
return refund_token
except Exception as e:
last_exception = e
@@ -1764,6 +1786,8 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse:
"""Handle streaming response for X-Cashu payment, calculating refund if needed.
@@ -1773,6 +1797,7 @@ class BaseUpstreamProvider:
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
payment_token_hash: Optional hash of original payment token for refund storage
Returns:
StreamingResponse with refund token in header if applicable
@@ -1844,7 +1869,10 @@ class BaseUpstreamProvider:
},
)
refund_token = await self.send_refund(refund_amount, unit, mint)
refund_token = await self.send_refund(
refund_amount, unit, mint, payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
@@ -1897,6 +1925,8 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> Response:
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed.
@@ -1906,6 +1936,7 @@ class BaseUpstreamProvider:
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
payment_token_hash: Optional hash of original payment token for refund storage
Returns:
Response with refund token in header if applicable
@@ -1967,7 +1998,10 @@ class BaseUpstreamProvider:
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
refund_token = await self.send_refund(
refund_amount, unit, mint, payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
@@ -2003,6 +2037,17 @@ class BaseUpstreamProvider:
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
logger.warning(
"Emergency refund issued due to JSON parse error",
@@ -2027,6 +2072,8 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse | Response:
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
@@ -2063,11 +2110,25 @@ class BaseUpstreamProvider:
if is_streaming:
return await self.handle_x_cashu_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
else:
return await self.handle_x_cashu_non_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
except Exception as e:
@@ -2096,6 +2157,7 @@ class BaseUpstreamProvider:
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
payment_token_hash: str | None = None,
) -> Response | StreamingResponse:
"""Forward request paid with X-Cashu token to upstream service.
@@ -2166,7 +2228,10 @@ class BaseUpstreamProvider:
},
)
refund_token = await self.send_refund(amount - 60, unit, mint)
refund_token = await self.send_refund(
amount - 60, unit, mint, payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
logger.info(
"Refund processed for failed upstream request",
@@ -2204,7 +2269,13 @@ class BaseUpstreamProvider:
)
result = await self.handle_x_cashu_chat_completion(
response, amount, unit, max_cost_for_model, mint
response,
amount,
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
@@ -2279,10 +2350,25 @@ class BaseUpstreamProvider:
)
try:
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
logger.info(
"X-Cashu token redeemed for Responses API",
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
@@ -2297,6 +2383,7 @@ class BaseUpstreamProvider:
max_cost_for_model,
model_obj,
mint,
payment_token_hash,
)
except Exception as e:
error_message = str(e)
@@ -2356,6 +2443,7 @@ class BaseUpstreamProvider:
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
payment_token_hash: str | None = None,
) -> Response | StreamingResponse:
"""Forward Responses API request paid with X-Cashu token to upstream service.
@@ -2427,7 +2515,10 @@ class BaseUpstreamProvider:
},
)
refund_token = await self.send_refund(amount - 60, unit, mint)
refund_token = await self.send_refund(
amount - 60, unit, mint, payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
logger.info(
"Refund processed for failed upstream Responses API request",
@@ -2465,7 +2556,13 @@ class BaseUpstreamProvider:
)
result = await self.handle_x_cashu_responses_completion(
response, amount, unit, max_cost_for_model, mint
response,
amount,
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
@@ -2515,6 +2612,8 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse | Response:
"""Handle Responses API completion response for X-Cashu payment.
@@ -2552,11 +2651,25 @@ class BaseUpstreamProvider:
if is_streaming:
return await self.handle_x_cashu_streaming_responses_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
else:
return await self.handle_x_cashu_non_streaming_responses_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
except Exception as e:
@@ -2583,6 +2696,8 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse:
"""Handle streaming Responses API response for X-Cashu payment.
@@ -2664,7 +2779,10 @@ class BaseUpstreamProvider:
},
)
refund_token = await self.send_refund(refund_amount, unit, mint)
refund_token = await self.send_refund(
refund_amount, unit, mint, payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
@@ -2717,6 +2835,8 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> Response:
"""Handle non-streaming Responses API response for X-Cashu payment."""
logger.debug(
@@ -2776,7 +2896,10 @@ class BaseUpstreamProvider:
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
refund_token = await self.send_refund(
refund_amount, unit, mint, payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
logger.info(
@@ -2812,6 +2935,17 @@ class BaseUpstreamProvider:
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
logger.warning(
"Emergency refund issued for Responses API due to JSON parse error",
@@ -2861,10 +2995,25 @@ class BaseUpstreamProvider:
)
try:
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
logger.info(
"X-Cashu token redeemed successfully",
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
@@ -2879,6 +3028,7 @@ class BaseUpstreamProvider:
max_cost_for_model,
model_obj,
mint,
payment_token_hash,
)
except Exception as e:
error_message = str(e)
@@ -3090,7 +3240,7 @@ class BaseUpstreamProvider:
async with create_session() as session:
stmt = select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == self.base_url,
UpstreamProviderRow.api_key == self.api_key
UpstreamProviderRow.api_key == self.api_key,
)
result = await session.exec(stmt)
@@ -3111,15 +3261,24 @@ class BaseUpstreamProvider:
diff = set(db_model_ids) - set(model_ids)
for db_model_id in diff:
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
found_db_model = next(
(
model_obj
for model_obj in db_models
if model_obj.id == db_model_id
)
)
models.append(found_db_model)
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
models_with_fees = [
self._apply_provider_fee_to_model(m) for m in models
]
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
_update_model_sats_pricing(m, sats_to_usd)
for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees

View File

@@ -1,11 +1,12 @@
import asyncio
import math
import time
from typing import TypedDict
from cashu.core.base import Proof, Token
from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet
from sqlmodel import col, update
from sqlmodel import col, select, update
from .core import db, get_logger
from .core.settings import settings
@@ -34,6 +35,7 @@ async def recieve_token(
wallet.verify_proofs_dleq(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_obj.amount, token_obj.unit, token_obj.mint
@@ -62,11 +64,12 @@ async def swap_to_primary_mint(
token_obj: Token, token_wallet: Wallet
) -> tuple[int, str, str]:
logger.info(
"swap_to_primary_mint",
"swap_to_primary_mint: starting",
extra={
"mint": token_obj.mint,
"amount": token_obj.amount,
"foreign_mint": token_obj.mint,
"token_amount": token_obj.amount,
"unit": token_obj.unit,
"primary_mint": settings.primary_mint,
},
)
# Ensure amount is an integer
@@ -89,16 +92,105 @@ async def swap_to_primary_mint(
minted_amount = int(amount_msat_after_fee // 1000)
else:
minted_amount = int(amount_msat_after_fee)
logger.info(
"swap_to_primary_mint: fee estimation",
extra={
"token_amount_sat": amount_msat // 1000,
"estimated_fee_sat": estimated_fee_sat,
"minted_amount": minted_amount,
"minted_unit": settings.primary_mint_unit,
},
)
mint_quote = await primary_wallet.request_mint(minted_amount)
logger.info(
"swap_to_primary_mint: mint quote received",
extra={"mint_quote_id": mint_quote.quote},
)
melt_quote = await token_wallet.melt_quote(mint_quote.request)
_ = await token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
total_needed = melt_quote.amount + melt_quote.fee_reserve
logger.info(
"swap_to_primary_mint: melt quote received",
extra={
"melt_quote_id": melt_quote.quote,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"total_needed": total_needed,
"token_amount": token_amount,
},
)
if total_needed > token_amount:
logger.warning(
"swap_to_primary_mint: insufficient token amount for melt fees",
extra={
"token_amount": token_amount,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"total_needed": total_needed,
"shortfall": total_needed - token_amount,
},
)
raise ValueError(
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
f"melt fees. Needed: {total_needed} {token_obj.unit} "
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
)
try:
_ = await token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
)
except Exception as e:
logger.error(
"swap_to_primary_mint: melt failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"foreign_mint": token_obj.mint,
"token_amount": token_amount,
"melt_quote_id": melt_quote.quote,
"total_needed": total_needed,
},
)
raise ValueError(
f"Failed to melt token from foreign mint {token_obj.mint}: {e}"
) from e
logger.info(
"swap_to_primary_mint: melt succeeded, minting on primary",
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
)
try:
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
except Exception as e:
logger.error(
"swap_to_primary_mint: mint on primary failed after successful melt",
extra={
"error": str(e),
"error_type": type(e).__name__,
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
},
)
raise
logger.info(
"swap_to_primary_mint: completed successfully",
extra={
"foreign_mint": token_obj.mint,
"primary_mint": settings.primary_mint,
"original_amount": token_amount,
"minted_amount": minted_amount,
"unit": settings.primary_mint_unit,
},
)
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
return int(minted_amount), settings.primary_mint_unit, settings.primary_mint
@@ -352,6 +444,61 @@ async def periodic_payout() -> None:
)
async def periodic_refund_sweep() -> None:
while True:
await asyncio.sleep(60 * 60) # every hour
try:
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
async with db.create_session() as session:
stmt = select(db.CashuTransaction).where(
db.CashuTransaction.type == "out",
db.CashuTransaction.collected == False, # noqa: E712
db.CashuTransaction.swept == False, # noqa: E712
db.CashuTransaction.created_at < cutoff,
)
results = await session.exec(stmt)
refunds = results.all()
for refund in refunds:
try:
await recieve_token(refund.token)
refund.swept = True
session.add(refund)
logger.info(
"Swept uncollected refund",
extra={
"id": refund.id,
"amount": refund.amount,
"unit": refund.unit,
},
)
except Exception as e:
error_msg = str(e).lower()
if "already spent" in error_msg:
refund.swept = True
session.add(refund)
logger.info(
"Refund already spent (client collected), marking swept",
extra={
"id": refund.id,
},
)
else:
logger.warning(
"Failed to sweep refund",
extra={
"id": refund.id,
"error": str(e),
},
)
await session.commit()
except Exception as e:
logger.error(
"Error in periodic refund sweep",
extra={"error": str(e), "error_type": type(e).__name__},
)
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
wallet = await get_wallet(mint, unit)
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]

View File

@@ -0,0 +1,143 @@
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, cast
from unittest.mock import patch
import pytest
from routstr.core.db import AsyncSession, ModelRow, UpstreamProviderRow
from routstr.payment.models import Architecture, Model, Pricing
from routstr.proxy import refresh_model_maps
from routstr.upstream.base import BaseUpstreamProvider
@pytest.mark.integration
@pytest.mark.asyncio
async def test_enforce_lowest_provider_fee_for_same_url(
integration_session: Any,
) -> None:
"""Test that the algorithm selects the provider with the lowest fee when URLs match."""
# 1. Create two providers with the same URL but different fees
url = "https://api.example.com"
p1 = UpstreamProviderRow(
provider_type="custom",
base_url=url,
api_key="key1",
enabled=True,
provider_fee=1.01,
)
p2 = UpstreamProviderRow(
provider_type="custom",
base_url=url,
api_key="key2",
enabled=True,
provider_fee=1.05,
)
integration_session.add(p1)
integration_session.add(p2)
await integration_session.commit()
await integration_session.refresh(p1)
await integration_session.refresh(p2)
assert p1.id is not None
assert p2.id is not None
# 2. Add a model for each provider
m1 = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=p1.id,
enabled=True,
)
m2 = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=p2.id,
enabled=True,
)
integration_session.add(m1)
integration_session.add(m2)
await integration_session.commit()
# 3. Create mock provider instances
class MockProvider(BaseUpstreamProvider):
db_id: int
def __init__(self, db_id: int, base_url: str, api_key: str, fee: float):
super().__init__(base_url, api_key, fee)
self.db_id = db_id
self.provider_type = "custom"
def get_cached_models(self) -> list[Model]:
return [
Model(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="tiktoken",
instruct_type="chat",
),
pricing=Pricing(prompt=1.0, completion=1.0),
enabled=True,
upstream_provider_id=self.db_id,
)
]
async def refresh_models_cache(self) -> None:
pass
def prepare_headers(self, request_headers: dict[str, str]) -> dict[str, str]:
return request_headers
# 4. Inject mock providers into the proxy
from routstr import proxy
assert p1.id is not None
assert p2.id is not None
# Need to patch proxy._upstreams and proxy.create_session
mp1: MockProvider = MockProvider(p1.id, url, "key1", 1.01)
mp2: MockProvider = MockProvider(p2.id, url, "key2", 1.05)
with (
patch("routstr.proxy._upstreams", [mp1, mp2]),
patch("routstr.proxy.create_session") as mock_session_factory,
):
# Configure mock_session_factory to return a session that uses the test engine
@asynccontextmanager
async def mock_create_session() -> AsyncGenerator[AsyncSession, None]:
yield integration_session
mock_session_factory.return_value = mock_create_session()
await refresh_model_maps()
# 5. Check which provider is selected for 'model-a'
provider_map = proxy.get_provider_for_model("model-a")
# Assertions
assert provider_map is not None
assert len(provider_map) >= 1
# Check the first one, cast to MockProvider to access db_id
best_provider = cast(MockProvider, provider_map[0])
assert best_provider.db_id == p1.id
assert best_provider.provider_fee == 1.01

View File

@@ -0,0 +1,267 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
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_stats_snapshot_payload_schema_and_shape(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": hours,
"successful_chat_completions": max(1, hours - 1),
"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,
},
"model_usage_mix": {
"top_models": ["openai/gpt-4o"],
"metrics": [
{
"timestamp": "2026-03-02 10:00:00",
"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,
}
],
},
}
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_stats_snapshot_payload(
"provider123",
public_key_hex="ab" * 32,
generated_at=1772451600,
)
assert payload["schema"] == analytics.ANALYTICS_SCHEMA
assert payload["provider_id"] == "provider123"
assert payload["window_hours"] == 24
assert payload["interval_minutes"] == 60
assert payload["endpoint_urls"] == ["https://node.example.com"]
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,
}
]
assert payload["others_usage"] == {
"successful_requests": 4,
"revenue_msats": 1800.0,
"total_tokens": 400,
}
def test_create_stats_snapshot_event_tags() -> None:
private_key_hex = "11" * 32
event = analytics.create_stats_snapshot_event(
private_key_hex,
"provider123",
payload_json='{"schema":"routstr.analytics.snapshot.v1"}',
d_tag="provider123:stats",
)
tags = event["tags"]
assert ["d", "provider123:stats"] in tags
assert ["provider", "provider123"] in tags
assert ["schema", analytics.ANALYTICS_SCHEMA] in tags
assert all(tag[0] != "period" for tag in tags)
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 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", [])

View File

@@ -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

View File

@@ -108,6 +108,92 @@ async def test_credit_balance() -> None:
assert mock_session.refresh.called
@pytest.mark.asyncio
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = "http://foreign:3338"
mock_token.unit = "sat"
mock_token.amount = 404
mock_token.keysets = ["keyset1"]
mock_token.proofs = [{"amount": 404}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
mock_primary_wallet.load_proofs = AsyncMock()
mock_mint_quote = Mock()
mock_mint_quote.quote = "mint_quote_123"
mock_mint_quote.request = "lnbc1..."
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
mock_melt_quote = Mock()
mock_melt_quote.quote = "melt_quote_123"
mock_melt_quote.amount = 400
mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
from routstr.core.settings import settings
with patch.object(settings, "primary_mint", "http://primary:3338"):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
with pytest.raises(ValueError, match="insufficient to cover melt fees"):
await swap_to_primary_mint(mock_token, mock_token_wallet)
# melt should never have been called
mock_token_wallet.melt.assert_not_called()
@pytest.mark.asyncio
async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
"""Melt failure from cashu lib is wrapped as ValueError."""
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = "http://foreign:3338"
mock_token.unit = "sat"
mock_token.amount = 5000
mock_token.keysets = ["keyset1"]
mock_token.proofs = [{"amount": 5000}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
mock_primary_wallet.load_proofs = AsyncMock()
mock_mint_quote = Mock()
mock_mint_quote.quote = "mint_quote_456"
mock_mint_quote.request = "lnbc1..."
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
mock_melt_quote = Mock()
mock_melt_quote.quote = "melt_quote_456"
mock_melt_quote.amount = 4940
mock_melt_quote.fee_reserve = 50 # total 4990 < 5000, passes fee check
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
mock_token_wallet.melt = AsyncMock(
side_effect=Exception("Provided: 5000, needed: 5100 (Code: 11000)")
)
from routstr.core.settings import settings
with patch.object(settings, "primary_mint", "http://primary:3338"):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
with pytest.raises(ValueError, match="Failed to melt token"):
await swap_to_primary_mint(mock_token, mock_token_wallet)
@pytest.mark.asyncio
async def test_recieve_token_untrusted_mint() -> None:
mock_wallet = Mock()

5
ui/app/model/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { ModelsPage } from '@/components/models-page';
export default function ModelPage() {
return <ModelsPage />;
}

View File

@@ -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 (
@@ -461,7 +449,7 @@ function DashboardInsights({
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) =>
labelFormatter={(_: React.ReactNode, payload) =>
String(payload?.[0]?.payload?.type ?? '')
}
formatter={(value, name) => {
@@ -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<ChartConfig[]>(() => {
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.
</p>
{isUsageRangeCapped ? (
<p className='text-muted-foreground text-[11px] sm:text-xs'>
Usage analytics are capped to the last{' '}
{MAX_USAGE_RANGE_HOURS / 24} days for server safety.
</p>
) : null}
</div>
<div className='flex items-center gap-2'>
<div className='min-w-0 flex-1 sm:max-w-[22rem]'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<div className='w-full max-w-[20rem] sm:max-w-[22rem]'>
<div className='border-input bg-card/30 dark:bg-input/30 flex h-8 w-full min-w-0 items-stretch overflow-hidden rounded-lg border sm:h-9'>
<Popover
open={isCustomRangePickerOpen}
@@ -981,39 +982,20 @@ export default function DashboardPage() {
onClick={handleRefresh}
variant='outline'
disabled={isManualRefreshing}
aria-label='Refresh analytics'
className='h-8 w-8 shrink-0 rounded-lg p-0 sm:h-9 sm:w-auto sm:px-3'
className='h-8 w-full px-2.5 text-xs sm:ml-auto sm:w-auto'
>
<RefreshCw
className={cn(
'h-3.5 w-3.5 sm:mr-1',
'mr-1 h-3 w-3',
isManualRefreshing && 'animate-spin'
)}
/>
<span className='hidden sm:inline'>
{isManualRefreshing ? 'Refreshing...' : 'Refresh'}
</span>
{isManualRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
{metricsLoading ? (
<SectionLoading label='metrics' />
) : metricsError ? (
<Card>
<CardContent>
<Empty className='border-none py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<RefreshCw className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>Unable to load analytics</EmptyTitle>
<EmptyDescription>
{getQueryErrorMessage(metricsError)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
) : activeChartConfig ? (
<UsageMetricsChart
data={activeChartConfig.data}
@@ -1051,21 +1033,16 @@ export default function DashboardPage() {
</Card>
)}
{!metricsLoading && modelUsageMixData && hasModelUsageMixMetrics ? (
<TopModelsUsageChart
mix={modelUsageMixData}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
) : null}
{summaryLoading ? (
<SectionLoading label='summary' />
) : summaryError ? (
<Card>
<CardContent>
<Empty className='border-none py-8'>
<EmptyHeader>
<EmptyTitle>Usage summary unavailable</EmptyTitle>
<EmptyDescription>
{getQueryErrorMessage(summaryError)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
@@ -1074,19 +1051,6 @@ export default function DashboardPage() {
{errorLoading ? (
<SectionLoading label='errors' />
) : errorDetailsError ? (
<Card>
<CardContent>
<Empty className='border-none py-8'>
<EmptyHeader>
<EmptyTitle>Error details unavailable</EmptyTitle>
<EmptyDescription>
{getQueryErrorMessage(errorDetailsError)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}

View File

@@ -0,0 +1,366 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import {
RefreshCw,
Search,
ArrowDownLeft,
ArrowUpRight,
Copy,
Check,
Receipt,
} from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin';
import { format } from 'date-fns';
import { toast } from 'sonner';
const STORAGE_KEY = 'routstr-transaction-filters';
export default function TransactionsPage() {
const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all');
const [status, setStatus] = useState<string>('all');
const [copiedId, setCopiedId] = useState<string | null>(null);
// Load filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.search) setSearch(parsed.search);
if (parsed.type) setType(parsed.type);
if (parsed.status) setStatus(parsed.status);
} catch (e) {
console.error('Failed to load filters from localStorage', e);
}
}
}, []);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = { search, type, status };
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
}, [search, type, status]);
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['transactions', type, status, search],
queryFn: () =>
AdminService.getTransactions(
type === 'all' ? undefined : type,
status === 'all' ? undefined : status,
search || undefined,
100
),
});
const handleClearFilters = () => {
setSearch('');
setType('all');
setStatus('all');
};
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedId(null), 2000);
};
const getStatusBadge = (tx: Transaction) => {
if (tx.swept)
return (
<Badge
variant='outline'
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
>
Swept
</Badge>
);
if (tx.collected)
return (
<Badge
variant='outline'
className='border-green-500/20 bg-green-500/10 text-green-500'
>
Collected
</Badge>
);
return (
<Badge
variant='outline'
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
>
Pending
</Badge>
);
};
const hasActiveFilters =
type !== 'all' || status !== 'all' || Boolean(search);
const activeFilterDescription = [
type !== 'all' ? `type ${type === 'in' ? 'incoming' : 'outgoing'}` : null,
status !== 'all' ? `status ${status}` : null,
search ? `search "${search}"` : null,
]
.filter(Boolean)
.join(' • ');
return (
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
<div className='space-y-6'>
<PageHeader
title='X-Cashu Transactions'
description='View all incoming and outgoing X-Cashu token transactions.'
actions={
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isRefetching}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
}
/>
<Card className='mb-6'>
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardDescription>
Filter transactions by type, status, or search text
</CardDescription>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='search'>Search</Label>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
id='search'
placeholder='Search by ID, token or request ID...'
className='pl-8'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='type'>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder='Type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Types</SelectItem>
<SelectItem value='in'>Incoming (Payments)</SelectItem>
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='status'>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Statuses</SelectItem>
<SelectItem value='pending'>Pending</SelectItem>
<SelectItem value='collected'>Collected</SelectItem>
<SelectItem value='swept'>Swept</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
<Button
onClick={handleClearFilters}
variant='outline'
className='w-full'
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Transaction History</CardTitle>
{data && (
<Badge variant='secondary'>
{data.transactions.length} entries
</Badge>
)}
</div>
{hasActiveFilters && (
<CardDescription>
Showing transactions filtered by {activeFilterDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className='overflow-hidden'>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`tx-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
) : data?.transactions && data.transactions.length > 0 ? (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>
<div className='flex items-center gap-2'>
{tx.type === 'in' ? (
<ArrowDownLeft className='h-4 w-4 text-green-500' />
) : (
<ArrowUpRight className='h-4 w-4 text-blue-500' />
)}
<span className='capitalize'>{tx.type}</span>
</div>
</TableCell>
<TableCell className='font-mono'>
{tx.amount} {tx.unit}
</TableCell>
<TableCell>{getStatusBadge(tx)}</TableCell>
<TableCell>
{tx.request_id ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[150px] truncate font-mono'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
copyToClipboard(
tx.request_id!,
tx.id + '-req'
)
}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'>
</span>
)}
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
copyToClipboard(tx.token, tx.id + '-token')
}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Receipt className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No transactions found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
</Card>
</div>
</AppPageShell>
);
}

View File

@@ -13,6 +13,7 @@ import {
ServerIcon,
SettingsIcon,
WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react';
import Image from 'next/image';
import { toast } from 'sonner';
@@ -39,8 +40,9 @@ const NAV_ITEMS = [
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
{ title: 'Balances', url: '/balances', icon: WalletIcon },
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/models', icon: DatabaseIcon },
{ title: 'Models', url: '/model', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const;

View File

@@ -9,6 +9,7 @@ import {
ServerIcon,
SettingsIcon,
WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
@@ -45,6 +46,11 @@ const data = {
url: '/balances',
icon: WalletIcon,
},
{
title: 'Transactions',
url: '/transactions',
icon: ArrowRightLeftIcon,
},
{
title: 'Logs',
url: '/logs',
@@ -52,7 +58,7 @@ const data = {
},
{
title: 'Models',
url: '/models',
url: '/model',
icon: DatabaseIcon,
},
{

View File

@@ -23,7 +23,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export default function ModelsPage() {
export function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
undefined
);

View File

@@ -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}
</Card>
{/* Analytics Sharing */}
<Card>
<CardHeader>
<CardTitle>Analytics Sharing</CardTitle>
<CardDescription>
Publish aggregate usage stats to Nostr for external dashboards
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex items-center justify-between space-y-0 py-1'>
<div className='space-y-1'>
<Label htmlFor='enable_analytics_sharing'>
Share analytics to Nostr
</Label>
<p className='text-muted-foreground text-sm'>
When enabled, Routstr periodically publishes aggregate model
usage and revenue stats.
</p>
</div>
<Switch
id='enable_analytics_sharing'
checked={Boolean(settings.enable_analytics_sharing ?? true)}
onCheckedChange={(checked) =>
handleInputChange('enable_analytics_sharing', checked)
}
/>
</div>
</CardContent>
{analyticsSharingChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetAnalyticsSharing}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Other Settings */}
<Card>
<CardHeader>

View File

@@ -23,7 +23,7 @@ const PAGE_META: Record<string, { title: string; description: string }> = {
title: 'System Logs',
description: 'Inspect request and application logs.',
},
'/models': {
'/model': {
title: 'Models',
description: 'Manage model catalog and provider mappings.',
},

View File

@@ -0,0 +1,872 @@
'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,
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',
year: 'numeric',
});
}
function formatAxisTimestamp(
timestamp: string,
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',
day: 'numeric',
});
}
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function convertRevenueMsats(
amountMsats: number,
displayUnit: DisplayUnit,
usdPerSat: number | null
): number {
if (displayUnit === 'msat') {
return amountMsats;
}
const sats = amountMsats / 1000;
if (displayUnit === 'usd') {
return sats * (usdPerSat ?? 0);
}
return sats;
}
function prettifyProvider(provider: string): string {
const normalized = provider.trim().toLowerCase();
const aliasMap: Record<string, string> = {
'x ai': 'x-ai',
xai: 'x-ai',
'z ai': 'z-ai',
zai: 'z-ai',
open_ai: 'openai',
openai: 'openai',
};
if (aliasMap[normalized]) {
return aliasMap[normalized];
}
return normalized.replace(/[_-]+/g, ' ');
}
function detectProviderFromModel(model: string): string {
const value = model.toLowerCase();
if (value.includes('claude')) return 'anthropic';
if (value.includes('gpt') || value.includes('openai')) return 'openai';
if (value.includes('gemini')) return 'google';
if (
value.includes('grok') ||
value.includes('x-ai') ||
value.includes('xai')
) {
return 'x-ai';
}
if (value.includes('deepseek')) return 'deepseek';
if (value.includes('minimax')) return 'minimax';
if (value.includes('kimi') || value.includes('moonshot')) return 'moonshot';
if (value.includes('mistral')) return 'mistral';
if (value.includes('qwen') || value.includes('alibaba')) return 'alibaba';
if (
value.includes('glm') ||
value.includes('z-ai') ||
value.includes('z ai')
) {
return 'z-ai';
}
return 'unknown';
}
function getModelPresentation(model: string): {
displayName: string;
provider: string;
} {
const trimmed = model.trim();
const slashIndex = trimmed.indexOf('/');
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
const provider = prettifyProvider(trimmed.slice(0, slashIndex));
const displayName = trimmed.slice(slashIndex + 1);
return { displayName, provider };
}
return {
displayName: trimmed,
provider: detectProviderFromModel(trimmed),
};
}
export function TopModelsUsageChart({
mix,
displayUnit,
usdPerSat,
}: TopModelsUsageChartProps) {
const [mode, setMode] = useState<ChartMode>('requests');
const [hoveredSeriesKey, setHoveredSeriesKey] = useState<string | null>(null);
const [isChartPointerInside, setIsChartPointerInside] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const compactNumber = useMemo(
() =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 2,
}),
[]
);
const mixTopModels = useMemo(
() => (Array.isArray(mix.top_models) ? mix.top_models : []),
[mix.top_models]
);
const mixMetrics = useMemo(
() => (Array.isArray(mix.metrics) ? mix.metrics : []),
[mix.metrics]
);
const chartModels = useMemo(() => mixTopModels.slice(0, 20), [mixTopModels]);
const leaderboardModels = useMemo(
() => mixTopModels.slice(0, 20),
[mixTopModels]
);
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
return 'sat';
}
return displayUnit;
}, [displayUnit, usdPerSat]);
const revenueUnitLabel =
revenueDisplayUnit === 'usd'
? 'USD'
: revenueDisplayUnit === 'sat'
? 'sats'
: revenueDisplayUnit === 'msat'
? 'msats'
: revenueDisplayUnit;
const series = useMemo(
() =>
chartModels.map((model, index) => ({
requestsKey: `model_req_${index}`,
revenueKey: `model_rev_${index}`,
tokensKey: `model_tok_${index}`,
label: model,
color: getSeriesColor(model, index),
})),
[chartModels]
);
const chartData = useMemo(
() =>
mixMetrics.map((metric) => {
const modelCounts = metric.model_counts ?? {};
const modelRevenue = metric.model_revenue_msats ?? {};
const modelTokens = metric.model_tokens ?? {};
const point: Record<string, number | string> = {
timestamp: metric.timestamp,
total_successful: metric.total_successful,
total_revenue_msats: metric.total_revenue_msats,
total_tokens: metric.total_tokens,
others_requests: metric.others,
others_revenue_msats: metric.others_revenue_msats,
others_tokens: metric.others_tokens,
};
for (const item of series) {
point[item.requestsKey] = modelCounts[item.label] ?? 0;
point[item.revenueKey] = modelRevenue[item.label] ?? 0;
point[item.tokensKey] = modelTokens[item.label] ?? 0;
}
return point;
}),
[mixMetrics, series]
);
const hasMultipleDays = useMemo(() => {
const daySet = new Set(
chartData.map((item) =>
parseBucketDate(String(item.timestamp))?.toDateString()
)
);
return daySet.size > 1;
}, [chartData]);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
for (const item of series) {
config[item.requestsKey] = {
label: item.label,
color: item.color,
};
config[item.revenueKey] = {
label: item.label,
color: item.color,
};
config[item.tokensKey] = {
label: item.label,
color: item.color,
};
}
config.others_requests = {
label: 'Others',
color: '#6b7280',
};
config.others_revenue_msats = {
label: 'Others',
color: '#6b7280',
};
config.others_tokens = {
label: 'Others',
color: '#6b7280',
};
return config;
}, [series]);
useEffect(() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}, [mode]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement === containerRef.current);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleFullscreen = async () => {
if (!containerRef.current) {
return;
}
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen();
} else {
await containerRef.current.requestFullscreen();
}
} catch (error) {
console.error('Failed to toggle top models chart fullscreen', error);
}
};
const formatValue = (rawValue: number): string => {
if (mode === 'requests') {
return compactNumber.format(rawValue);
}
if (mode === 'tokens') {
return compactNumber.format(rawValue);
}
const converted = convertRevenueMsats(
rawValue,
revenueDisplayUnit,
usdPerSat
);
const compact = compactNumber.format(converted);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${revenueUnitLabel}`;
};
const activeSeries = series.map((item) => ({
dataKey:
mode === 'requests'
? item.requestsKey
: mode === 'revenue'
? item.revenueKey
: item.tokensKey,
name: item.label,
color: item.color,
}));
const othersKey = (
mode === 'requests'
? 'others_requests'
: mode === 'revenue'
? 'others_revenue_msats'
: 'others_tokens'
) as 'others_requests' | 'others_revenue_msats' | 'others_tokens';
const activeSeriesKeys = [
...activeSeries.map((item) => item.dataKey),
othersKey,
];
const activeHoverSeriesKey =
hoveredSeriesKey && activeSeriesKeys.includes(hoveredSeriesKey)
? hoveredSeriesKey
: null;
const getSeriesOpacity = (dataKey: string): number =>
activeHoverSeriesKey && activeHoverSeriesKey !== dataKey ? 0.18 : 1;
const formatLeaderboardTotal = (rawValue: number): string => {
if (mode === 'requests') {
return `${compactNumber.format(rawValue)} requests`;
}
if (mode === 'tokens') {
return `${compactNumber.format(rawValue)} tokens`;
}
const converted = convertRevenueMsats(
rawValue,
revenueDisplayUnit,
usdPerSat
);
const compact = compactNumber.format(converted);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${revenueUnitLabel}`;
};
const formatTrendPercent = (value: number): string => {
const abs = Math.abs(value);
const rounded = abs >= 10 ? abs.toFixed(0) : abs.toFixed(1);
return rounded.replace(/\.0$/, '');
};
const leaderboardRows = useMemo<LeaderboardRow[]>(() => {
if (leaderboardModels.length === 0 || mixMetrics.length === 0) {
return [];
}
const windowSize = Math.floor(mixMetrics.length / 2);
const previousMetrics =
windowSize > 0 ? mixMetrics.slice(-windowSize * 2, -windowSize) : [];
const currentMetrics =
windowSize > 0 ? mixMetrics.slice(-windowSize) : mixMetrics;
const rows = leaderboardModels
.map((model) => {
const readMetric = (metric: (typeof mixMetrics)[number]): number =>
mode === 'requests'
? ((metric.model_counts ?? {})[model] ?? 0)
: mode === 'revenue'
? ((metric.model_revenue_msats ?? {})[model] ?? 0)
: ((metric.model_tokens ?? {})[model] ?? 0);
const totalRaw = mixMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const previousRaw = previousMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const currentRaw = currentMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const trendPercent =
previousRaw > 0
? ((currentRaw - previousRaw) / previousRaw) * 100
: null;
let trend: LeaderboardTrend = 'flat';
if (previousRaw <= 0 && currentRaw > 0) {
trend = 'new';
} else if (trendPercent !== null && trendPercent > 0.5) {
trend = 'up';
} else if (trendPercent !== null && trendPercent < -0.5) {
trend = 'down';
}
const presentation = getModelPresentation(model);
const matchingSeries = series.find((item) => item.label === model);
const chartDataKey = matchingSeries
? mode === 'requests'
? matchingSeries.requestsKey
: mode === 'revenue'
? matchingSeries.revenueKey
: matchingSeries.tokensKey
: null;
return {
chartDataKey,
displayName: presentation.displayName,
model,
provider: presentation.provider,
rank: 0,
totalRaw,
trend,
trendPercent,
} satisfies LeaderboardRow;
})
.filter((row) => row.totalRaw > 0)
.sort((a, b) => b.totalRaw - a.totalRaw)
.slice(0, 20)
.map((row, index) => ({
...row,
rank: index + 1,
}));
return rows;
}, [leaderboardModels, mixMetrics, mode, series]);
if (chartData.length === 0) {
return null;
}
return (
<div ref={containerRef}>
<Card
className={cn(isFullscreen && 'h-full rounded-none border-0 ring-0')}
>
<CardHeader className='space-y-3 sm:space-y-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<CardTitle className='text-base sm:text-lg'>
Model Usage
</CardTitle>
<p className='text-muted-foreground mt-1 text-xs sm:text-sm'>
Stacked requests, revenue, or tokens by model (
{mix.interval_minutes}m buckets).
</p>
</div>
<div className='flex items-center gap-2 sm:shrink-0'>
<div className='bg-muted/25 border-border/60 flex items-center gap-1 rounded-full border p-1'>
<Button
type='button'
size='sm'
variant={mode === 'requests' ? 'secondary' : 'ghost'}
onClick={() => setMode('requests')}
className='h-7 rounded-full px-2.5 text-xs'
>
Requests
</Button>
<Button
type='button'
size='sm'
variant={mode === 'revenue' ? 'secondary' : 'ghost'}
onClick={() => setMode('revenue')}
className='h-7 rounded-full px-2.5 text-xs'
>
Revenue
</Button>
<Button
type='button'
size='sm'
variant={mode === 'tokens' ? 'secondary' : 'ghost'}
onClick={() => setMode('tokens')}
className='h-7 rounded-full px-2.5 text-xs'
>
Tokens
</Button>
</div>
<Button
type='button'
variant='outline'
size='icon'
className='hidden h-8 w-8 shrink-0 sm:inline-flex'
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2Icon className='h-4 w-4' />
) : (
<ExpandIcon className='h-4 w-4' />
)}
<span className='sr-only'>
{isFullscreen
? 'Exit fullscreen chart'
: 'Enter fullscreen chart'}
</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent className='space-y-3 sm:space-y-4'>
<ChartContainer
config={chartConfig}
className={cn(
'aspect-auto w-full',
isFullscreen
? 'h-[calc(100vh-220px)] min-h-[340px] sm:h-[calc(100vh-260px)] sm:min-h-[420px]'
: 'h-[260px] sm:h-[340px]'
)}
onMouseLeave={() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}}
>
<BarChart
data={chartData}
onMouseEnter={() => setIsChartPointerInside(true)}
onMouseMove={() => setIsChartPointerInside(true)}
onMouseLeave={() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}}
margin={{
top: 12,
right: isMobile ? 8 : 18,
left: isMobile ? 0 : 8,
bottom: 0,
}}
>
<CartesianGrid vertical={false} className='stroke-muted/30' />
<XAxis
dataKey='timestamp'
tickLine={false}
axisLine={false}
minTickGap={isMobile ? 14 : 24}
tickFormatter={(value) =>
formatAxisTimestamp(
String(value),
hasMultipleDays,
mix.interval_minutes,
mix.hours_back
)
}
/>
<YAxis
tickLine={false}
axisLine={false}
width={isMobile ? 40 : 56}
tickFormatter={(value) =>
formatValue(
typeof value === 'number' ? value : Number(value || 0)
)
}
/>
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (!isChartPointerInside || !active || !payload?.length) {
return null;
}
const rows = payload
.map((entry) => {
const value =
typeof entry.value === 'number'
? entry.value
: Number(entry.value || 0);
return {
color: String(entry.color || '#6b7280'),
dataKey: String(entry.dataKey || ''),
label: String(entry.name || ''),
value,
} satisfies TooltipRow;
})
.filter(
(row) => Number.isFinite(row.value) && row.value > 0
)
.sort((a, b) => b.value - a.value);
const total = rows.reduce((sum, row) => sum + row.value, 0);
if (rows.length === 0) {
return null;
}
return (
<div className='border-border/50 bg-background min-w-[220px] rounded-lg border px-2.5 py-2 text-xs shadow-xl'>
<p className='text-foreground mb-2 text-sm font-medium'>
{formatTooltipTimestamp(
String(label || ''),
mix.interval_minutes,
mix.hours_back
)}
</p>
<div className='space-y-1.5'>
{rows.map((row) => (
<div
key={row.label}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3',
activeHoverSeriesKey &&
row.dataKey !== activeHoverSeriesKey &&
'opacity-45'
)}
>
<span className='text-muted-foreground flex min-w-0 items-center gap-2'>
<span
className='h-2.5 w-1.5 shrink-0 rounded-sm'
style={{ backgroundColor: row.color }}
/>
<span className='truncate'>{row.label}</span>
</span>
<span className='text-foreground font-mono tabular-nums'>
{formatValue(row.value)}
</span>
</div>
))}
</div>
<div className='border-border/60 mt-2 border-t pt-2'>
<div className='grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3'>
<span className='text-muted-foreground'>Total</span>
<span className='text-foreground font-mono font-semibold tabular-nums'>
{formatValue(total)}
</span>
</div>
</div>
</div>
);
}}
/>
{activeSeries.map((item) => (
<Bar
key={item.dataKey}
dataKey={item.dataKey}
name={item.name}
stackId='models'
fill={item.color}
fillOpacity={getSeriesOpacity(item.dataKey)}
maxBarSize={44}
onMouseEnter={() => setHoveredSeriesKey(item.dataKey)}
onMouseLeave={() => setHoveredSeriesKey(null)}
/>
))}
<Bar
dataKey={othersKey}
name='Others'
stackId='models'
fill='#6b7280'
fillOpacity={getSeriesOpacity(othersKey)}
maxBarSize={44}
onMouseEnter={() => setHoveredSeriesKey(othersKey)}
onMouseLeave={() => setHoveredSeriesKey(null)}
/>
</BarChart>
</ChartContainer>
<div className='border-border/60 space-y-2 border-t pt-3 sm:pt-4'>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-xs font-medium'>
Top models
</p>
<p className='text-muted-foreground text-xs'>
Change vs prior period
</p>
</div>
{leaderboardRows.length > 0 ? (
<div className='divide-border/40 divide-y'>
{leaderboardRows.map((row) => {
const rowIsLinked = Boolean(row.chartDataKey);
const rowIsActive =
row.chartDataKey !== null &&
activeHoverSeriesKey === row.chartDataKey;
const rowIsDimmed =
Boolean(activeHoverSeriesKey) &&
row.chartDataKey !== null &&
row.chartDataKey !== activeHoverSeriesKey;
let trendLabel = '0%';
let trendClass = 'text-muted-foreground';
if (row.trend === 'new') {
trendLabel = 'new';
trendClass = 'text-blue-500';
} else if (row.trend === 'up' && row.trendPercent !== null) {
trendLabel = `${formatTrendPercent(row.trendPercent)}%`;
trendClass = 'text-emerald-500';
} else if (
row.trend === 'down' &&
row.trendPercent !== null
) {
trendLabel = `${formatTrendPercent(row.trendPercent)}%`;
trendClass = 'text-red-500';
} else if (row.trendPercent !== null) {
trendLabel = `${formatTrendPercent(row.trendPercent)}%`;
}
return (
<div
key={row.model}
className={cn(
'grid grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-3 rounded-md px-2 py-2 text-xs',
rowIsLinked &&
'hover:bg-muted/25 cursor-pointer transition',
rowIsActive && 'bg-muted/30',
rowIsDimmed && 'opacity-45'
)}
title={row.model}
onMouseEnter={() => {
if (row.chartDataKey) {
setHoveredSeriesKey(row.chartDataKey);
}
}}
onMouseLeave={() => {
if (row.chartDataKey) {
setHoveredSeriesKey(null);
}
}}
>
<span className='text-muted-foreground w-5 text-right font-mono tabular-nums'>
{row.rank}.
</span>
<div className='min-w-0'>
<span className='truncate font-medium'>
{row.displayName}
</span>{' '}
<span className='text-muted-foreground truncate'>
by {row.provider}
</span>
</div>
<span className='text-foreground font-mono tabular-nums'>
{formatLeaderboardTotal(row.totalRaw)}
</span>
<span className={cn('font-medium', trendClass)}>
{trendLabel}
</span>
</div>
);
})}
</div>
) : (
<p className='text-muted-foreground text-xs'>
No model totals available for this range.
</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -35,9 +35,10 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const formatAmount = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const hasTokenStats =
typeof summary.total_tokens === 'number' ||
typeof summary.avg_total_tokens_per_completion === 'number';
const totalTokens = Number(summary.total_tokens ?? 0);
const avgTotalTokensPerCompletion = Number(
summary.avg_total_tokens_per_completion ?? 0
);
const cards = [
{
@@ -52,26 +53,20 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
icon: CheckCircle2,
iconClassName: 'text-emerald-600 dark:text-emerald-300',
},
...(hasTokenStats
? [
{
title: 'Total Tokens',
value: Number(summary.total_tokens ?? 0).toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Avg Tokens/Completion',
value: Number(
summary.avg_total_tokens_per_completion ?? 0
).toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
icon: Activity,
iconClassName: 'text-indigo-600 dark:text-indigo-300',
},
]
: []),
{
title: 'Total Tokens',
value: totalTokens.toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Avg Tokens/Completion',
value: avgTotalTokensPerCompletion.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
icon: Activity,
iconClassName: 'text-indigo-600 dark:text-indigo-300',
},
{
title: 'Revenue',
value: formatAmount(summary.revenue_msats),

View File

@@ -845,6 +845,23 @@ export class AdminService {
);
}
static async getUsageDashboard(
hours: number = 24,
interval: number = 15,
errorLimit: number = 100,
modelLimit: number = 20
): Promise<UsageDashboardResponse> {
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<UsageDashboardResponse>(
`/admin/api/usage/dashboard?${params.toString()}`
);
}
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
return await apiClient.get<UsageSummary>(
`/admin/api/usage/summary?hours=${hours}`
@@ -869,6 +886,23 @@ export class AdminService {
);
}
static async getTransactions(
type?: string,
status?: string,
search?: string,
limit: number = 100
): Promise<TransactionsResponse> {
const params = new URLSearchParams();
if (type) params.append('type', type);
if (status) params.append('status', status);
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<TransactionsResponse>(
`/admin/api/transactions?${params.toString()}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
@@ -952,9 +986,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 +997,20 @@ export interface UsageMetrics {
interval_minutes: number;
hours_back: number;
total_buckets: number;
totals?: Partial<Record<string, number>>;
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 +1025,12 @@ export interface UsageSummary {
unique_models_count: number;
unique_models: string[];
error_types: Record<string, number>;
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 +1040,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 +1073,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<string, number>;
model_revenue_msats: Record<string, number>;
model_tokens: Record<string, number>;
}
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;
@@ -1042,3 +1122,21 @@ export interface LogResponse {
search: string | null;
limit: number;
}
export interface Transaction {
id: string;
token: string;
amount: number;
unit: string;
mint_url: string;
type: 'in' | 'out';
request_id?: string;
created_at: number;
collected: boolean;
swept: boolean;
}
export interface TransactionsResponse {
transactions: Transaction[];
total: number;
}

2
uv.lock generated
View File

@@ -1878,7 +1878,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.3.0"
version = "0.4.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },