diff --git a/.dockerignore b/.dockerignore index a1831c97..4018f08a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,6 @@ compose.testing.yml .todo .github .vscode -.DS_Store \ No newline at end of file +.DS_Store +**/node_modules +ui/.next diff --git a/README.md b/README.md index 2d482dc1..a0834551 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Once built, the UI is automatically served by the FastAPI backend: - **Dashboard**: `http://localhost:8000/` - **Login**: `http://localhost:8000/login` -- **Models Management**: `http://localhost:8000/model +- **Models Management**: `http://localhost:8000/model` - **Providers Management**: `http://localhost:8000/providers` - **Settings**: `http://localhost:8000/settings` diff --git a/pyproject.toml b/pyproject.toml index 10918b41..a25cbbaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.2.0c" +version = "0.2.1" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" @@ -20,6 +20,7 @@ dependencies = [ "nostr>=0.0.2", "mdurl==0.1.2", "pillow>=10", + "openai>=1.98.0", ] [dependency-groups] diff --git a/routstr/algorithm.py b/routstr/algorithm.py index efd2a566..a1b6babd 100644 --- a/routstr/algorithm.py +++ b/routstr/algorithm.py @@ -153,9 +153,9 @@ def should_prefer_model( # Log provider changes when candidate wins if should_replace: candidate_provider_name = getattr( - candidate_provider, "upstream_name", "unknown" + candidate_provider, "provider_type", "unknown" ) - current_provider_name = getattr(current_provider, "upstream_name", "unknown") + current_provider_name = getattr(current_provider, "provider_type", "unknown") logger.debug( f"Model selection for alias '{alias}': choosing {candidate_provider_name} " f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} " @@ -254,7 +254,12 @@ def create_model_mappings( # Add to unique models base_id = get_base_model_id(model_to_use.id) if not is_openrouter or base_id not in unique_models: - unique_model = model_to_use.copy(update={"id": base_id}) + unique_model = model_to_use.copy( + update={ + "id": base_id, + "upstream_provider_id": upstream.provider_type, + } + ) unique_models[base_id] = unique_model # Get all aliases for this model diff --git a/routstr/auth.py b/routstr/auth.py index b1e16987..5b5debdc 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -9,7 +9,7 @@ from sqlmodel import col, update from .core import get_logger from .core.db import ApiKey, AsyncSession from .core.settings import settings -from .payment.cost_caculation import ( +from .payment.cost_calculation import ( CostData, CostDataError, MaxCostData, @@ -337,7 +337,7 @@ async def pay_for_request( stmt = ( update(ApiKey) .where(col(ApiKey.hashed_key) == key.hashed_key) - .where(col(ApiKey.balance) >= cost_per_request) + .where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request) .values( reserved_balance=col(ApiKey.reserved_balance) + cost_per_request, total_requests=col(ApiKey.total_requests) + 1, diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c11cbe50..29ac8319 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -3,7 +3,7 @@ import secrets from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from pydantic import BaseModel from sqlmodel import select @@ -18,6 +18,7 @@ from ..wallet import ( slow_filter_spend_proofs, ) from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session +from .log_manager import log_manager from .logging import get_logger from .settings import SettingsService, settings @@ -3045,3 +3046,122 @@ h1 { color: #333; } .no-logs { text-align: center; color: #666; padding: 40px; } .request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; } """ + + +@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)]) +async def get_usage_metrics( + request: Request, + interval: int = Query( + default=15, ge=1, le=1440, description="Time interval in minutes" + ), + hours: int = Query( + default=24, ge=1, le=168, 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/summary", dependencies=[Depends(require_admin_api)]) +async def get_usage_summary( + request: Request, + hours: int = Query( + default=24, ge=1, le=168, description="Hours of history to analyze" + ), +) -> dict: + """Get summary statistics for the specified time period.""" + return log_manager.get_usage_summary(hours=hours) + + +@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, le=168, description="Hours of history to analyze" + ), + limit: int = Query( + default=100, ge=1, le=1000, description="Maximum number of errors to return" + ), +) -> dict: + """Get detailed error information.""" + return log_manager.get_error_details(hours=hours, limit=limit) + + +@admin_router.get( + "/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)] +) +async def get_revenue_by_model( + request: Request, + hours: int = Query( + default=24, ge=1, le=168, description="Hours of history to analyze" + ), + limit: int = Query( + default=20, ge=1, le=100, description="Maximum number of models to return" + ), +) -> dict: + """ + Get revenue breakdown by model. + """ + return log_manager.get_revenue_by_model(hours=hours, limit=limit) + + +@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)]) +async def get_logs_api( + request: Request, + date: str | None = None, + level: str | None = None, + request_id: str | None = None, + search: str | None = None, + limit: int = 100, +) -> dict[str, object]: + """ + Get filtered log entries. + + Args: + date: Filter by specific date (YYYY-MM-DD) + level: Filter by log level + request_id: Filter by request ID + search: Search text in message and name fields (case-insensitive) + limit: Maximum number of entries to return + + Returns: + Dict containing logs and filter metadata + """ + log_entries = log_manager.search_logs( + date=date, + level=level, + request_id=request_id, + search_text=search, + limit=limit, + ) + + return { + "logs": log_entries, + "total": len(log_entries), + "date": date, + "level": level, + "request_id": request_id, + "search": search, + "limit": limit, + } + + +@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)]) +async def get_log_dates_api(request: Request) -> dict[str, object]: + logs_dir = Path("logs") + dates = [] + + if logs_dir.exists(): + log_files = sorted( + logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True + ) + + for log_file in log_files[:30]: + try: + filename = log_file.name + date_str = filename.replace("app_", "").replace(".log", "") + dates.append(date_str) + except Exception: + continue + + return {"dates": dates} diff --git a/routstr/core/log_manager.py b/routstr/core/log_manager.py new file mode 100644 index 00000000..5052f3d2 --- /dev/null +++ b/routstr/core/log_manager.py @@ -0,0 +1,469 @@ +import json +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterator + +from .logging import get_logger + +logger = get_logger(__name__) + + +class LogManager: + def __init__(self, logs_dir: Path = Path("logs")): + self.logs_dir = logs_dir + + def _yield_log_entries( + self, + hours_back: int | None = None, + specific_date: str | None = None, + reverse_files: bool = False, + max_files: int | None = None, + ) -> Iterator[dict[str, Any]]: + """ + Yields log entries from files. + + Args: + hours_back: specific number of hours to look back. + 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). + """ + if not self.logs_dir.exists(): + return + + log_files = [] + cutoff_date = None + + if specific_date: + log_file = self.logs_dir / f"app_{specific_date}.log" + if log_file.exists(): + log_files.append(log_file) + else: + log_files = sorted(self.logs_dir.glob("app_*.log")) + if reverse_files: + log_files.reverse() + + # If we only care about hours back, we can optimize file selection + if hours_back is not None: + cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back) + filtered_files = [] + for log_path in log_files: + try: + file_date_str = log_path.stem.split("_")[1] + file_date = datetime.strptime( + file_date_str, "%Y-%m-%d" + ).replace(tzinfo=timezone.utc) + # Include file if it's from the same day or after the cutoff day + if file_date >= cutoff_date.replace( + hour=0, minute=0, second=0, microsecond=0 + ): + filtered_files.append(log_path) + except Exception: + continue + log_files = filtered_files + + if max_files is not None and len(log_files) > max_files: + log_files = log_files[:max_files] + + for log_file in log_files: + try: + with open(log_file, "r") as f: + # For reverse search, we might want to read lines in reverse? + # But usually logs are append-only. + # If reverse_files is True, we iterate files newest to oldest. + # But lines within file are still oldest to newest unless we reverse them. + lines = f.readlines() + if reverse_files: + lines.reverse() + + for line in lines: + try: + entry = json.loads(line.strip()) + + if cutoff_date: + timestamp_str = entry.get("asctime", "") + if not timestamp_str: + continue + log_time = datetime.strptime( + timestamp_str, "%Y-%m-%d %H:%M:%S" + ) + log_time = log_time.replace(tzinfo=timezone.utc) + if log_time < cutoff_date: + continue + + yield entry + except json.JSONDecodeError: + continue + except Exception as e: + logger.error(f"Error processing log file {log_file}: {e}") + continue + + def search_logs( + self, + date: str | None = None, + level: str | None = None, + request_id: str | None = None, + search_text: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """ + Search through log files and return matching entries. + """ + log_entries: list[dict[str, Any]] = [] + + # Use reverse=True to get newest logs first by default + # If date is specified, we only look at that file + + search_text_lower = search_text.lower() if search_text else None + + # We iterate efficiently + iterator = self._yield_log_entries( + specific_date=date, + reverse_files=True if not date else False, + max_files=7 if not date else None, + ) + + # If we are searching globally (no date), we might want to limit how far back we go? + # PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files) + # My _yield_log_entries with reverse_files=True does all files. + # Let's rely on limit to stop us. + + # Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield? + # For now, let's just iterate. + + for log_data in iterator: + if not self._matches_filters( + log_data, level, request_id, search_text_lower + ): + continue + + log_entries.append(log_data) + + if len(log_entries) >= limit: + break + + # Sort by time descending (newest first) + log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True) + return log_entries + + def _matches_filters( + self, + log_data: dict[str, Any], + level: str | None, + request_id: str | None, + search_text_lower: str | None, + ) -> bool: + if level and log_data.get("levelname", "").upper() != level.upper(): + return False + + if request_id and log_data.get("request_id") != request_id: + return False + + if search_text_lower: + message = str(log_data.get("message", "")).lower() + name = str(log_data.get("name", "")).lower() + pathname = str(log_data.get("pathname", "")).lower() + + if ( + search_text_lower not in message + and search_text_lower not in name + and search_text_lower not in pathname + ): + return False + + return True + + def get_usage_summary(self, hours: int = 24) -> dict: + entries = list(self._yield_log_entries(hours_back=hours)) + return self._calculate_summary_stats(entries) + + def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict: + entries = list(self._yield_log_entries(hours_back=hours)) + return self._aggregate_metrics_by_time(entries, interval, hours) + + def get_error_details(self, hours: int = 24, limit: int = 100) -> dict: + errors: list[dict] = [] + # Iterate newest to oldest for errors? + # yield_log_entries sorts files by name (date) ascending by default. + # usage stats logic usually expects ascending time for aggregation (though dictionaries don't care). + # For error details "last N errors", we probably want newest first. + + # Using list() loads everything into memory, which is what PR 229 did. + # For optimization, we could use reverse iterator. + + # Let's just stick to PR 229 logic which filters 'ERROR' level. + + entries = self._yield_log_entries(hours_back=hours) # oldest to newest + + for entry in entries: + if 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", ""), + } + ) + + # Sort reverse time + errors.sort(key=lambda x: x["timestamp"], reverse=True) + return {"errors": errors[:limit], "total_count": len(errors)} + + def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict: + entries = list(self._yield_log_entries(hours_back=hours)) + + model_stats: dict[str, dict[str, int | float]] = defaultdict( + lambda: { + "revenue_msats": 0, + "refunds_msats": 0, + "requests": 0, + "successful": 0, + "failed": 0, + } + ) + + for entry in entries: + try: + model = entry.get("model", "unknown") + if not isinstance(model, str): + model = "unknown" + + message = entry.get("message", "").lower() + + if "received proxy request" in message: + model_stats[model]["requests"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + model_stats[model]["successful"] += 1 + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + model_stats[model]["revenue_msats"] += actual_cost + + if "revert payment" in message or "upstream request failed" in message: + 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), + } + + def _calculate_summary_stats(self, entries: list[dict]) -> dict: + 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, + } + + for entry in entries: + try: + stats["total_entries"] += 1 + + message = entry.get("message", "").lower() + level = entry.get("levelname", "").upper() + + if level == "ERROR": + stats["total_errors"] += 1 + if "error_type" in entry: + stats["error_types"][str(entry["error_type"])] += 1 + elif level == "WARNING": + stats["total_warnings"] += 1 + + if "received proxy request" in message: + stats["total_requests"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + stats["successful_chat_completions"] += 1 + + if "upstream request failed" in message or "revert payment" in message: + stats["failed_requests"] += 1 + + if "payment processed successfully" in message: + stats["payment_processed"] += 1 + + if "upstream" in message and level == "ERROR": + stats["upstream_errors"] += 1 + + if "model" in entry: + model = entry["model"] + if isinstance(model, str) and model != "unknown": + stats["unique_models"].add(model) + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + stats["revenue_msats"] += float(actual_cost) + + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + stats["refunds_msats"] += float(max_cost) + + except Exception: + continue + + revenue_sats = stats["revenue_msats"] / 1000 + refunds_sats = stats["refunds_msats"] / 1000 + net_revenue_sats = revenue_sats - refunds_sats + + total_requests = stats["total_requests"] + successful = stats["successful_chat_completions"] + + return { + "total_entries": stats["total_entries"], + "total_requests": total_requests, + "successful_chat_completions": successful, + "failed_requests": stats["failed_requests"], + "total_errors": stats["total_errors"], + "total_warnings": stats["total_warnings"], + "payment_processed": stats["payment_processed"], + "upstream_errors": stats["upstream_errors"], + "unique_models_count": len(stats["unique_models"]), + "unique_models": sorted(list(stats["unique_models"])), + "error_types": dict(stats["error_types"]), + "success_rate": (successful / total_requests * 100) + if total_requests > 0 + else 0, + "revenue_msats": stats["revenue_msats"], + "refunds_msats": stats["refunds_msats"], + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"], + "net_revenue_sats": net_revenue_sats, + "avg_revenue_per_request_msats": ( + stats["revenue_msats"] / successful if successful > 0 else 0 + ), + "refund_rate": ( + (stats["failed_requests"] / total_requests * 100) + if total_requests > 0 + else 0 + ), + } + + def _aggregate_metrics_by_time( + self, entries: list[dict], interval_minutes: int, hours_back: int + ) -> dict: + time_buckets: dict[str, dict[str, Any]] = defaultdict( + lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0} + ) + + for entry in entries: + try: + timestamp_str = entry.get("asctime", "") + if not timestamp_str: + continue + + log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") + log_time = log_time.replace(tzinfo=timezone.utc) + + # Round down to nearest interval + minutes = log_time.minute + rounded_minutes = (minutes // interval_minutes) * interval_minutes + bucket_time = log_time.replace( + minute=rounded_minutes, second=0, microsecond=0 + ) + bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S") + + bucket = time_buckets[bucket_key] + + message = entry.get("message", "").lower() + level = entry.get("levelname", "").upper() + + if "received proxy request" in message: + bucket["requests"] += 1 + + if level == "ERROR": + bucket["errors"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + bucket["revenue_msats"] += float(actual_cost) + except Exception: + continue + + result = [] + for bucket_key in sorted(time_buckets.keys()): + result.append({"timestamp": bucket_key, **time_buckets[bucket_key]}) + + return { + "metrics": result, + "interval_minutes": interval_minutes, + "hours_back": hours_back, + "total_buckets": len(result), + } + + +log_manager = LogManager() diff --git a/routstr/core/logging.py b/routstr/core/logging.py index d682b944..65b6a5c0 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -1,3 +1,40 @@ +""" +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). +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 + - 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 + +3. "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 + - 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 + - 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() +""" + import logging.config import logging.handlers import os @@ -155,21 +192,24 @@ class SecurityFilter(logging.Filter): """Filter out sensitive information from log records.""" try: message = record.getMessage() + standalone_patterns = [ + r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives) + r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens + r"nsec[a-z0-9]+", # Nostr Public / Private Key + ] + for pattern in standalone_patterns: + message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE) for key in self.SENSITIVE_KEYS: if key in message.lower(): - patterns = [ - rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value - rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value' - r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token - r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens + key_patterns = [ + rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces) + rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces) ] - - for pattern in patterns: + for pattern in key_patterns: message = re.sub( pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE ) - record.msg = message record.args = () diff --git a/routstr/core/main.py b/routstr/core/main.py index 81212a01..06b86d65 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -34,9 +34,9 @@ setup_logging() logger = get_logger(__name__) if os.getenv("VERSION_SUFFIX") is not None: - __version__ = f"0.2.0c-{os.getenv('VERSION_SUFFIX')}" + __version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}" else: - __version__ = "0.2.0c" + __version__ = "0.2.1" @asynccontextmanager @@ -80,8 +80,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: from ..proxy import get_upstreams from ..upstream.helpers import refresh_upstreams_models_periodically - await _update_prices() - await initialize_upstreams() + _update_prices_task = asyncio.create_task(_update_prices()) + _initialize_upstreams_task = asyncio.create_task(initialize_upstreams()) btc_price_task = asyncio.create_task(update_prices_periodically()) pricing_task = asyncio.create_task(update_sats_pricing()) @@ -92,8 +92,15 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically()) model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically()) payout_task = asyncio.create_task(periodic_payout()) - nip91_task = asyncio.create_task(announce_provider()) - providers_task = asyncio.create_task(providers_cache_refresher()) + if global_settings.nsec: + nip91_task = asyncio.create_task(announce_provider()) + if global_settings.providers_refresh_interval_seconds > 0: + providers_task = asyncio.create_task(providers_cache_refresher()) + + # ensure both setup tasks complete + await asyncio.gather( + _update_prices_task, _initialize_upstreams_task, return_exceptions=True + ) yield @@ -182,7 +189,6 @@ async def info() -> dict: "mints": global_settings.cashu_mints, "http_url": global_settings.http_url, "onion_url": global_settings.onion_url, - "models": [], # kept for back-compat; prefer /v1/models } @@ -264,6 +270,33 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir(): async def redirect_transactions_index_txt() -> RedirectResponse: return RedirectResponse("/transactions") + @app.get("/balances", include_in_schema=False) + async def serve_balances_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "balances" / "index.html") + + # Add explicit route for /balances/index.txt to redirect to /balances + @app.get("/balances/index.txt", include_in_schema=False) + async def redirect_balances_index_txt() -> RedirectResponse: + return RedirectResponse("/balances") + + @app.get("/logs", include_in_schema=False) + async def serve_logs_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "logs" / "index.html") + + # Add explicit route for /logs/index.txt to redirect to /logs + @app.get("/logs/index.txt", include_in_schema=False) + async def redirect_logs_index_txt() -> RedirectResponse: + return RedirectResponse("/logs") + + @app.get("/usage", include_in_schema=False) + async def serve_usage_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "usage" / "index.html") + + # Add explicit route for /usage/index.txt to redirect to /usage + @app.get("/usage/index.txt", include_in_schema=False) + async def redirect_usage_index_txt() -> RedirectResponse: + return RedirectResponse("/usage") + @app.get("/unauthorized", include_in_schema=False) async def serve_unauthorized_ui() -> FileResponse: return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html") diff --git a/routstr/core/settings.py b/routstr/core/settings.py index cd59758c..302df24d 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -114,7 +114,7 @@ def resolve_bootstrap() -> Settings: ) except Exception: pass - # Map COST_PER_1K_* -> CUSTOM_PER_1K_* + # Map COST_PER_1K_* -> FIXED_PER_1K_* if ( "COST_PER_1K_INPUT_TOKENS" in os.environ and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ diff --git a/routstr/payment/__init__.py b/routstr/payment/__init__.py index 55f5a854..0ca1ed03 100644 --- a/routstr/payment/__init__.py +++ b/routstr/payment/__init__.py @@ -1,4 +1,4 @@ -from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost __all__ = [ "CostData", diff --git a/routstr/payment/cost_caculation.py b/routstr/payment/cost_calculation.py similarity index 100% rename from routstr/payment/cost_caculation.py rename to routstr/payment/cost_calculation.py diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 1f7daead..0e44d5da 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -2,6 +2,7 @@ import asyncio import json import random from pathlib import Path +from typing import Final from urllib.request import urlopen import httpx @@ -19,6 +20,15 @@ logger = get_logger(__name__) models_router = APIRouter() +DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = { + "openrouter/auto", + "openrouter/bodybuilder", + "google/gemini-2.5-pro-exp-03-25", + "opengvlab/internvl3-78b", + "openrouter/sonoma-dusk-alpha", + "openrouter/sonoma-sky-alpha", +} + class Architecture(BaseModel): modality: str @@ -31,10 +41,10 @@ class Architecture(BaseModel): class Pricing(BaseModel): prompt: float completion: float - request: float - image: float - web_search: float - internal_reasoning: float + request: float = 0.0 + image: float = 0.0 + web_search: float = 0.0 + internal_reasoning: float = 0.0 max_prompt_cost: float = 0.0 # in sats not msats max_completion_cost: float = 0.0 # in sats not msats max_cost: float = 0.0 # in sats not msats @@ -58,7 +68,7 @@ class Model(BaseModel): per_request_limits: dict | None = None top_provider: TopProvider | None = None enabled: bool = True - upstream_provider_id: int | None = None + upstream_provider_id: int | str | None = None canonical_slug: str | None = None alias_ids: list[str] | None = None @@ -112,11 +122,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: if ( "(free)" in model.get("name", "") - or model_id == "openrouter/auto" - or model_id == "google/gemini-2.5-pro-exp-03-25" - or model_id == "opengvlab/internvl3-78b" - or model_id == "openrouter/sonoma-dusk-alpha" - or model_id == "openrouter/sonoma-sky-alpha" + or model_id in DEFAULT_EXCLUDED_MODEL_IDS ): continue @@ -156,11 +162,7 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis if ( "(free)" in model.get("name", "") - or model_id == "openrouter/auto" - or model_id == "google/gemini-2.5-pro-exp-03-25" - or model_id == "opengvlab/internvl3-78b" - or model_id == "openrouter/sonoma-dusk-alpha" - or model_id == "openrouter/sonoma-sky-alpha" + or model_id in DEFAULT_EXCLUDED_MODEL_IDS ): continue diff --git a/routstr/upstream/__init__.py b/routstr/upstream/__init__.py index 074cd14d..13c26791 100644 --- a/routstr/upstream/__init__.py +++ b/routstr/upstream/__init__.py @@ -2,6 +2,7 @@ from .anthropic import AnthropicUpstreamProvider from .azure import AzureUpstreamProvider from .base import BaseUpstreamProvider from .fireworks import FireworksUpstreamProvider +from .gemini import GeminiUpstreamProvider from .generic import GenericUpstreamProvider from .groq import GroqUpstreamProvider from .ollama import OllamaUpstreamProvider @@ -15,6 +16,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [ AnthropicUpstreamProvider, AzureUpstreamProvider, FireworksUpstreamProvider, + GeminiUpstreamProvider, GenericUpstreamProvider, GroqUpstreamProvider, OllamaUpstreamProvider, diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 6b908b8e..64eb6a4c 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -19,7 +19,7 @@ from ..core.db import ApiKey, AsyncSession, create_session if TYPE_CHECKING: from ..core.db import UpstreamProviderRow -from ..payment.cost_caculation import ( +from ..payment.cost_calculation import ( CostData, CostDataError, MaxCostData, @@ -151,6 +151,13 @@ class BaseUpstreamProvider: if headers.pop(auth_header, None) is not None: removed_headers.append(auth_header) + for header in ["authorization", "accept-encoding"]: + if headers.pop(header, None) is not None: + removed_headers.append(f"{header} (replaced with routstr-safe version)") + + # Explicitly define the list of supported compression encodings + headers["accept-encoding"] = "gzip, deflate, br, identity" + logger.debug( "Headers prepared for upstream", extra={ @@ -468,11 +475,12 @@ class BaseUpstreamProvider: ) usage_finalized = True logger.info( - "Token adjustment completed for streaming", + "Payment adjustment completed for streaming", extra={ "key_hash": key.hashed_key[:8] + "...", "cost_data": cost_data, + "model": last_model_seen, "balance_after_adjustment": fresh_key.balance, }, ) @@ -519,10 +527,15 @@ class BaseUpstreamProvider: await finalize_without_usage() raise + # Remove inaccurate encoding headers from upstream response + response_headers = dict(response.headers) + response_headers.pop("content-encoding", None) + response_headers.pop("content-length", None) + return StreamingResponse( stream_with_cost(max_cost_for_model), status_code=response.status_code, - headers=dict(response.headers), + headers=response_headers, ) async def handle_non_streaming_chat_completion( @@ -571,7 +584,7 @@ class BaseUpstreamProvider: response_json["cost"] = cost_data logger.info( - "Token adjustment completed for non-streaming", + "Payment adjustment completed for non-streaming", extra={ "key_hash": key.hashed_key[:8] + "...", "cost_data": cost_data, @@ -1891,14 +1904,15 @@ class BaseUpstreamProvider: f"Provider {self.provider_type} does not support top-up" ) - async def get_balance(self) -> dict[str, object]: + async def get_balance(self) -> float | None: """Get the current account balance from the provider. Returns: - Dict with balance information + Float representing the balance amount, or None if not supported/available. + Typically in USD or the provider's credit unit. Raises: - NotImplementedError: If provider does not support balance checking + NotImplementedError: If provider does not support balance checking (default behavior) """ raise NotImplementedError( f"Provider {self.provider_type} does not support balance checking" diff --git a/routstr/upstream/clients/__init__.py b/routstr/upstream/clients/__init__.py new file mode 100644 index 00000000..2443feaa --- /dev/null +++ b/routstr/upstream/clients/__init__.py @@ -0,0 +1,3 @@ +from .gemini import GeminiClient + +__all__ = ["GeminiClient"] diff --git a/routstr/upstream/clients/base.py b/routstr/upstream/clients/base.py new file mode 100644 index 00000000..17fab428 --- /dev/null +++ b/routstr/upstream/clients/base.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, AsyncGenerator + + +class BaseAPIClient(ABC): + """Base class for AI provider API clients.""" + + def __init__(self, api_key: str, base_url: str | None = None): + self.api_key = api_key + self.base_url = base_url + + @abstractmethod + async def generate_content( + self, + model: str, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Generate content non-streaming.""" + pass + + @abstractmethod + def generate_content_stream( + self, + model: str, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + **kwargs: Any, + ) -> AsyncGenerator[dict[str, Any], None]: + pass + + @abstractmethod + async def list_models(self) -> list[dict[str, Any]]: + """List available models.""" + pass diff --git a/routstr/upstream/clients/gemini.py b/routstr/upstream/clients/gemini.py new file mode 100644 index 00000000..893dd647 --- /dev/null +++ b/routstr/upstream/clients/gemini.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Any, AsyncGenerator + +from openai import AsyncOpenAI + +from .base import BaseAPIClient + + +class GeminiClient(BaseAPIClient): + """Gemini API client using OpenAI compatibility layer.""" + + def __init__(self, api_key: str, base_url: str | None = None): + super().__init__(api_key, base_url) + self.client = AsyncOpenAI( + api_key=api_key, + base_url=base_url + or "https://generativelanguage.googleapis.com/v1beta/openai/", + ) + + async def generate_content( + self, + model: str, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + from openai import NOT_GIVEN + + response = await self.client.chat.completions.create( + model=model, + messages=messages, # type: ignore + temperature=temperature if temperature is not None else NOT_GIVEN, + max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN, + top_p=kwargs.get("top_p", NOT_GIVEN), + ) + return response.model_dump() + + async def generate_content_stream( + self, + model: str, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + **kwargs: Any, + ) -> AsyncGenerator[dict[str, Any], None]: + from openai import NOT_GIVEN + + usage_callback = kwargs.get("usage_callback") + completion_callback = kwargs.get("completion_callback") + + stream = await self.client.chat.completions.create( + model=model, + messages=messages, # type: ignore + stream=True, + stream_options={"include_usage": True}, + temperature=temperature if temperature is not None else NOT_GIVEN, + max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN, + top_p=kwargs.get("top_p", NOT_GIVEN), + ) + + final_usage = None + + async for chunk in stream: + chunk_data = chunk.model_dump() + + if chunk.usage: + final_usage = chunk.usage.model_dump() + if usage_callback: + usage_callback(final_usage) + + yield chunk_data + + if completion_callback: + await completion_callback(model, final_usage) + + async def list_models(self) -> list[dict[str, Any]]: + """List available Gemini models.""" + try: + response = await self.client.models.list() + return [model.model_dump() for model in response.data] + except Exception as e: + from ...core.logging import get_logger + + logger = get_logger(__name__) + logger.error(f"Failed to list Gemini models: {e}") + return [] diff --git a/routstr/upstream/gemini.py b/routstr/upstream/gemini.py new file mode 100644 index 00000000..79a109c4 --- /dev/null +++ b/routstr/upstream/gemini.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any + +from fastapi import Request +from fastapi.responses import Response, StreamingResponse + +from .base import BaseUpstreamProvider +from .clients.gemini import GeminiClient + +if TYPE_CHECKING: + from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow + from ..payment.models import Model + +from ..core.logging import get_logger + +logger = get_logger(__name__) + + +class GeminiUpstreamProvider(BaseUpstreamProvider): + provider_type = "gemini" + default_base_url = "https://generativelanguage.googleapis.com/v1beta" + platform_url = "https://aistudio.google.com/app/apikey" + + def __init__( + self, + base_url: str = "https://generativelanguage.googleapis.com/v1beta", + api_key: str = "", + provider_fee: float = 1.01, + ): + super().__init__( + api_key=api_key, + provider_fee=provider_fee, + base_url=base_url, + ) + self._client: GeminiClient | None = None + + @property + def client(self) -> GeminiClient: + """Get or create the Gemini API client.""" + if self._client is None: + self._client = GeminiClient(api_key=self.api_key) + return self._client + + @classmethod + def from_db_row( + cls, provider_row: "UpstreamProviderRow" + ) -> "GeminiUpstreamProvider": + return cls( + base_url=provider_row.base_url, + api_key=provider_row.api_key, + provider_fee=provider_row.provider_fee, + ) + + @classmethod + def get_provider_metadata(cls) -> dict[str, object]: + return { + "id": cls.provider_type, + "name": "Google Gemini", + "default_base_url": cls.default_base_url, + "fixed_base_url": True, + "platform_url": cls.platform_url, + } + + def transform_model_name(self, model_id: str) -> str: + return model_id.removeprefix("gemini/") + + async def forward_request( + self, + request: Request, + path: str, + headers: dict, + request_body: bytes | None, + key: ApiKey, + max_cost_for_model: int, + session: AsyncSession, + model_obj: Model, + ) -> Response | StreamingResponse: + # Remove provider prefix from model ID for Gemini API + if "/" in model_obj.id: + model_obj.id = model_obj.id.split("/", 1)[1] + + if not path.startswith("chat/completions"): + return await super().forward_request( + request, + path, + headers, + request_body, + key, + max_cost_for_model, + session, + model_obj, + ) + + if not request_body: + return await super().forward_request( + request, + path, + headers, + request_body, + key, + max_cost_for_model, + session, + model_obj, + ) + + try: + openai_data = json.loads(request_body) + messages = openai_data.get("messages", []) + temperature = openai_data.get("temperature") + max_tokens = openai_data.get("max_tokens") + top_p = openai_data.get("top_p") + is_streaming = openai_data.get("stream", False) + + logger.info( + "Processing Gemini request with client abstraction", + extra={ + "model": model_obj.id, + "is_streaming": is_streaming, + "message_count": len(messages), + "key_hash": key.hashed_key[:8] + "...", + }, + ) + + if is_streaming: + final_usage_data: dict | None = None + + def usage_callback(usage_data: dict[str, Any]) -> None: + """Callback to capture usage data during streaming""" + nonlocal final_usage_data + final_usage_data = usage_data + + async def completion_callback( + model: str, usage_data: dict[str, Any] | None + ) -> None: + """Callback to handle payment when streaming completes""" + nonlocal final_usage_data + if usage_data: + final_usage_data = usage_data + + payment_data = { + "model": model, + "usage": final_usage_data, + } + + from ..auth import adjust_payment_for_tokens + from ..core.db import create_session + + async with create_session() as new_session: + fresh_key = await new_session.get(key.__class__, key.hashed_key) + if fresh_key: + try: + cost_data = await adjust_payment_for_tokens( + fresh_key, + payment_data, + new_session, + max_cost_for_model, + ) + + logger.info( + "Gemini streaming payment finalized", + extra={ + "cost_data": cost_data, + "usage_data": final_usage_data, + "key_hash": key.hashed_key[:8] + "...", + }, + ) + except Exception as cost_error: + logger.error( + "Error finalizing Gemini streaming payment", + extra={ + "error": str(cost_error), + "key_hash": key.hashed_key[:8] + "...", + }, + ) + + response_generator = self.client.generate_content_stream( + model=model_obj.id, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + top_p=top_p, + usage_callback=usage_callback, + completion_callback=completion_callback, + ) + + async def stream_with_cost() -> AsyncGenerator[bytes, None]: + try: + async for chunk in response_generator: + sse_data = f"data: {json.dumps(chunk)}\n\n" + yield sse_data.encode() + + except Exception as e: + logger.error( + "Error in Gemini streaming response", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "key_hash": key.hashed_key[:8] + "...", + }, + ) + raise + + return StreamingResponse( + stream_with_cost(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + else: + openai_format_response = await self.client.generate_content( + model=model_obj.id, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + top_p=top_p, + ) + + from ..auth import adjust_payment_for_tokens + + cost_data = await adjust_payment_for_tokens( + key, openai_format_response, session, max_cost_for_model + ) + openai_format_response["cost"] = cost_data + + logger.info( + "Gemini non-streaming payment completed", + extra={ + "cost_data": cost_data, + "model": model_obj.id, + "key_hash": key.hashed_key[:8] + "...", + }, + ) + + return Response( + content=json.dumps(openai_format_response), + media_type="application/json", + headers={"Cache-Control": "no-cache"}, + ) + + except Exception as e: + logger.error( + "Error in Gemini forward_request", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "path": path, + "key_hash": key.hashed_key[:8] + "...", + }, + ) + return await super().forward_request( + request, + path, + headers, + request_body, + key, + max_cost_for_model, + session, + model_obj, + ) + + async def _fetch_provider_models(self) -> dict: + """Fetch models from Gemini API.""" + try: + models_data = await self.client.list_models() + + for model in models_data: + if "id" in model and model["id"].startswith("models/"): + model["id"] = model["id"].removeprefix("models/") + + return {"data": models_data} + except Exception as e: + logger.error( + f"Failed to fetch models from Gemini API: {e}", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "base_url": self.base_url, + }, + ) + return {"data": []} diff --git a/routstr/upstream/openrouter.py b/routstr/upstream/openrouter.py index cc0e2908..3ee58125 100644 --- a/routstr/upstream/openrouter.py +++ b/routstr/upstream/openrouter.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING +import httpx + from ..payment.models import Model, async_fetch_openrouter_models from .base import BaseUpstreamProvider @@ -42,9 +44,33 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider): "default_base_url": cls.default_base_url, "fixed_base_url": True, "platform_url": cls.platform_url, + "can_show_balance": True, } async def fetch_models(self) -> list[Model]: """Fetch all OpenRouter models.""" models_data = await async_fetch_openrouter_models() return [Model(**model) for model in models_data] # type: ignore + + async def get_balance(self) -> float | None: + """Get the current account balance from OpenRouter. + + Returns: + Float representing the balance amount (in credits/USD), or None if unavailable. + """ + url = f"{self.base_url}/credits" + headers = {"Authorization": f"Bearer {self.api_key}"} + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + + credits_data = data.get("data", {}) + total_credits = float(credits_data.get("total_credits", 0.0)) + total_usage = float(credits_data.get("total_usage", 0.0)) + + return total_credits - total_usage + except Exception: + return None diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index f8a9c965..24013edb 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -36,6 +36,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): provider_type = "ppqai" default_base_url = "https://api.ppq.ai" platform_url = "https://ppq.ai/api-docs" + IGNORED_MODEL_IDS: list[str] = ["auto"] def __init__(self, api_key: str, provider_fee: float = 1.0): super().__init__( @@ -127,11 +128,16 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): for model_data in models_data: try: ppqai_model = PPQAIModel.parse_obj(model_data) + if ppqai_model.id in self.IGNORED_MODEL_IDS: + continue + or_model = next( ( model for model in or_models - if model.id == ppqai_model.id + if (model.id == ppqai_model.id) + or (model.id.split("/")[-1] == ppqai_model.id) + or (model.id == ppqai_model.id.split("/")[-1]) ), None, ) @@ -371,16 +377,20 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): return topup_data - async def get_balance(self) -> dict[str, object]: + async def get_balance(self) -> float | None: """Get the current account balance from PPQ.AI. Returns: - Dict with balance information + Float representing the balance amount (in USD), or None if unavailable. Raises: httpx.HTTPStatusError: If the API request fails """ - return await self.check_balance() + data = await self.check_balance() + balance = data.get("balance") + if isinstance(balance, (int, float)): + return float(balance) + return None async def check_balance(self) -> dict[str, object]: """Check the account balance for this PPQ.AI account. diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index cdc8e652..4c7af5f1 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -62,7 +62,6 @@ async def test_root_endpoint_structure_and_performance( "mints", "http_url", "onion_url", - "models", ] for field in required_fields: assert field in data, f"Missing required field: {field}" @@ -75,15 +74,9 @@ async def test_root_endpoint_structure_and_performance( assert isinstance(data["mints"], list) assert isinstance(data["http_url"], str) assert isinstance(data["onion_url"], str) - assert isinstance(data["models"], list) - # Validate models structure if any exist - for model in data["models"]: - assert isinstance(model, dict) - # Models should have at least basic fields - model_required_fields = ["id", "name"] - for field in model_required_fields: - assert field in model, f"Model missing required field: {field}" + # Ensure models field is not present (removed as per issue #184) + assert "models" not in data, "Models field should not be present in base URL output" # Verify no database state changes diff = await db_snapshot.diff() diff --git a/tests/unit/test_logging_securityfilter.py b/tests/unit/test_logging_securityfilter.py new file mode 100644 index 00000000..8f621988 --- /dev/null +++ b/tests/unit/test_logging_securityfilter.py @@ -0,0 +1,118 @@ +"""Unit tests for the logging SecurityFilter. + +This module tests that the SecurityFilter correctly identifies and redacts +sensitive information from log messages without causing false positives. + +""" + +import logging +from collections.abc import Callable + +import pytest + +from routstr.core.logging import SecurityFilter + + +@pytest.fixture +def security_filter() -> SecurityFilter: + """Provide an instance of the SecurityFilter for testing.""" + return SecurityFilter() + + +@pytest.fixture +def filter_message(security_filter: SecurityFilter) -> Callable[[str], str]: + """A helper fixture to apply the filter to a message string.""" + + def _filter(msg: str) -> str: + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + security_filter.filter(record) + return record.getMessage() + + return _filter + + +def test_redacts_unquoted_key_value_pairs(filter_message: Callable[[str], str]) -> None: + """Test that an unquoted key-value pair is correctly redacted.""" + original = "Processing request with api_key=sk-12345abcdef" + expected = "Processing request with api_key: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_quoted_key_value_pairs(filter_message: Callable[[str], str]) -> None: + """Test that a quoted token is correctly redacted.""" + original = 'User authenticated with token="cashuA123abc"' + expected = "User authenticated with token: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_bearer_token(filter_message: Callable[[str], str]) -> None: + """Test that a Bearer token of sufficient length is redacted.""" + original = "Authorization: Bearer abc1234567890xyzabcdefg" + expected = "Authorization: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_cashu_token(filter_message: Callable[[str], str]) -> None: + """Test that a Cashu token is redacted.""" + original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI" + expected = "Received [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_nsec_key(filter_message: Callable[[str], str]) -> None: + """Test that a full-length Nostr private key is redacted.""" + original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f" + expected = "Private key is [REDACTED]" + assert filter_message(original) == expected + + +def test_ignores_non_sensitive_message(filter_message: Callable[[str], str]) -> None: + """Test that a message with no sensitive data is left untouched.""" + original = "No token pricing configured, using base cost" + expected = "No token pricing configured, using base cost" + assert filter_message(original) == expected + + +def test_multiple_secrets_in_one_message(filter_message: Callable[[str], str]) -> None: + """Test that multiple different secrets in one message are all redacted.""" + original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"' + expected = "Auth with [REDACTED] and api_key: [REDACTED]" + assert filter_message(original) == expected + + +def test_redacts_key_with_no_value(filter_message: Callable[[str], str]) -> None: + """Test that a key with no value is not redacted.""" + original = "Request contains api_key and secret." + expected = "Request contains api_key and secret." + assert filter_message(original) == expected + + +def test_redacts_key_value_with_spaces(filter_message: Callable[[str], str]) -> None: + """Test that key-value pairs with extra spaces are correctly redacted.""" + original = "Auth info: api_key = 'sk-12345'" + expected = "Auth info: api_key: [REDACTED]" + assert filter_message(original) == expected + + +def test_is_case_insensitive_for_keys(filter_message: Callable[[str], str]) -> None: + """Test that key matching is case-insensitive.""" + original = "TOKEN=sk-abcdef12345" + expected = "token: [REDACTED]" + assert filter_message(original) == expected + + +def test_is_case_insensitive_for_standalone( + filter_message: Callable[[str], str], +) -> None: + """Test that standalone matching is case-insensitive.""" + original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc" + expected = "Using [REDACTED] and [REDACTED]" + assert filter_message(original) == expected diff --git a/ui/app/_register/page.tsx b/ui/app/_register/page.tsx deleted file mode 100644 index f88bf940..00000000 --- a/ui/app/_register/page.tsx +++ /dev/null @@ -1,172 +0,0 @@ -'use client'; - -import { useAuth } from '@/lib/auth/AuthContext'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import Link from 'next/link'; -import { ArrowUpCircleIcon } from 'lucide-react'; -import { registerUser, SchemaRegisterProps } from '@/lib/api/services/auth'; - -export default function NostrRegisterPage() { - const router = useRouter(); - const { connectNostr } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [formData, setFormData] = useState({ - npub: '', - name: '', - }); - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; - - const handleNostrConnect = async () => { - setIsLoading(true); - try { - const publicKey = await connectNostr(); - if (publicKey) { - setFormData((prev) => ({ ...prev, npub: publicKey })); - toast.success( - 'Nostr connected. Please enter your name to complete registration.' - ); - } else { - toast.error( - 'Failed to connect. Please make sure your Nostr extension is installed and enabled.' - ); - } - } catch (error) { - console.error('Nostr connection error:', error); - toast.error('Failed to connect to Nostr. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!formData.npub || formData.npub.length < 10) { - toast.error('Please enter a valid Nostr public key'); - return; - } - - if (!formData.name) { - toast.error('Please enter your name'); - return; - } - - setIsLoading(true); - try { - // Register the user - const result = await registerUser(formData); - console.log('Registration successful:', result); - toast.success('Account created successfully'); - router.push('/login'); - } catch (error) { - console.error('Registration error:', error); - toast.error('Registration failed. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-
-
-
-
- -
- -
- Routstr - -

Create an Account

-
- Already have an account?{' '} - - Sign in - -
-
-
-
- - -
-
- - -
- -
-
- - Or - -
-
- -
-
-
-
- By clicking create account, you agree to our{' '} - Terms of Service and{' '} - Privacy Policy. -
-
-
-
- ); -} diff --git a/ui/app/balances/page.tsx b/ui/app/balances/page.tsx new file mode 100644 index 00000000..b5213287 --- /dev/null +++ b/ui/app/balances/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useCurrencyStore } from '@/lib/stores/currency'; +import { useQuery } from '@tanstack/react-query'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; +import { TemporaryBalances } from '@/components/temporary-balances'; +import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; + +export default function BalancesPage() { + const { displayUnit } = useCurrencyStore(); + + const { data: btcUsdPrice } = useQuery({ + queryKey: ['btc-usd-price'], + queryFn: fetchBtcUsdPrice, + refetchInterval: 120_000, + staleTime: 60_000, + }); + + const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; + + return ( + + + + +
+
+
+

+ Balances +

+

+ Monitor and manage wallet balances +

+
+ {/* Global currency toggle is now in SiteHeader */} +
+ +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/ui/app/globals.css b/ui/app/globals.css index 86093a5c..37756b40 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -6,8 +6,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: Geist, sans-serif; + --font-mono: Geist Mono, monospace; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -41,75 +41,152 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --font-serif: Georgia, serif; + --radius: 0.5rem; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xl: var(--shadow-2xl); + --shadow-xl: var(--shadow-xl); + --shadow-lg: var(--shadow-lg); + --shadow-md: var(--shadow-md); + --shadow: var(--shadow); + --shadow-sm: var(--shadow-sm); + --shadow-xs: var(--shadow-xs); + --shadow-2xs: var(--shadow-2xs); + --spacing: var(--spacing); + --letter-spacing: var(--letter-spacing); + --shadow-offset-y: var(--shadow-offset-y); + --shadow-offset-x: var(--shadow-offset-x); + --shadow-spread: var(--shadow-spread); + --shadow-blur: var(--shadow-blur); + --shadow-opacity: var(--shadow-opacity); + --color-shadow-color: var(--shadow-color); + --color-destructive-foreground: var(--destructive-foreground); } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.147 0.004 49.25); + --radius: 0.5rem; + --background: oklch(0.99 0 0); + --foreground: oklch(0 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0.147 0.004 49.25); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.147 0.004 49.25); - --primary: oklch(0.216 0.006 56.043); - --primary-foreground: oklch(0.985 0.001 106.423); - --secondary: oklch(0.97 0.001 106.424); - --secondary-foreground: oklch(0.216 0.006 56.043); - --muted: oklch(0.97 0.001 106.424); - --muted-foreground: oklch(0.553 0.013 58.071); - --accent: oklch(0.97 0.001 106.424); - --accent-foreground: oklch(0.216 0.006 56.043); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.923 0.003 48.717); - --input: oklch(0.923 0.003 48.717); - --ring: oklch(0.709 0.01 56.259); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0.001 106.423); - --sidebar-foreground: oklch(0.147 0.004 49.25); - --sidebar-primary: oklch(0.216 0.006 56.043); - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); - --sidebar-accent: oklch(0.97 0.001 106.424); - --sidebar-accent-foreground: oklch(0.216 0.006 56.043); - --sidebar-border: oklch(0.923 0.003 48.717); - --sidebar-ring: oklch(0.709 0.01 56.259); + --card-foreground: oklch(0 0 0); + --popover: oklch(0.99 0 0); + --popover-foreground: oklch(0 0 0); + --primary: oklch(0 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.94 0 0); + --secondary-foreground: oklch(0 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.44 0 0); + --accent: oklch(0.94 0 0); + --accent-foreground: oklch(0 0 0); + --destructive: oklch(0.63 0.19 23.03); + --border: oklch(0.92 0 0); + --input: oklch(0.94 0 0); + --ring: oklch(0 0 0); + --chart-1: oklch(0.81 0.17 75.35); + --chart-2: oklch(0.55 0.22 264.53); + --chart-3: oklch(0.72 0 0); + --chart-4: oklch(0.92 0 0); + --chart-5: oklch(0.56 0 0); + --sidebar: oklch(0.99 0 0); + --sidebar-foreground: oklch(0 0 0); + --sidebar-primary: oklch(0 0 0); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.94 0 0); + --sidebar-accent-foreground: oklch(0 0 0); + --sidebar-border: oklch(0.94 0 0); + --sidebar-ring: oklch(0 0 0); + --destructive-foreground: oklch(1 0 0); + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; + --shadow-color: hsl(0 0% 0%); + --shadow-opacity: 0.18; + --shadow-blur: 2px; + --shadow-spread: 0px; + --shadow-offset-x: 0px; + --shadow-offset-y: 1px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); + --tracking-normal: 0em; } .dark { - --background: oklch(0.147 0.004 49.25); - --foreground: oklch(0.985 0.001 106.423); - --card: oklch(0.216 0.006 56.043); - --card-foreground: oklch(0.985 0.001 106.423); - --popover: oklch(0.216 0.006 56.043); - --popover-foreground: oklch(0.985 0.001 106.423); - --primary: oklch(0.923 0.003 48.717); - --primary-foreground: oklch(0.216 0.006 56.043); - --secondary: oklch(0.268 0.007 34.298); - --secondary-foreground: oklch(0.985 0.001 106.423); - --muted: oklch(0.268 0.007 34.298); - --muted-foreground: oklch(0.709 0.01 56.259); - --accent: oklch(0.268 0.007 34.298); - --accent-foreground: oklch(0.985 0.001 106.423); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.553 0.013 58.071); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.216 0.006 56.043); - --sidebar-foreground: oklch(0.985 0.001 106.423); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); - --sidebar-accent: oklch(0.268 0.007 34.298); - --sidebar-accent-foreground: oklch(0.985 0.001 106.423); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.553 0.013 58.071); + --background: oklch(0 0 0); + --foreground: oklch(1 0 0); + --card: oklch(0.14 0 0); + --card-foreground: oklch(1 0 0); + --popover: oklch(0.18 0 0); + --popover-foreground: oklch(1 0 0); + --primary: oklch(1 0 0); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.25 0 0); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.23 0 0); + --muted-foreground: oklch(0.72 0 0); + --accent: oklch(0.32 0 0); + --accent-foreground: oklch(1 0 0); + --destructive: oklch(0.69 0.2 23.91); + --border: oklch(0.26 0 0); + --input: oklch(0.32 0 0); + --ring: oklch(0.72 0 0); + --chart-1: oklch(0.81 0.17 75.35); + --chart-2: oklch(0.58 0.21 260.84); + --chart-3: oklch(0.56 0 0); + --chart-4: oklch(0.44 0 0); + --chart-5: oklch(0.92 0 0); + --sidebar: oklch(0.18 0 0); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(1 0 0); + --sidebar-primary-foreground: oklch(0 0 0); + --sidebar-accent: oklch(0.32 0 0); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(0.32 0 0); + --sidebar-ring: oklch(0.72 0 0); + --destructive-foreground: oklch(0 0 0); + --radius: 0.5rem; + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; + --shadow-color: hsl(0 0% 0%); + --shadow-opacity: 0.18; + --shadow-blur: 2px; + --shadow-spread: 0px; + --shadow-offset-x: 0px; + --shadow-offset-y: 1px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); } @layer base { @@ -118,6 +195,7 @@ } body { @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); } } diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx index 0355f8c2..7df50995 100644 --- a/ui/app/login/page.tsx +++ b/ui/app/login/page.tsx @@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement { }; return ( -
- +
+ Admin Login diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx new file mode 100644 index 00000000..852aa4a8 --- /dev/null +++ b/ui/app/logs/log-details-dialog.tsx @@ -0,0 +1,205 @@ +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Copy, Check } from 'lucide-react'; +import { useState } from 'react'; + +interface LogEntry { + asctime: string; + name: string; + levelname: string; + message: string; + pathname: string; + lineno: number; + version: string; + request_id: string; + [key: string]: string | number | object | undefined; +} + +interface LogDetailsDialogProps { + log: LogEntry | null; + isOpen: boolean; + onClose: () => void; +} + +const getLevelColor = (level: string): string => { + switch (level.toUpperCase()) { + case 'TRACE': + case 'DEBUG': + return 'bg-gray-100 text-gray-800 border-gray-200'; + case 'INFO': + return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'WARNING': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'ERROR': + return 'bg-red-100 text-red-800 border-red-200'; + case 'CRITICAL': + return 'bg-purple-100 text-purple-800 border-purple-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } +}; + +export function LogDetailsDialog({ + log, + isOpen, + onClose, +}: LogDetailsDialogProps) { + const [copiedField, setCopiedField] = useState(null); + + if (!log) return null; + + const copyToClipboard = (text: string, fieldName?: string) => { + navigator.clipboard.writeText(text); + if (fieldName) { + setCopiedField(fieldName); + setTimeout(() => setCopiedField(null), 2000); + } + }; + + const allFields = Object.keys(log).filter((key) => key !== 'key'); + const standardFields = [ + 'asctime', + 'name', + 'levelname', + 'message', + 'pathname', + 'lineno', + 'version', + 'request_id', + ]; + const extraFields = allFields.filter((key) => !standardFields.includes(key)); + + return ( + + + + + + {log.levelname} + + Log Entry Details + + + {log.asctime} • {log.name} • {log.pathname}:{log.lineno} + + + + +
+
+

Message

+
+
+                  {log.message}
+                
+
+
+ +
+

Standard Fields

+
+ {standardFields.map((field) => ( +
+
+ + {field} + + {field === 'request_id' && ( + + )} +
+
+
+                        {String(log[field as keyof LogEntry] || 'N/A')}
+                      
+
+
+ ))} +
+
+ + {extraFields.length > 0 && ( +
+

Additional Fields

+
+ {extraFields.map((field) => ( +
+ + {field} + +
+ {typeof log[field] === 'object' ? ( +
+                            {JSON.stringify(log[field], null, 2)}
+                          
+ ) : ( +
+                            {String(log[field] || 'N/A')}
+                          
+ )} +
+
+ ))} +
+
+ )} + +
+
+

Raw JSON

+ +
+
+
+                  {JSON.stringify(log, null, 2)}
+                
+
+
+
+
+
+
+ ); +} diff --git a/ui/app/logs/log-entry-card.tsx b/ui/app/logs/log-entry-card.tsx new file mode 100644 index 00000000..20133908 --- /dev/null +++ b/ui/app/logs/log-entry-card.tsx @@ -0,0 +1,122 @@ +import { Badge } from '@/components/ui/badge'; +import { Eye } from 'lucide-react'; + +interface LogEntry { + asctime: string; + name: string; + levelname: string; + message: string; + pathname: string; + lineno: number; + version: string; + request_id: string; + [key: string]: string | number | object | undefined; +} + +interface LogEntryCardProps { + entry: LogEntry; + onClick: (entry: LogEntry) => void; +} + +const getLevelColor = (level: string): string => { + switch (level.toUpperCase()) { + case 'TRACE': + case 'DEBUG': + return 'bg-gray-100 text-gray-800 border-gray-200'; + case 'INFO': + return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'WARNING': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'ERROR': + return 'bg-red-100 text-red-800 border-red-200'; + case 'CRITICAL': + return 'bg-purple-100 text-purple-800 border-purple-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } +}; + +export function LogEntryCard({ entry, onClick }: LogEntryCardProps) { + const extraFields = Object.keys(entry).filter( + (key) => + ![ + 'asctime', + 'name', + 'levelname', + 'message', + 'pathname', + 'lineno', + 'version', + 'request_id', + ].includes(key) + ); + + return ( +
onClick(entry)} + > +
+
+ + {entry.levelname} + + + {entry.asctime} + + + {entry.name} + +
+
+
+ {entry.pathname}:{entry.lineno} +
+ +
+
+ +
+ {entry.message} +
+ + {entry.request_id && entry.request_id !== 'no-request-id' && ( +
+
+ + + Request ID: {entry.request_id} + + +
+
+ )} + + {extraFields.length > 0 && ( +
+
Additional Fields:
+
+ {extraFields.slice(0, 4).map((key) => ( +
+ {key}:{' '} + + {typeof entry[key] === 'object' + ? JSON.stringify(entry[key]) + : String(entry[key])} + +
+ ))} + {extraFields.length > 4 && ( +
+ ...and {extraFields.length - 4} more fields +
+ )} +
+
+ )} +
+ ); +} diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx new file mode 100644 index 00000000..81b9aff9 --- /dev/null +++ b/ui/app/logs/log-filters.tsx @@ -0,0 +1,296 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon, Filter, X } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; + +interface LogFiltersProps { + selectedDate: string; + selectedLevel: string; + requestId: string; + searchText: string; + limit: number; + onDateChange: (date: string) => void; + onLevelChange: (level: string) => void; + onRequestIdChange: (requestId: string) => void; + onSearchTextChange: (searchText: string) => void; + onLimitChange: (limit: number) => void; + onClearFilters: () => void; +} + +const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']; +const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000']; + +export function LogFilters({ + selectedDate, + selectedLevel, + requestId, + searchText, + limit, + onDateChange, + onLevelChange, + onRequestIdChange, + onSearchTextChange, + onLimitChange, + onClearFilters, +}: LogFiltersProps) { + const isPreset = PRESET_LIMITS.includes(limit.toString()); + + const [customLimit, setCustomLimit] = useState( + isPreset ? '' : limit.toString() + ); + const [isCustom, setIsCustom] = useState(!isPreset); + const [date, setDate] = useState( + selectedDate && selectedDate !== 'all' + ? new Date(selectedDate + 'T00:00:00') + : undefined + ); + + useEffect(() => { + const currentIsPreset = PRESET_LIMITS.includes(limit.toString()); + setIsCustom(!currentIsPreset); + if (!currentIsPreset) { + setCustomLimit(limit.toString()); + } + }, [limit]); + + useEffect(() => { + if (selectedDate === 'all' || !selectedDate) { + setDate(undefined); + } else { + const d = new Date(selectedDate + 'T00:00:00'); + setDate(isNaN(d.getTime()) ? undefined : d); + } + }, [selectedDate]); + + const handleLimitChange = (value: string) => { + if (value === 'custom') { + setIsCustom(true); + setCustomLimit(limit.toString()); + } else { + setIsCustom(false); + setCustomLimit(''); + onLimitChange(Number(value)); + } + }; + + const handleCustomLimitChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setCustomLimit(value); + }; + + const handleCustomLimitApply = () => { + const numValue = parseInt(customLimit); + if (!isNaN(numValue) && numValue > 0) { + onLimitChange(numValue); + } else { + setIsCustom(false); + setCustomLimit(''); + onLimitChange(100); + } + }; + + const handleCustomLimitKeyDown = ( + e: React.KeyboardEvent + ) => { + if (e.key === 'Enter') { + handleCustomLimitApply(); + } + }; + + const handleDateSelect = (selectedDate: Date | undefined) => { + setDate(selectedDate); + if (selectedDate) { + onDateChange(format(selectedDate, 'yyyy-MM-dd')); + } else { + onDateChange('all'); + } + }; + + return ( + + + + + Filters + + + Filter logs by date, level, request ID, text search, and limit + + + +
+
+ + + + + + + + + + {date && ( + + )} +
+ +
+ + +
+ +
+ + onRequestIdChange(e.target.value)} + /> +
+ +
+ + onSearchTextChange(e.target.value)} + /> +
+ +
+ + {isCustom ? ( +
+ + +
+ ) : ( + + )} + {!isCustom && !isPreset && ( +

Custom: {limit}

+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/app/logs/page.tsx b/ui/app/logs/page.tsx new file mode 100644 index 00000000..b3551aad --- /dev/null +++ b/ui/app/logs/page.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { FileText, RefreshCw } from 'lucide-react'; +import { apiClient } from '@/lib/api/client'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { LogEntry, LogsResponse } from './types'; +import { LogFilters } from './log-filters'; +import { LogEntryCard } from './log-entry-card'; +import { LogDetailsDialog } from './log-details-dialog'; + +export default function LogsPage() { + const [selectedDate, setSelectedDate] = useState('all'); + const [selectedLevel, setSelectedLevel] = useState('all'); + const [requestId, setRequestId] = useState(''); + const [searchText, setSearchText] = useState(''); + const [limit, setLimit] = useState(100); + const [selectedLog, setSelectedLog] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { + data: logsData, + refetch: refetchLogs, + isLoading, + } = useQuery({ + queryKey: [ + 'logs', + selectedDate, + selectedLevel, + requestId, + searchText, + limit, + ], + queryFn: () => + apiClient.get('/admin/api/logs', { + date: selectedDate === 'all' ? undefined : selectedDate, + level: selectedLevel === 'all' ? undefined : selectedLevel, + request_id: requestId || undefined, + search: searchText || undefined, + limit: limit, + }), + refetchInterval: 30000, + }); + + const handleClearFilters = () => { + setSelectedDate('all'); + setSelectedLevel('all'); + setRequestId(''); + setSearchText(''); + setLimit(100); + }; + + const handleLogClick = (entry: LogEntry) => { + setSelectedLog(entry); + setIsDialogOpen(true); + }; + + return ( + + + + +
+
+
+

+ + System Logs +

+

+ View and filter application logs +

+
+ +
+ + + + + + + Log Entries + {logsData && ( + + {logsData.logs.length} entries + + )} + + {(selectedDate !== 'all' || + selectedLevel !== 'all' || + requestId || + searchText) && ( + + Showing logs + {selectedDate !== 'all' && ` for ${selectedDate}`} + {selectedLevel !== 'all' && ` with level ${selectedLevel}`} + {requestId && ` with request ID ${requestId}`} + {searchText && ` matching "${searchText}"`} + + )} + + + {isLoading ? ( +
+ + + Loading logs... + +
+ ) : logsData?.logs && logsData.logs.length > 0 ? ( + <> + +
+ {logsData.logs.map((entry, index) => ( + + ))} +
+
+ + ) : ( +
+ +

No log entries found

+

+ Try adjusting your filters or check back later +

+
+ )} +
+
+ + setIsDialogOpen(false)} + /> +
+
+
+ ); +} diff --git a/ui/app/logs/types.ts b/ui/app/logs/types.ts new file mode 100644 index 00000000..8c905827 --- /dev/null +++ b/ui/app/logs/types.ts @@ -0,0 +1,25 @@ +export interface LogEntry { + asctime: string; + name: string; + levelname: string; + message: string; + pathname: string; + lineno: number; + version: string; + request_id: string; + [key: string]: string | number | object | undefined; +} + +export interface LogsResponse { + logs: LogEntry[]; + total: number; + date: string | null; + level: string | null; + request_id: string | null; + search: string | null; + limit: number; +} + +export interface DatesResponse { + dates: string[]; +} diff --git a/ui/app/model/page.tsx b/ui/app/model/page.tsx index edd6b20f..6c415827 100644 --- a/ui/app/model/page.tsx +++ b/ui/app/model/page.tsx @@ -34,7 +34,6 @@ export default function ModelsPage() { const { models = [], groups = [] } = modelsData || {}; const groupedModels = useMemo(() => { - if (!models) return {}; return groupAndSortModelsByProvider(models); }, [models]); @@ -43,7 +42,14 @@ export default function ModelsPage() { }, [groups]); const providerInfo = useMemo(() => { - return Object.entries(groupedModels).map(([provider, providerModels]) => { + const allProviders = new Set([ + ...Object.keys(groupedModels), + ...groups.map((g) => g.provider), + ]); + console.log(allProviders); + + return Array.from(allProviders).map((provider) => { + const providerModels = groupedModels[provider] || []; const groupData = groupDataMap.get(provider); const activeModels = providerModels.filter( (m) => m.isEnabled && !m.soft_deleted @@ -59,7 +65,7 @@ export default function ModelsPage() { hasGroupApiKey: !!groupData?.group_api_key, }; }); - }, [groupedModels, groupDataMap]); + }, [groupedModels, groupDataMap, groups]); return ( @@ -158,9 +164,9 @@ export default function ModelsPage() {
- {Object.entries(groupedModels).map( - ([provider, providerModels]) => { - const groupData = groupDataMap.get(provider); + {providerInfo.map( + ({ provider, totalModels, groupData }) => { + const providerModels = groupedModels[provider] || []; return ( @@ -190,9 +196,48 @@ export default function ModelsPage() { {groupData.group_url} )} + {totalModels === 0 && ( + + No models configured + + )}
+ {totalModels === 0 && ( + + + +
+

+ No models found for this provider +

+
+

Common issues:

+
    +
  • + API credentials: Check if the API key is correct and has the right permissions +
  • +
  • + Base URL: Verify the base URL is correct for your provider +
  • +
  • + Network access: Ensure the server can reach the provider's API endpoint +
  • +
  • + Provider status: The upstream provider might be temporarily unavailable +
  • +
+ {groupData?.group_url && ( +

+ Current endpoint: {groupData.group_url} +

+ )} +
+
+
+
+ )} ('sat'); +export default function DashboardPage() { + const [timeRange, setTimeRange] = useState('24'); + const [interval, setInterval] = useState('15'); + const { displayUnit } = useCurrencyStore(); + const [isAuthenticated, setIsAuthenticated] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + return ConfigurationService.isTokenValid(); + }); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const syncAuthState = (): void => { + setIsAuthenticated(ConfigurationService.isTokenValid()); + }; + + syncAuthState(); + window.addEventListener('storage', syncAuthState); + + return () => { + window.removeEventListener('storage', syncAuthState); + }; + }, []); + const { data: btcUsdPrice } = useQuery({ queryKey: ['btc-usd-price'], queryFn: fetchBtcUsdPrice, + enabled: isAuthenticated, refetchInterval: 120_000, staleTime: 60_000, }); const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; - useEffect(() => { - if (displayUnit === 'usd' && usdPerSat === null) { - setDisplayUnit('sat'); - } - }, [displayUnit, usdPerSat]); + const { + data: metricsData, + isLoading: metricsLoading, + refetch: refetchMetrics, + } = useQuery({ + queryKey: ['usage-metrics', interval, timeRange], + queryFn: () => + AdminService.getUsageMetrics(parseInt(interval), parseInt(timeRange)), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const { + data: summaryData, + isLoading: summaryLoading, + refetch: refetchSummary, + } = useQuery({ + queryKey: ['usage-summary', timeRange], + queryFn: () => AdminService.getUsageSummary(parseInt(timeRange)), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const { + data: errorData, + isLoading: errorLoading, + refetch: refetchErrors, + } = useQuery({ + queryKey: ['usage-errors', timeRange], + queryFn: () => AdminService.getErrorDetails(parseInt(timeRange), 100), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const { + data: revenueByModelData, + isLoading: revenueByModelLoading, + refetch: refetchRevenueByModel, + } = useQuery({ + queryKey: ['revenue-by-model', timeRange], + queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange), 20), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + if (!isAuthenticated) { + return ; + } + + const handleRefresh = () => { + refetchMetrics(); + refetchSummary(); + refetchErrors(); + refetchRevenueByModel(); + }; return ( -
-
+
+
+

Dashboard

+ +
+ +
-

- Admin Dashboard -

-

- Monitor and manage wallet balances +

+ Usage Analytics +

+

+ Monitor requests, errors, and revenue over the last {timeRange} hours

-
- { - if (value) { - setDisplayUnit(value as DisplayUnit); - } - }} - variant='outline' - size='sm' - > - mSAT - sat - - USD - - +
+ + +
-
-
- +
+ {summaryLoading ? ( +
Loading summary...
+ ) : summaryData ? ( + + ) : null} + +
+ {metricsLoading ? ( +
+ Loading metrics... +
+ ) : metricsData && metricsData.metrics.length > 0 ? ( + <> +
+ ({ + ...m, + revenue_sats: m.revenue_msats / 1000, + refunds_sats: m.refunds_msats / 1000, + net_revenue_sats: + (m.revenue_msats - m.refunds_msats) / 1000, + })) as Array & { timestamp: string }>} + title='Revenue Over Time (sats)' + dataKeys={[ + { + key: 'revenue_sats', + name: 'Revenue', + color: '#10b981', + }, + { + key: 'net_revenue_sats', + name: 'Net Revenue', + color: '#059669', + }, + { + key: 'refunds_sats', + name: 'Refunds', + color: '#ef4444', + }, + ]} + /> +
+ & { timestamp: string }>} + title='Request Volume' + dataKeys={[ + { + key: 'total_requests', + name: 'Total Requests', + color: '#3b82f6', + }, + { + key: 'successful_chat_completions', + name: 'Successful', + color: '#22c55e', + }, + { + key: 'failed_requests', + name: 'Failed', + color: '#f43f5e', + }, + ]} + /> + & { timestamp: string }>} + title='Error Tracking' + dataKeys={[ + { + key: 'errors', + name: 'Errors', + color: '#f97316', + }, + { + key: 'warnings', + name: 'Warnings', + color: '#eab308', + }, + { + key: 'upstream_errors', + name: 'Upstream Errors', + color: '#dc2626', + }, + ]} + /> + & { timestamp: string }>} + title='Payment Activity' + dataKeys={[ + { + key: 'payment_processed', + name: 'Payments Processed', + color: '#8b5cf6', + }, + ]} + /> +
+ {summaryData && summaryData.unique_models.length > 0 && ( + + + Active Models + + +
+ {summaryData.unique_models.map((model) => ( + + {model} + + ))} +
+
+
+ )} + {summaryData && + summaryData.error_types && + Object.keys(summaryData.error_types).length > 0 && ( + + + Error Types Distribution + + +
+ {Object.entries(summaryData.error_types) + .sort(([, a], [, b]) => b - a) + .map(([type, count]) => ( +
+ {type} + + {count} + +
+ ))} +
+
+
+ )} +
+ + ) : ( + + + No Data Available + + +

+ No metrics data found for the selected time range. This + could be because no requests have been logged yet or the + log files are not available. +

+
+
+ )}
-
- Loading revenue by model...
+ ) : revenueByModelData && revenueByModelData.models.length > 0 ? ( + -
+ ) : null} + + {errorLoading ? ( +
Loading errors...
+ ) : errorData ? ( + + ) : null}
diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx index 759874e1..f431f101 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -18,7 +18,9 @@ import { UpstreamProvider, CreateUpstreamProvider, UpdateUpstreamProvider, + AdminModel, } from '@/lib/api/services/admin'; +import { AddProviderModelDialog } from '@/components/AddProviderModelDialog'; import { Skeleton } from '@/components/ui/skeleton'; import { AlertCircle, @@ -54,7 +56,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useState, useEffect } from 'react'; import { toast } from 'sonner'; -function ProviderBalance({ providerId }: { providerId: number }) { +function ProviderBalance({ + providerId, + platformUrl, +}: { + providerId: number; + platformUrl?: string | null; +}) { const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false); const [topupAmount, setTopupAmount] = useState(''); const [topupError, setTopupError] = useState(''); @@ -136,6 +144,10 @@ function ProviderBalance({ providerId }: { providerId: number }) { }); const handleTopup = () => { + // If no dialog open logic (which depends on API implementation), + // we check if we should redirect or open dialog based on available info + // But since this function is called inside the dialog, we might want to change + // how the "Top Up" button behaves instead. const amount = parseFloat(topupAmount); if (isNaN(amount)) { @@ -151,6 +163,31 @@ function ProviderBalance({ providerId }: { providerId: number }) { topupMutation.mutate(amount); }; + const handleTopUpClick = () => { + // Check if the provider supports direct topup (currently only PPQ.AI effectively) + // We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability + // For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented), + // we should redirect. + // However, the prompt asks to redirect if topup is not implemented. + // The backend throws 500/400 if not implemented. + // A better approach is to check if we have a platform URL and maybe redirect there + // if we know it's not supported. + + // BUT, we don't know for sure if it's supported without checking metadata or trying. + // Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance". + + // Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect? + // Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect? + // The user specifically mentioned "like in openrouter". + + if (platformUrl && (platformUrl.includes('openrouter.ai') || platformUrl.includes('openai.com'))) { + window.open(platformUrl, '_blank'); + return; + } + + setIsTopupDialogOpen(true); + }; + const handleCloseDialog = () => { setIsTopupDialogOpen(false); setTopupAmount(''); @@ -170,12 +207,18 @@ function ProviderBalance({ providerId }: { providerId: number }) { const balance = balanceData.balance_data; let displayValue = 'N/A'; - if (typeof balance.balance === 'number') { - displayValue = `$${balance.balance.toFixed(2)}`; - } else if (typeof balance.balance === 'string') { - displayValue = balance.balance; - } else if (balance.amount !== undefined) { - displayValue = `$${Number(balance.amount).toFixed(2)}`; + if (typeof balance === 'number') { + displayValue = `$${balance.toFixed(2)}`; + } else if (balance && typeof balance === 'object') { + // Legacy support for object response + const b = balance as Record; + if (typeof b.balance === 'number') { + displayValue = `$${b.balance.toFixed(2)}`; + } else if (typeof b.balance === 'string') { + displayValue = b.balance; + } else if (b.amount !== undefined) { + displayValue = `$${Number(b.amount).toFixed(2)}`; + } } return ( @@ -183,7 +226,7 @@ function ProviderBalance({ providerId }: { providerId: number }) {
) : (
@@ -833,9 +961,19 @@ export default function ProvidersPage() { {model.description || model.name}
-
- {model.context_length?.toLocaleString()}{' '} - tokens +
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
))} @@ -886,12 +1024,17 @@ export default function ProvidersPage() { value='custom' className='mt-4 space-y-2' > - {providerModels.db_models.length > 0 && ( -
- Custom models override or extend the - provider's catalog. -
- )} +
+ {providerModels.db_models.length > 0 && ( +
+ Custom models override or extend the provider's catalog. +
+ )} + +
{providerModels.db_models.length === 0 ? (
No custom models configured @@ -927,9 +1070,19 @@ export default function ProvidersPage() { model.name}
-
- {model.context_length?.toLocaleString()}{' '} - tokens +
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
) @@ -964,9 +1117,20 @@ export default function ProvidersPage() { model.name} -
- {model.context_length?.toLocaleString()}{' '} - tokens +
+
+ {model.context_length?.toLocaleString()}{' '} + tokens +
+
) @@ -1001,11 +1165,12 @@ export default function ProvidersPage() { + setFormData({ + ...formData, + provider_fee: e.target.value + ? parseFloat(e.target.value) + : undefined, + }) + } + placeholder={getProviderFeePlaceholder( + formData.provider_type + )} + /> +

+ 1.01 means +1% e.g. currency exchange, card + fees, etc. +

+ + + e.preventDefault()} + > + + + e.stopPropagation()} + > + {isLoadingPresets ? ( + Loading presets... + ) : presets.length === 0 ? ( + No presets available. + ) : ( + + {presets.map((preset) => ( + { + applyModelToForm(preset); + setIsPresetOpen(false); + }} + > +
+ {preset.id} + + {preset.name} + +
+
+ ))} +
+ )} +
+
+
+ + +
+ Prefill fields from a preset model definition, then adjust as needed. +
+ + )} +
+ +
+ ( + + Model ID * + + + + Unique identifier for the model + + + )} + /> + + ( + + Display Name * + + + + + + )} + /> +
+ + ( + + Description + +