diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index b52fdde6..234e8aad 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -8,6 +8,7 @@ from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlmodel import select
+from ..payment.models import sync_models_with_api
from ..wallet import (
fetch_all_balances,
get_proofs_per_mint_and_unit,
@@ -417,6 +418,64 @@ async def dashboard(request: Request) -> str:
window.location.href = `/admin/logs/${requestId}`;
}
+ function openSyncModelsModal() {
+ const modal = document.getElementById('sync-models-modal');
+ modal.style.display = 'block';
+ }
+
+ function closeSyncModelsModal() {
+ const modal = document.getElementById('sync-models-modal');
+ modal.style.display = 'none';
+ }
+
+ async function syncModels() {
+ const deleteRemoved = document.getElementById('delete-removed-models').checked;
+ const button = document.getElementById('sync-models-btn');
+ const resultDiv = document.getElementById('sync-models-result');
+
+ button.disabled = true;
+ button.textContent = 'Syncing...';
+ resultDiv.style.display = 'none';
+
+ try {
+ const response = await fetch('/admin/api/sync_models', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'same-origin',
+ body: JSON.stringify({
+ delete_removed: deleteRemoved
+ })
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ resultDiv.innerHTML = `✅ Success!
${data.message}`;
+ resultDiv.style.display = 'block';
+ resultDiv.style.backgroundColor = '#d4edda';
+ resultDiv.style.borderColor = '#c3e6cb';
+ resultDiv.style.color = '#155724';
+ } else {
+ const errorData = await response.json();
+ resultDiv.innerHTML = `❌ Error:
${errorData.detail || 'Unknown error'}`;
+ resultDiv.style.display = 'block';
+ resultDiv.style.backgroundColor = '#f8d7da';
+ resultDiv.style.borderColor = '#f5c6cb';
+ resultDiv.style.color = '#721c24';
+ }
+ } catch (error) {
+ resultDiv.innerHTML = `❌ Error:
${error.message}`;
+ resultDiv.style.display = 'block';
+ resultDiv.style.backgroundColor = '#f8d7da';
+ resultDiv.style.borderColor = '#f5c6cb';
+ resultDiv.style.color = '#721c24';
+ } finally {
+ button.disabled = false;
+ button.textContent = 'Sync Models';
+ }
+ }
+
async function openSettingsModal() {
const modal = document.getElementById('settings-modal');
const textarea = document.getElementById('settings-json');
@@ -498,12 +557,15 @@ async def dashboard(request: Request) -> str:
const withdrawModal = document.getElementById('withdraw-modal');
const investigateModal = document.getElementById('investigate-modal');
const settingsModal = document.getElementById('settings-modal');
+ const syncModelsModal = document.getElementById('sync-models-modal');
if (event.target == withdrawModal) {
closeWithdrawModal();
} else if (event.target == investigateModal) {
closeInvestigateModal();
} else if (event.target == settingsModal) {
closeSettingsModal();
+ } else if (event.target == syncModelsModal) {
+ closeSyncModelsModal();
}
}
@@ -532,6 +594,29 @@ async def dashboard(request: Request) -> str:
+
+
+
@@ -721,7 +806,6 @@ async def view_logs(request: Request, request_id: str) -> str:
async def withdraw(
request: Request, withdraw_request: WithdrawRequest
) -> dict[str, str]:
- # Get wallet and check balance
from .settings import settings as global_settings
wallet = await get_wallet(
@@ -750,6 +834,36 @@ async def withdraw(
return {"token": token}
+class SyncModelsRequest(BaseModel):
+ delete_removed: bool = False
+
+
+@admin_router.post("/api/sync_models", dependencies=[Depends(require_admin_api)])
+async def sync_models(request: Request, sync_request: SyncModelsRequest) -> dict:
+ try:
+ src = settings.source or None
+ source_filter = src if src and src.strip() else None
+ except Exception:
+ source_filter = None
+
+ logger.info(
+ "Manual models sync triggered",
+ extra={"delete_removed": sync_request.delete_removed},
+ )
+
+ counts = await sync_models_with_api(
+ source_filter=source_filter, delete_removed=sync_request.delete_removed
+ )
+
+ return {
+ "success": True,
+ "inserted": counts["inserted"],
+ "updated": counts["updated"],
+ "deleted": counts["deleted"],
+ "message": f"Synced: {counts['inserted']} inserted, {counts['updated']} updated, {counts['deleted']} deleted",
+ }
+
+
DASHBOARD_CSS: str = """
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; }
diff --git a/routstr/core/main.py b/routstr/core/main.py
index b73b21b5..f06b9b9e 100644
--- a/routstr/core/main.py
+++ b/routstr/core/main.py
@@ -67,8 +67,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
await ensure_models_bootstrapped()
pricing_task = asyncio.create_task(update_sats_pricing())
- if global_settings.models_refresh_interval_seconds > 0:
- models_refresh_task = asyncio.create_task(refresh_models_periodically())
+ models_refresh_task = asyncio.create_task(refresh_models_periodically())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
providers_task = asyncio.create_task(providers_cache_refresher())
diff --git a/routstr/core/settings.py b/routstr/core/settings.py
index 35560a4c..37df014d 100644
--- a/routstr/core/settings.py
+++ b/routstr/core/settings.py
@@ -64,10 +64,11 @@ class Settings(BaseSettings):
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
)
models_refresh_interval_seconds: int = Field(
- default=0, env="MODELS_REFRESH_INTERVAL_SECONDS"
+ default=30, env="MODELS_REFRESH_INTERVAL_SECONDS"
)
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
+ delete_removed_models: bool = Field(default=False, env="DELETE_REMOVED_MODELS")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
# Logging
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index f21da6bf..1c0329cb 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -379,11 +379,76 @@ async def update_sats_pricing() -> None:
break
+async def sync_models_with_api(
+ source_filter: str | None = None, delete_removed: bool = False
+) -> dict[str, int]:
+ """Fetch models from OpenRouter and sync with database.
+
+ Args:
+ source_filter: Optional source filter (e.g., 'anthropic')
+ delete_removed: If True, delete models that no longer exist in API
+
+ Returns:
+ Dict with counts: inserted, updated, deleted
+ """
+ models = fetch_openrouter_models(source_filter=source_filter)
+ if not models:
+ return {"inserted": 0, "updated": 0, "deleted": 0}
+
+ async with create_session() as s:
+ result = await s.exec(select(ModelRow)) # type: ignore
+ existing_rows = {row.id: row for row in result.all()}
+
+ fetched_ids = set()
+ inserted = 0
+ updated = 0
+
+ for m in models:
+ try:
+ model = Model(**m) # type: ignore
+ except Exception:
+ continue
+
+ fetched_ids.add(model.id)
+ payload = _model_to_row_payload(model)
+
+ if model.id not in existing_rows:
+ try:
+ s.add(ModelRow(**payload)) # type: ignore
+ inserted += 1
+ except Exception:
+ pass
+ else:
+ existing_row = existing_rows[model.id]
+ changed = False
+ for key, value in payload.items():
+ if getattr(existing_row, key) != value:
+ setattr(existing_row, key, value)
+ changed = True
+ if changed:
+ s.add(existing_row)
+ updated += 1
+
+ deleted = 0
+ if delete_removed:
+ for existing_id in existing_rows:
+ if existing_id not in fetched_ids:
+ row_to_delete = existing_rows[existing_id]
+ await s.delete(row_to_delete)
+ deleted += 1
+
+ if inserted or updated or deleted:
+ await s.commit()
+
+ return {"inserted": inserted, "updated": updated, "deleted": deleted}
+
+
async def refresh_models_periodically() -> None:
- """Background task: periodically fetch OpenRouter models and insert new ones.
+ """Background task: periodically fetch OpenRouter models and sync with database.
- Respects optional SOURCE filter from settings
- - Does not overwrite existing rows
+ - Updates existing models with new information
+ - Optionally deletes models no longer in API (if settings.delete_removed_models)
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
"""
interval = getattr(settings, "models_refresh_interval_seconds", 0)
@@ -403,33 +468,24 @@ async def refresh_models_periodically() -> None:
except Exception:
source_filter = None
- models = fetch_openrouter_models(source_filter=source_filter)
- if not models:
- await asyncio.sleep(interval)
- continue
+ try:
+ delete_removed = getattr(settings, "delete_removed_models", False)
+ except Exception:
+ delete_removed = False
- 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")
+ counts = await sync_models_with_api(
+ source_filter=source_filter, delete_removed=delete_removed
+ )
+
+ if counts["inserted"] or counts["updated"] or counts["deleted"]:
+ logger.info(
+ "Models synced",
+ extra={
+ "inserted": counts["inserted"],
+ "updated": counts["updated"],
+ "deleted": counts["deleted"],
+ },
+ )
except asyncio.CancelledError:
break
except Exception as e: