From a226a7822238a2ae554910b512acd1d97fb38e6b Mon Sep 17 00:00:00 2001 From: Shroominic Date: Tue, 2 Dec 2025 12:40:54 +0800 Subject: [PATCH 01/27] rm unused functions --- routstr/payment/models.py | 206 -------------------------------------- 1 file changed, 206 deletions(-) diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 2e3fbd9c..0403f007 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -1,9 +1,7 @@ import asyncio import json import random -from pathlib import Path from typing import Final -from urllib.request import urlopen import httpx from fastapi import APIRouter, Depends @@ -75,41 +73,6 @@ class Model(BaseModel): return hash(self.id) -def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: - """Fetches model information from OpenRouter API.""" - base_url = "https://openrouter.ai/api/v1" - - try: - with urlopen(f"{base_url}/models") as response: - data = json.loads(response.read().decode("utf-8")) - - models_data: list[dict] = [] - for model in data.get("data", []): - model_id = model.get("id", "") - - if source_filter: - source_prefix = f"{source_filter}/" - if not model_id.startswith(source_prefix): - continue - - model = dict(model) - model["id"] = model_id[len(source_prefix) :] - model_id = model["id"] - - if ( - "(free)" in model.get("name", "") - or model_id in DEFAULT_EXCLUDED_MODEL_IDS - ): - continue - - models_data.append(model) - - return models_data - except Exception as e: - logger.error(f"Error fetching models from OpenRouter API: {e}") - return [] - - async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: """Asynchronously fetch model information from OpenRouter API.""" base_url = "https://openrouter.ai/api/v1" @@ -155,54 +118,6 @@ def is_openrouter_upstream() -> bool: return base.lower() == "https://openrouter.ai/api/v1" -def load_models() -> list[Model]: - """Load model definitions from a JSON file or auto-generate from OpenRouter API. - - The file path can be specified via the ``MODELS_PATH`` environment variable. - If a user-provided models.json exists, it will be used. Otherwise, models are - automatically fetched from OpenRouter API in memory. If the example file exists - and no user file is provided, it will be used as a fallback. - """ - - try: - models_path = Path(settings.models_path) - except Exception: - models_path = Path("models.json") - - # Check if user has actively provided a models.json file - if models_path.exists(): - logger.info(f"Loading models from user-provided file: {models_path}") - try: - with models_path.open("r") as f: - data = json.load(f) - return [Model(**model) for model in data.get("models", [])] # type: ignore - except Exception as e: - logger.error(f"Error loading models from {models_path}: {e}") - # Fall through to auto-generation - - # Only auto-generate from OpenRouter when upstream is OpenRouter - if not is_openrouter_upstream(): - logger.info( - "Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1" - ) - return [] - - logger.info("Auto-generating models from OpenRouter API") - try: - source_filter = settings.source or None - except Exception: - source_filter = None - source_filter = source_filter if source_filter and source_filter.strip() else None - - models_data = fetch_openrouter_models(source_filter=source_filter) - if not models_data: - logger.error("Failed to fetch models from OpenRouter API") - return [] - - logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API") - return [Model(**model) for model in models_data] # type: ignore - - def _row_to_model( row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01 ) -> Model: @@ -431,57 +346,6 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model: return model -async def ensure_models_bootstrapped() -> None: - async with create_session() as s: - existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore - if existing: - return - - try: - models_path = Path(settings.models_path) - except Exception: - models_path = Path("models.json") - - models_to_insert: list[dict] = [] - if models_path.exists(): - try: - with models_path.open("r") as f: - data = json.load(f) - models_to_insert = data.get("models", []) - logger.info( - f"Bootstrapping {len(models_to_insert)} models from {models_path}" - ) - except Exception as e: - logger.error(f"Error loading models from {models_path}: {e}") - - if not models_to_insert and is_openrouter_upstream(): - logger.info("Bootstrapping models from OpenRouter API") - source_filter = None - try: - src = settings.source or None - source_filter = src if src and src.strip() else None - except Exception: - pass - models_to_insert = fetch_openrouter_models(source_filter=source_filter) - elif not models_to_insert: - logger.info( - "No models.json found and upstream is not OpenRouter; skipping bootstrap" - ) - - for m in models_to_insert: - try: - model = Model(**m) # type: ignore - except Exception: - # Some OpenRouter models include extra fields; only map required ones - continue - exists = await s.get(ModelRow, model.id) - if exists: - continue - payload = _model_to_row_payload(model) - s.add(ModelRow(**payload)) # type: ignore - await s.commit() - - async def _update_sats_pricing_once() -> None: """Update sats pricing once for all provider models (in-memory only).""" from ..proxy import get_upstreams @@ -643,76 +507,6 @@ def _pricing_matches( return True -async def refresh_models_periodically() -> None: - """Background task: periodically fetch OpenRouter models and insert new ones. - - - Respects optional SOURCE filter from settings - - Does not overwrite existing rows - - Sleeps according to settings.models_refresh_interval_seconds; disabled when 0 - """ - interval = getattr(settings, "models_refresh_interval_seconds", 0) - if not interval or interval <= 0: - return - - # Only refresh from OpenRouter when upstream is OpenRouter - if not is_openrouter_upstream(): - logger.info("Skipping models refresh: upstream_base_url is not OpenRouter") - return - - while True: - try: - try: - if not settings.enable_models_refresh: - return - except Exception: - pass - try: - src = settings.source or None - source_filter = src if src and src.strip() else None - except Exception: - source_filter = None - - models = fetch_openrouter_models(source_filter=source_filter) - if not models: - await asyncio.sleep(interval) - continue - - async with create_session() as s: - result = await s.exec(select(ModelRow.id)) # type: ignore - existing_ids = { - row[0] if isinstance(row, tuple) else row for row in result.all() - } - inserted = 0 - for m in models: - try: - model = Model(**m) # type: ignore - except Exception: - continue - if model.id in existing_ids: - continue - payload = _model_to_row_payload(model) - try: - s.add(ModelRow(**payload)) # type: ignore - except Exception: - pass - inserted += 1 - if inserted: - await s.commit() - logger.info(f"Inserted {inserted} new models from OpenRouter") - except asyncio.CancelledError: - break - except Exception as e: - logger.error( - "Error during models refresh", - extra={"error": str(e), "error_type": type(e).__name__}, - ) - try: - jitter = max(0.0, float(interval) * 0.1) - await asyncio.sleep(interval + random.uniform(0, jitter)) - except asyncio.CancelledError: - break - - @models_router.get("/v1/models") @models_router.get("/models", include_in_schema=False) async def models(session: AsyncSession = Depends(get_session)) -> dict: From 6b4b3924a18b03d8e29aab76b9ffbf96d5194735 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Tue, 2 Dec 2025 15:23:00 +0800 Subject: [PATCH 02/27] feat(ui): make admin settings dynamic based on backend response This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes. - Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX. - All other fields are rendered dynamically based on their type (boolean, number, string, array). - Sensitive fields (keys, passwords) are automatically masked. - Specific internal/unused fields are ignored. --- ui/components/settings/admin-settings.tsx | 196 +++++++++++++++++++++- 1 file changed, 194 insertions(+), 2 deletions(-) diff --git a/ui/components/settings/admin-settings.tsx b/ui/components/settings/admin-settings.tsx index c81d0b75..dd8468db 100644 --- a/ui/components/settings/admin-settings.tsx +++ b/ui/components/settings/admin-settings.tsx @@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; +import { Switch } from '@/components/ui/switch'; interface SettingsData { name?: string; @@ -28,9 +29,32 @@ interface SettingsData { http_url?: string; onion_url?: string; cashu_mints?: string[]; + relays?: string[]; [key: string]: unknown; } +const HANDLED_KEYS = [ + 'name', + 'description', + 'http_url', + 'onion_url', + 'npub', + 'nsec', + 'cashu_mints', + 'relays', + 'admin_password', + 'id', + 'updated_at', +]; + +const IGNORED_KEYS = [ + 'upstream_base_url', + 'upstream_api_key', + 'upstream_provider_fee', + 'exchange_fee', + 'models_path', +]; + interface PasswordData { current_password: string; new_password: string; @@ -44,6 +68,7 @@ export function AdminSettings() { const [error, setError] = useState(''); const [showSecrets, setShowSecrets] = useState(false); const [newMint, setNewMint] = useState(''); + const [newRelay, setNewRelay] = useState(''); const [passwordData, setPasswordData] = useState({ current_password: '', new_password: '', @@ -129,7 +154,7 @@ export function AdminSettings() { } }; - const handleInputChange = (field: string, value: string | boolean) => { + const handleInputChange = (field: string, value: unknown) => { setSettings((prev) => ({ ...prev, [field]: value, @@ -153,6 +178,23 @@ export function AdminSettings() { })); }; + const addRelay = () => { + if (newRelay.trim()) { + setSettings((prev) => ({ + ...prev, + relays: [...(prev.relays || []), newRelay.trim()], + })); + setNewRelay(''); + } + }; + + const removeRelay = (index: number) => { + setSettings((prev) => ({ + ...prev, + relays: prev.relays?.filter((_, i) => i !== index) || [], + })); + }; + const renderSecretField = ( field: string, label: string, @@ -162,7 +204,7 @@ export function AdminSettings() { const displayValue = showSecrets ? value : value ? '••••••••' : ''; return ( -
+
{ + const label = key + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + if (typeof value === 'boolean') { + return ( +
+ + handleInputChange(key, checked)} + /> +
+ ); + } + + if (typeof value === 'number') { + return ( +
+ + { + const val = e.target.value === '' ? 0 : Number(e.target.value); + handleInputChange(key, val); + }} + /> +
+ ); + } + + if (Array.isArray(value)) { + const strValue = value.join(', '); + return ( +
+ +