diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 596f3f17..06799705 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -1,8 +1,6 @@ import asyncio import json import random -from pathlib import Path -from urllib.request import urlopen import httpx from fastapi import APIRouter, Depends @@ -89,41 +87,6 @@ def _has_valid_pricing(model: dict) -> bool: return True -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", ""): - continue - - if not _has_valid_pricing(model): - 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" @@ -189,69 +152,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") - - valid_models = [] - for model_data in models_data: - try: - model = Model(**model_data) # type: ignore - valid_models.append(model) - except Exception as e: - model_id = model_data.get("id", "unknown") - logger.warning(f"Skipping model {model_id} - validation failed: {e}") - - if len(valid_models) != len(models_data): - logger.warning( - f"Filtered out {len(models_data) - len(valid_models)} models with incomplete data" - ) - - return valid_models - - def _row_to_model( row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01 ) -> Model: @@ -480,57 +380,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 @@ -692,76 +541,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: 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 ( +
+ +