From 5fb583be5583e6c3ee6f043e08eb8351d5338dce Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 11:11:36 +0100 Subject: [PATCH 01/40] v0.1.4-dev --- docs/api/overview.md | 2 +- docs/contributing/code-structure.md | 2 +- docs/getting-started/quickstart.md | 2 +- pyproject.toml | 2 +- routstr/core/main.py | 2 +- uv.lock | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 47abbd7a..16d8d77f 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -347,7 +347,7 @@ GET /health Response: { "status": "healthy", - "version": "0.1.3", + "version": "0.1.4", "timestamp": "2024-01-01T00:00:00Z", "checks": { "database": "ok", diff --git a/docs/contributing/code-structure.md b/docs/contributing/code-structure.md index 4b141b20..be2df5d5 100644 --- a/docs/contributing/code-structure.md +++ b/docs/contributing/code-structure.md @@ -348,7 +348,7 @@ Project metadata and dependencies: ```toml [project] name = "routstr" -version = "0.1.3" +version = "0.1.4" dependencies = [ "fastapi[standard]>=0.115", "sqlmodel>=0.0.24", diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index e179adcb..ba5d6751 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -67,7 +67,7 @@ You should see: { "name": "ARoutstrNode", "description": "A Routstr Node", - "version": "0.1.3", + "version": "0.1.4", "npub": "", "mints": ["https://mint.minibits.cash/Bitcoin"], "models": {...} diff --git a/pyproject.toml b/pyproject.toml index 1f554101..8d5843b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.1.3" +version = "0.1.4" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" diff --git a/routstr/core/main.py b/routstr/core/main.py index b73b21b5..91a800f4 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -30,7 +30,7 @@ from .settings import settings as global_settings setup_logging() logger = get_logger(__name__) -__version__ = "0.1.3" +__version__ = "0.1.4-dev" @asynccontextmanager diff --git a/uv.lock b/uv.lock index b3f3170e..f8a0ed42 100644 --- a/uv.lock +++ b/uv.lock @@ -1783,7 +1783,7 @@ wheels = [ [[package]] name = "routstr" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From c5289364e4c9d91180db728c521e9f6fa2ddf6e3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 12:47:39 +0100 Subject: [PATCH 02/40] models admin dashboard --- routstr/core/admin.py | 589 +++++++++++++++++++++++++++++++++++++- routstr/payment/models.py | 27 +- 2 files changed, 612 insertions(+), 4 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index b52fdde6..7961797f 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 Model, get_model_by_id, list_models from ..wallet import ( fetch_all_balances, get_proofs_per_mint_and_unit, @@ -15,7 +16,7 @@ from ..wallet import ( send_token, slow_filter_spend_proofs, ) -from .db import ApiKey, create_session +from .db import ApiKey, ModelRow, create_session from .logging import get_logger from .settings import SettingsService, settings @@ -529,6 +530,9 @@ async def dashboard(request: Request) -> str: + @@ -750,6 +754,587 @@ async def withdraw( return {"token": token} +DASHBOARD_MODELS_JS: str = """ + +""" + + +def models_page() -> str: + return ( + f""" + + + + {DASHBOARD_MODELS_JS} + + """ + + """ + + ← Back to Dashboard +

Models

+ +
+

Models Table

+
+ + +
+ + + + + + + + + + +
ID
Loading…
+
+ +
+
+ + + + + + + """ + ) + + +@admin_router.get("/models", response_class=HTMLResponse) +async def admin_models(request: Request) -> str: + if is_admin_authenticated(request): + return models_page() + return admin_auth() + + +@admin_router.get("/api/models", dependencies=[Depends(require_admin_api)]) +async def get_models_admin_api(request: Request) -> list[dict[str, object]]: + items = await list_models() + return [m.dict() for m in items] # type: ignore + + +@admin_router.post("/api/models", dependencies=[Depends(require_admin_api)]) +async def create_model_admin_api(payload: Model) -> dict[str, object]: + async with create_session() as session: + exists = await session.get(ModelRow, payload.id) + if exists: + raise HTTPException( + status_code=409, detail="Model with this ID already exists" + ) + pricing_dict = payload.pricing.dict() + for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): + pricing_dict.pop(k, None) + row = ModelRow( + id=payload.id, + name=payload.name, + description=payload.description, + created=int(payload.created), + context_length=int(payload.context_length), + architecture=json.dumps(payload.architecture.dict()), + pricing=json.dumps(pricing_dict), + sats_pricing=None, + per_request_limits=( + json.dumps(payload.per_request_limits) + if payload.per_request_limits is not None + else None + ), + top_provider=( + json.dumps(payload.top_provider.dict()) + if payload.top_provider + else None + ), + ) + session.add(row) + await session.commit() + + created_model = await get_model_by_id(payload.id) + return created_model.dict() if created_model else {"id": payload.id} # type: ignore + + +@admin_router.get( + "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] +) +async def get_model_admin_api(model_id: str) -> dict[str, object]: + model = await get_model_by_id(model_id) + if not model: + raise HTTPException(status_code=404, detail="Model not found") + return model.dict() # type: ignore + + +@admin_router.patch( + "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] +) +async def update_model_admin_api(model_id: str, payload: Model) -> dict[str, object]: + if payload.id != model_id: + raise HTTPException(status_code=400, detail="Path id does not match payload id") + + async with create_session() as session: + row = await session.get(ModelRow, model_id) + if not row: + raise HTTPException(status_code=404, detail="Model not found") + + row.name = payload.name + row.description = payload.description + row.created = int(payload.created) + row.context_length = int(payload.context_length) + row.architecture = json.dumps(payload.architecture.dict()) + pricing_dict = payload.pricing.dict() + for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): + pricing_dict.pop(k, None) + row.pricing = json.dumps(pricing_dict) + row.sats_pricing = None + row.per_request_limits = ( + json.dumps(payload.per_request_limits) + if payload.per_request_limits is not None + else None + ) + row.top_provider = ( + json.dumps(payload.top_provider.dict()) if payload.top_provider else None + ) + + session.add(row) + await session.commit() + + updated = await get_model_by_id(model_id) + if not updated: + raise HTTPException(status_code=404, detail="Model not found after update") + return updated.dict() # type: ignore + + +@admin_router.delete( + "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] +) +async def delete_model_admin_api(model_id: str) -> dict[str, object]: + async with create_session() as session: + row = await session.get(ModelRow, model_id) + if not row: + raise HTTPException(status_code=404, detail="Model not found") + await session.delete(row) + await session.commit() + return {"ok": True, "deleted_id": model_id} + + +@admin_router.delete("/api/models", dependencies=[Depends(require_admin_api)]) +async def delete_all_models_admin_api() -> dict[str, object]: + async with create_session() as session: + result = await session.exec(select(ModelRow)) # type: ignore + rows = result.all() + for row in rows: + await session.delete(row) # type: ignore + await session.commit() + return {"ok": True, "deleted": "all"} + + 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; } @@ -785,7 +1370,7 @@ button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; } .copy-btn { background: #38a169; padding: 6px 12px; font-size: 14px; } .copy-btn:hover { background: #2f855a; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); } -.modal-content { background: white; margin: 10% auto; padding: 2rem; width: 90%; max-width: 400px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } +.modal-content { background: white; margin: 5% auto; padding: 0.75rem 1rem 2.25rem; width: 90%; max-width: 720px; max-height: 85vh; overflow-y: auto; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; } .close:hover { color: #2d3748; } diff --git a/routstr/payment/models.py b/routstr/payment/models.py index f21da6bf..7c7f951b 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -94,6 +94,14 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: return [] +def is_openrouter_upstream() -> bool: + try: + base = (settings.upstream_base_url or "").strip().rstrip("/") + except Exception: + return False + 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. @@ -119,7 +127,13 @@ def load_models() -> list[Model]: logger.error(f"Error loading models from {models_path}: {e}") # Fall through to auto-generation - # Auto-generate models from OpenRouter API + # 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 @@ -240,7 +254,7 @@ async def ensure_models_bootstrapped() -> None: except Exception as e: logger.error(f"Error loading models from {models_path}: {e}") - if not models_to_insert: + if not models_to_insert and is_openrouter_upstream(): logger.info("Bootstrapping models from OpenRouter API") source_filter = None try: @@ -249,6 +263,10 @@ async def ensure_models_bootstrapped() -> 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: @@ -390,6 +408,11 @@ async def refresh_models_periodically() -> None: 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: From 18a055ee2c821152853fec64a4ab73387aca89cf Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 12:58:53 +0100 Subject: [PATCH 03/40] add models.json batch paste --- routstr/core/admin.py | 134 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 7961797f..0b1245e1 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -988,6 +988,10 @@ DASHBOARD_MODELS_JS: str = """ if (event.target == createModal) { closeCreateModel(); } + const batchModal = document.getElementById('model-batch-modal'); + if (event.target == batchModal) { + closeBatchModal(); + } } function openCreateModel() { @@ -1089,6 +1093,71 @@ DASHBOARD_MODELS_JS: str = """ btn.textContent = '➕ Create'; } } + + function openBatchModal() { + const modal = document.getElementById('model-batch-modal'); + if (!modal) { alert('Batch modal not found'); return; } + const err = document.getElementById('batch-error'); + if (err) { err.style.display = 'none'; err.textContent = ''; } + const textarea = document.getElementById('batch-json'); + if (textarea && !textarea.value.trim()) { + const sample = { + models: [ + { + id: 'provider/model-id', + name: 'Model Name', + description: 'Description', + created: Math.floor(Date.now()/1000), + context_length: 0, + architecture: { modality: 'text', input_modalities: ['text'], output_modalities: ['text'], tokenizer: '', instruct_type: null }, + pricing: { prompt: 0.0, completion: 0.0, request: 0.0, image: 0.0, web_search: 0.0, internal_reasoning: 0.0 }, + per_request_limits: null, + top_provider: null + } + ] + }; + textarea.value = JSON.stringify(sample, null, 2); + } + modal.style.display = 'block'; + } + + function closeBatchModal() { + const modal = document.getElementById('model-batch-modal'); + if (modal) modal.style.display = 'none'; + } + + async function performBatchAdd() { + const textarea = document.getElementById('batch-json'); + const err = document.getElementById('batch-error'); + const btn = document.getElementById('batch-submit-btn'); + if (err) { err.style.display = 'none'; err.textContent = ''; } + if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; } + try { + if (!textarea) throw new Error('Input not found'); + const data = JSON.parse(textarea.value); + if (!data || !Array.isArray(data.models) || data.models.length === 0) { + throw new Error('Payload must include a non-empty "models" array'); + } + const resp = await fetch('/admin/api/models/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(data) + }); + if (!resp.ok) { + let errText = 'Failed to add models'; + try { const e = await resp.json(); if (e && e.detail) errText = typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail); } catch(_) {} + throw new Error(errText); + } + closeBatchModal(); + await fetchModels(); + } catch (e) { + if (err) { err.style.display = 'block'; err.textContent = e.message || String(e); } + else { alert(e.message || String(e)); } + } finally { + if (btn) { btn.disabled = false; btn.textContent = '➕ Add Models'; } + } + } """ @@ -1126,6 +1195,7 @@ def models_page() -> str:
+
@@ -1204,6 +1274,20 @@ def models_page() -> str: + + """ @@ -1261,6 +1345,56 @@ async def create_model_admin_api(payload: Model) -> dict[str, object]: return created_model.dict() if created_model else {"id": payload.id} # type: ignore +@admin_router.post("/api/models/batch", dependencies=[Depends(require_admin_api)]) +async def batch_create_models(payload: dict[str, object]) -> dict[str, int]: + models = payload.get("models") + if not isinstance(models, list) or not models: + raise HTTPException( + status_code=400, detail="Payload must include non-empty 'models' array" + ) + created = 0 + skipped = 0 + async with create_session() as session: + for m in models: + try: + model = Model(**m) # type: ignore[arg-type] + except Exception: + skipped += 1 + continue + exists = await session.get(ModelRow, model.id) + if exists: + skipped += 1 + continue + pricing_dict = model.pricing.dict() + for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): + pricing_dict.pop(k, None) + row = ModelRow( + id=model.id, + name=model.name, + description=model.description, + created=int(model.created), + context_length=int(model.context_length), + architecture=json.dumps(model.architecture.dict()), + pricing=json.dumps(pricing_dict), + sats_pricing=None, + per_request_limits=( + json.dumps(model.per_request_limits) + if model.per_request_limits is not None + else None + ), + top_provider=( + json.dumps(model.top_provider.dict()) + if model.top_provider + else None + ), + ) + session.add(row) + created += 1 + if created: + await session.commit() + return {"created": created, "skipped": skipped} + + @admin_router.get( "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] ) From 8e0ae6cbc878ec2806a04bcd0b3e722503b53c79 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 13:46:07 +0100 Subject: [PATCH 04/40] filter out openrouter spam models --- routstr/payment/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 7c7f951b..c064a8df 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -83,6 +83,9 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: "(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" ): continue From c2a26a3c009d80d89225b7307667904d9f6dbca3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 16:36:35 +0100 Subject: [PATCH 05/40] v1/providers redirect to v1/providers/ --- routstr/core/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routstr/core/main.py b/routstr/core/main.py index 91a800f4..1669a7bd 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -158,6 +158,11 @@ async def admin_redirect() -> RedirectResponse: return RedirectResponse("/admin/") +@app.get("/v1/providers") +async def providers() -> RedirectResponse: + return RedirectResponse("/v1/providers/") + + app.include_router(models_router) app.include_router(admin_router) app.include_router(balance_router) From d4e171561377fe9c416ad4f8d70d7b0b656efa19 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 16:36:52 +0100 Subject: [PATCH 06/40] improved upstream error message handling --- routstr/proxy.py | 127 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/routstr/proxy.py b/routstr/proxy.py index aebf80a1..89660837 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -30,6 +30,104 @@ logger = get_logger(__name__) proxy_router = APIRouter() +def _extract_upstream_error_message(body_bytes: bytes) -> tuple[str, str | None]: + """Extract a human-friendly message and optional upstream error code from a response body.""" + message: str = "Upstream request failed" + upstream_code: str | None = None + if not body_bytes: + return message, upstream_code + try: + data = json.loads(body_bytes) + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + raw_msg = err.get("message") or err.get("detail") or err.get("error") + if isinstance(raw_msg, (str, int, float)): + message = str(raw_msg) + upstream_code_raw = err.get("code") or err.get("type") + if isinstance(upstream_code_raw, (str, int, float)): + upstream_code = str(upstream_code_raw) + elif "message" in data and isinstance(data["message"], (str, int, float)): + message = str(data["message"]) # type: ignore[arg-type] + elif "detail" in data and isinstance(data["detail"], (str, int, float)): + message = str(data["detail"]) # type: ignore[arg-type] + except Exception: + preview = body_bytes.decode("utf-8", errors="ignore").strip() + if preview: + message = preview[:500] + return message, upstream_code + + +async def map_upstream_error_response( + request: Request, + path: str, + upstream_response: httpx.Response, +) -> Response: + """Map upstream non-200 responses to standardized error responses. + + - Known cases are mapped to friendly messages and appropriate status codes + - Unknown errors are converted to a generic 502 + """ + status_code = upstream_response.status_code + headers = dict(upstream_response.headers) + content_type = headers.get("content-type", "") + try: + body_bytes = await upstream_response.aread() + except Exception: + body_bytes = b"" + + message, upstream_code = _extract_upstream_error_message(body_bytes) + lowered_message = message.lower() + lowered_code = (upstream_code or "").lower() + + error_type = "upstream_error" + mapped_status = 502 + + # Specific mappings + if status_code in (400, 422): + error_type = "invalid_request_error" + mapped_status = 400 + elif status_code in (401, 403): + error_type = "upstream_auth_error" + mapped_status = 502 + elif status_code == 404: + # Many providers return 404 for unknown models or routes + if path.endswith("chat/completions"): + error_type = "invalid_model" + mapped_status = 400 + if not message or message == "Upstream request failed": + message = "Requested model is not available upstream" + elif "model" in lowered_message or "model" in lowered_code: + error_type = "invalid_model" + mapped_status = 400 + if not message or message == "Upstream request failed": + message = "Requested model is not available upstream" + else: + error_type = "upstream_error" + mapped_status = 502 + elif status_code == 429: + error_type = "rate_limit_exceeded" + mapped_status = 429 + elif status_code >= 500: + error_type = "upstream_error" + mapped_status = 502 + + # Include upstream content type hint in logs for diagnostics + logger.debug( + "Mapped upstream error", + extra={ + "path": path, + "upstream_status": status_code, + "mapped_status": mapped_status, + "error_type": error_type, + "upstream_content_type": content_type, + "message_preview": message[:200], + }, + ) + + return create_error_response(error_type, message, mapped_status, request=request) + + async def handle_streaming_chat_completion( response: httpx.Response, key: ApiKey, max_cost_for_model: int ) -> StreamingResponse: @@ -362,6 +460,17 @@ async def forward_to_upstream( }, ) + # Map and return errors immediately to provide clear messages + if response.status_code != 200: + try: + mapped_error = await map_upstream_error_response( + request, path, response + ) + finally: + await response.aclose() + await client.aclose() + return mapped_error + # For chat completions, we need to handle token-based pricing if path.endswith("chat/completions"): # Check if client requested streaming @@ -655,18 +764,10 @@ async def proxy( "upstream_headers": response.headers if hasattr(response, "headers") else None, - "upstream_response": response.body - if hasattr(response, "body") - else None, }, ) - request_id = ( - request.state.request_id if hasattr(request.state, "request_id") else None - ) - raise HTTPException( - status_code=502, - detail=f"Upstream request failed, please contact support with request id: {request_id}", - ) + # Return the mapped error response generated earlier rather than masking with 502 + return response return response @@ -786,6 +887,12 @@ async def forward_get_to_upstream( "GET request forwarded successfully", extra={"path": path, "status_code": response.status_code}, ) + if response.status_code != 200: + try: + mapped = await map_upstream_error_response(request, path, response) + finally: + await response.aclose() + return mapped return StreamingResponse( response.aiter_bytes(), From 150f3084c1b239f805b8ae8d725a89dd6a5c2184 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 16:40:48 +0100 Subject: [PATCH 07/40] remove need for ADMIN_PASSWORD, added in initial setup --- routstr/core/admin.py | 85 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 0b1245e1..c6cc8ee0 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -164,6 +164,28 @@ async def update_settings(request: Request, update: SettingsUpdate) -> dict: return data +class SetupRequest(BaseModel): + password: str + + +@admin_router.post("/api/setup") +async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]: + try: + current = SettingsService.get() + except Exception: + current = settings + if getattr(current, "admin_password", ""): + raise HTTPException(status_code=409, detail="Admin password already set") + pw = (payload.password or "").strip() + if len(pw) < 8: + raise HTTPException( + status_code=400, detail="Password must be at least 8 characters" + ) + async with create_session() as session: + await SettingsService.update({"admin_password": pw}, session) + return {"ok": True} + + class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None @@ -206,6 +228,67 @@ def login_form() -> str: """ +def setup_form() -> str: + return """ + + + + + + +
+

🔧 Initial Admin Setup

+

Create a secure password for your admin dashboard.

+
+ + + +
+
+
+ + + """ + + def info(content: str) -> str: return f""" @@ -233,7 +316,7 @@ def admin_auth() -> str: except Exception: admin_pw = os.getenv("ADMIN_PASSWORD", "") if admin_pw == "": - return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.") + return setup_form() else: return login_form() From 54d7a5a247081d770ec9c6043545d64a7afa6575 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 17:54:28 +0100 Subject: [PATCH 08/40] fix periodic payouts --- routstr/payment/lnurl.py | 15 ++++++++++++++- routstr/wallet.py | 8 ++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index 49bc8d65..04395311 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -230,7 +230,11 @@ async def get_lnurl_invoice( async def raw_send_to_lnurl( - wallet: Wallet, proofs: list[Proof], lnurl: str, unit: str + wallet: Wallet, + proofs: list[Proof], + lnurl: str, + unit: str, + amount: int | None = None, ) -> int: """Send funds to an LNURL address. @@ -255,6 +259,11 @@ async def raw_send_to_lnurl( paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd") """ total_balance = sum(proof.amount for proof in proofs) + if amount and total_balance < amount: + raise ValueError("Amount to send is higher than available proofs.") + else: + assert isinstance(amount, int) + total_balance = amount lnurl_data = await get_lnurl_data(lnurl) if unit == "sat": @@ -285,6 +294,10 @@ async def raw_send_to_lnurl( melt_quote_resp = await wallet.melt_quote( invoice=bolt11_invoice, amount_msat=final_amount ) + + if amount: + proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True) + _ = await wallet.melt( proofs=proofs, invoice=bolt11_invoice, diff --git a/routstr/wallet.py b/routstr/wallet.py index c00b5b82..95334320 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -299,7 +299,7 @@ async def periodic_payout() -> None: logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout") return while True: - await asyncio.sleep(60 * 5) + await asyncio.sleep(60 * 15) try: async with db.create_session() as session: for mint_url in settings.cashu_mints: @@ -319,7 +319,11 @@ async def periodic_payout() -> None: min_amount = 210 if unit == "sat" else 210000 if available_balance > min_amount: amount_received = await raw_send_to_lnurl( - wallet, proofs, settings.receive_ln_address, unit + wallet, + proofs, + settings.receive_ln_address, + unit, + amount=available_balance, ) logger.info( "Payout sent successfully", From 729f00caa2496eb257e5e3824fcea270648879ee Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:55:16 +0000 Subject: [PATCH 09/40] Fixed the bug where refund amount is below 1 sat for a sat mint --- routstr/balance.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 76e87498..23c41f55 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -152,14 +152,19 @@ async def refund_wallet_endpoint( key: ApiKey = await validate_bearer_key(bearer_value, session) remaining_balance_msats: int = key.balance - if remaining_balance_msats <= 0: + if key.refund_currency == "sat": + remaining_balance = remaining_balance_msats // 1000 + else: + remaining_balance = remaining_balance_msats + + if remaining_balance_msats > 0 and remaining_balance <= 0: + raise HTTPException(status_code=400, detail="Balance too small to refund") + elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") # Perform refund operation first, before modifying balance try: if key.refund_address: - if key.refund_currency == "sat": - remaining_balance = remaining_balance_msats // 1000 from .core.settings import settings as global_settings await send_to_lnurl( @@ -170,14 +175,9 @@ async def refund_wallet_endpoint( ) result = {"recipient": key.refund_address} else: - refund_amount = ( - remaining_balance_msats // 1000 - if key.refund_currency == "sat" - else remaining_balance_msats - ) refund_currency = key.refund_currency or "sat" token = await send_token( - refund_amount, refund_currency, key.refund_mint_url + remaining_balance, refund_currency, key.refund_mint_url ) result = {"token": token} From f8090f5c35348c3a84475c4ff276f03d2ade1d4c Mon Sep 17 00:00:00 2001 From: Shroominic Date: Thu, 2 Oct 2025 14:55:02 +0800 Subject: [PATCH 10/40] dontations endpoint --- routstr/balance.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 23c41f55..883c4673 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -8,12 +8,15 @@ from pydantic import BaseModel from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session +from .core.logging import get_logger from .core.settings import settings -from .wallet import credit_balance, send_to_lnurl, send_token +from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token router = APIRouter() balance_router = APIRouter(prefix="/v1/balance") +logger = get_logger(__name__) + async def get_key_from_header( authorization: Annotated[str, Header(...)], @@ -156,7 +159,7 @@ async def refund_wallet_endpoint( remaining_balance = remaining_balance_msats // 1000 else: remaining_balance = remaining_balance_msats - + if remaining_balance_msats > 0 and remaining_balance <= 0: raise HTTPException(status_code=400, detail="Balance too small to refund") elif remaining_balance <= 0: @@ -210,6 +213,19 @@ async def refund_wallet_endpoint( return result +@router.post("/donate") +async def donate(token: str, ref: str | None = None) -> str: + try: + amount, unit, _ = await recieve_token(token) + if ref: + logger.info( + "donation received", extra={"ref": ref, "amount": amount, "unit": unit} + ) + return "Thanks!" + except Exception: + return "Invalid token." + + @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], From 325abad736f35e01ea1ef7017e3845a4357a31b7 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 4 Oct 2025 20:49:45 +0800 Subject: [PATCH 11/40] ruff fmt --- routstr/wallet.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index c2bd83d0..fe34d7bb 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -152,9 +152,7 @@ async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wal global _wallets id = f"{mint_url}_{unit}" if id not in _wallets: - _wallets[id] = await Wallet.with_db( - mint_url, db=".wallet", unit=unit - ) + _wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit) if load: await _wallets[id].load_mint() From c56a06f3df9c2eb979f0e041b963fca5983f117f Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 4 Oct 2025 20:49:53 +0800 Subject: [PATCH 12/40] fix tests --- tests/integration/conftest.py | 1 + .../test_general_info_endpoints.py | 25 +++++-------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 311214f6..0c7d3ffa 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -63,6 +63,7 @@ else: # Set test environment variables before importing the app os.environ.update(test_env) +os.environ.pop("ADMIN_PASSWORD", None) from routstr.core.db import ApiKey, get_session # noqa: E402 from routstr.core.main import app, lifespan # noqa: E402 diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index f9d52c25..cfde36e1 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -271,36 +271,23 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) - async def test_admin_endpoint_unauthenticated( integration_client: AsyncClient, db_snapshot: Any ) -> None: - """Test GET /admin/ endpoint without authentication""" - - # Capture initial database state + """Test GET /admin/ endpoint without authentication shows setup form""" await db_snapshot.capture() response = await integration_client.get("/admin/") - # Should return 200 with login form (not 401/403) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] - # Response should be HTML html_content = response.text assert "" in html_content assert "" in html_content + assert "" in html_content or " +""" + + +def models_page() -> str: + return ( + f""" + + + + {DASHBOARD_MODELS_JS} + + """ + + """ + + ← Back to Dashboard +

Models

+ +
+

Models Table

+
+ + +
+ + + + + + + + + + +
ID
Loading…
+
+ +
+
+ + + + + + + """ + ) + + +@admin_router.get("/models", response_class=HTMLResponse) +async def admin_models(request: Request) -> str: + if is_admin_authenticated(request): + return models_page() + return admin_auth() + + +@admin_router.get("/api/models", dependencies=[Depends(require_admin_api)]) +async def get_models_admin_api(request: Request) -> list[dict[str, object]]: + items = await list_models() + return [m.dict() for m in items] # type: ignore + + +@admin_router.post("/api/models", dependencies=[Depends(require_admin_api)]) +async def create_model_admin_api(payload: Model) -> dict[str, object]: + async with create_session() as session: + exists = await session.get(ModelRow, payload.id) + if exists: + raise HTTPException( + status_code=409, detail="Model with this ID already exists" + ) + pricing_dict = payload.pricing.dict() + for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): + pricing_dict.pop(k, None) + row = ModelRow( + id=payload.id, + name=payload.name, + description=payload.description, + created=int(payload.created), + context_length=int(payload.context_length), + architecture=json.dumps(payload.architecture.dict()), + pricing=json.dumps(pricing_dict), + sats_pricing=None, + per_request_limits=( + json.dumps(payload.per_request_limits) + if payload.per_request_limits is not None + else None + ), + top_provider=( + json.dumps(payload.top_provider.dict()) + if payload.top_provider + else None + ), + ) + session.add(row) + await session.commit() + + created_model = await get_model_by_id(payload.id) + return created_model.dict() if created_model else {"id": payload.id} # type: ignore + + +@admin_router.get( + "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] +) +async def get_model_admin_api(model_id: str) -> dict[str, object]: + model = await get_model_by_id(model_id) + if not model: + raise HTTPException(status_code=404, detail="Model not found") + return model.dict() # type: ignore + + +@admin_router.patch( + "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] +) +async def update_model_admin_api(model_id: str, payload: Model) -> dict[str, object]: + if payload.id != model_id: + raise HTTPException(status_code=400, detail="Path id does not match payload id") + + async with create_session() as session: + row = await session.get(ModelRow, model_id) + if not row: + raise HTTPException(status_code=404, detail="Model not found") + + row.name = payload.name + row.description = payload.description + row.created = int(payload.created) + row.context_length = int(payload.context_length) + row.architecture = json.dumps(payload.architecture.dict()) + pricing_dict = payload.pricing.dict() + for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): + pricing_dict.pop(k, None) + row.pricing = json.dumps(pricing_dict) + row.sats_pricing = None + row.per_request_limits = ( + json.dumps(payload.per_request_limits) + if payload.per_request_limits is not None + else None + ) + row.top_provider = ( + json.dumps(payload.top_provider.dict()) if payload.top_provider else None + ) + + session.add(row) + await session.commit() + + updated = await get_model_by_id(model_id) + if not updated: + raise HTTPException(status_code=404, detail="Model not found after update") + return updated.dict() # type: ignore + + +@admin_router.delete( + "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] +) +async def delete_model_admin_api(model_id: str) -> dict[str, object]: + async with create_session() as session: + row = await session.get(ModelRow, model_id) + if not row: + raise HTTPException(status_code=404, detail="Model not found") + await session.delete(row) + await session.commit() + return {"ok": True, "deleted_id": model_id} + + +@admin_router.delete("/api/models", dependencies=[Depends(require_admin_api)]) +async def delete_all_models_admin_api() -> dict[str, object]: + async with create_session() as session: + result = await session.exec(select(ModelRow)) # type: ignore + rows = result.all() + for row in rows: + await session.delete(row) # type: ignore + await session.commit() + return {"ok": True, "deleted": "all"} + + 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; } @@ -785,7 +1370,7 @@ button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; } .copy-btn { background: #38a169; padding: 6px 12px; font-size: 14px; } .copy-btn:hover { background: #2f855a; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); } -.modal-content { background: white; margin: 10% auto; padding: 2rem; width: 90%; max-width: 400px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } +.modal-content { background: white; margin: 5% auto; padding: 0.75rem 1rem 2.25rem; width: 90%; max-width: 720px; max-height: 85vh; overflow-y: auto; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; } .close:hover { color: #2d3748; } diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 3e1104b5..215a454a 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -94,6 +94,14 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: return [] +def is_openrouter_upstream() -> bool: + try: + base = (settings.upstream_base_url or "").strip().rstrip("/") + except Exception: + return False + 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. @@ -119,7 +127,13 @@ def load_models() -> list[Model]: logger.error(f"Error loading models from {models_path}: {e}") # Fall through to auto-generation - # Auto-generate models from OpenRouter API + # 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 @@ -256,7 +270,7 @@ async def ensure_models_bootstrapped() -> None: except Exception as e: logger.error(f"Error loading models from {models_path}: {e}") - if not models_to_insert: + if not models_to_insert and is_openrouter_upstream(): logger.info("Bootstrapping models from OpenRouter API") source_filter = None try: @@ -265,6 +279,10 @@ async def ensure_models_bootstrapped() -> 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: @@ -406,6 +424,11 @@ async def refresh_models_periodically() -> None: 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: From 98aa8864775b6c0bfc672a679468b8e0ebbe91c3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 12:58:53 +0100 Subject: [PATCH 22/40] add models.json batch paste --- routstr/core/admin.py | 134 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 7961797f..0b1245e1 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -988,6 +988,10 @@ DASHBOARD_MODELS_JS: str = """ if (event.target == createModal) { closeCreateModel(); } + const batchModal = document.getElementById('model-batch-modal'); + if (event.target == batchModal) { + closeBatchModal(); + } } function openCreateModel() { @@ -1089,6 +1093,71 @@ DASHBOARD_MODELS_JS: str = """ btn.textContent = '➕ Create'; } } + + function openBatchModal() { + const modal = document.getElementById('model-batch-modal'); + if (!modal) { alert('Batch modal not found'); return; } + const err = document.getElementById('batch-error'); + if (err) { err.style.display = 'none'; err.textContent = ''; } + const textarea = document.getElementById('batch-json'); + if (textarea && !textarea.value.trim()) { + const sample = { + models: [ + { + id: 'provider/model-id', + name: 'Model Name', + description: 'Description', + created: Math.floor(Date.now()/1000), + context_length: 0, + architecture: { modality: 'text', input_modalities: ['text'], output_modalities: ['text'], tokenizer: '', instruct_type: null }, + pricing: { prompt: 0.0, completion: 0.0, request: 0.0, image: 0.0, web_search: 0.0, internal_reasoning: 0.0 }, + per_request_limits: null, + top_provider: null + } + ] + }; + textarea.value = JSON.stringify(sample, null, 2); + } + modal.style.display = 'block'; + } + + function closeBatchModal() { + const modal = document.getElementById('model-batch-modal'); + if (modal) modal.style.display = 'none'; + } + + async function performBatchAdd() { + const textarea = document.getElementById('batch-json'); + const err = document.getElementById('batch-error'); + const btn = document.getElementById('batch-submit-btn'); + if (err) { err.style.display = 'none'; err.textContent = ''; } + if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; } + try { + if (!textarea) throw new Error('Input not found'); + const data = JSON.parse(textarea.value); + if (!data || !Array.isArray(data.models) || data.models.length === 0) { + throw new Error('Payload must include a non-empty "models" array'); + } + const resp = await fetch('/admin/api/models/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(data) + }); + if (!resp.ok) { + let errText = 'Failed to add models'; + try { const e = await resp.json(); if (e && e.detail) errText = typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail); } catch(_) {} + throw new Error(errText); + } + closeBatchModal(); + await fetchModels(); + } catch (e) { + if (err) { err.style.display = 'block'; err.textContent = e.message || String(e); } + else { alert(e.message || String(e)); } + } finally { + if (btn) { btn.disabled = false; btn.textContent = '➕ Add Models'; } + } + } """ @@ -1126,6 +1195,7 @@ def models_page() -> str:
+
@@ -1204,6 +1274,20 @@ def models_page() -> str: + + """ @@ -1261,6 +1345,56 @@ async def create_model_admin_api(payload: Model) -> dict[str, object]: return created_model.dict() if created_model else {"id": payload.id} # type: ignore +@admin_router.post("/api/models/batch", dependencies=[Depends(require_admin_api)]) +async def batch_create_models(payload: dict[str, object]) -> dict[str, int]: + models = payload.get("models") + if not isinstance(models, list) or not models: + raise HTTPException( + status_code=400, detail="Payload must include non-empty 'models' array" + ) + created = 0 + skipped = 0 + async with create_session() as session: + for m in models: + try: + model = Model(**m) # type: ignore[arg-type] + except Exception: + skipped += 1 + continue + exists = await session.get(ModelRow, model.id) + if exists: + skipped += 1 + continue + pricing_dict = model.pricing.dict() + for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): + pricing_dict.pop(k, None) + row = ModelRow( + id=model.id, + name=model.name, + description=model.description, + created=int(model.created), + context_length=int(model.context_length), + architecture=json.dumps(model.architecture.dict()), + pricing=json.dumps(pricing_dict), + sats_pricing=None, + per_request_limits=( + json.dumps(model.per_request_limits) + if model.per_request_limits is not None + else None + ), + top_provider=( + json.dumps(model.top_provider.dict()) + if model.top_provider + else None + ), + ) + session.add(row) + created += 1 + if created: + await session.commit() + return {"created": created, "skipped": skipped} + + @admin_router.get( "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] ) From 401728582f6d8c3c29ea5818b5a15399c3eee9c5 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 13:46:07 +0100 Subject: [PATCH 23/40] filter out openrouter spam models --- routstr/payment/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 215a454a..7ce3c245 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -83,6 +83,9 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: "(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" ): continue From 78821929a687ee654e2d91cb6a30d49872bf244d Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 16:36:35 +0100 Subject: [PATCH 24/40] v1/providers redirect to v1/providers/ --- routstr/core/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routstr/core/main.py b/routstr/core/main.py index 91a800f4..1669a7bd 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -158,6 +158,11 @@ async def admin_redirect() -> RedirectResponse: return RedirectResponse("/admin/") +@app.get("/v1/providers") +async def providers() -> RedirectResponse: + return RedirectResponse("/v1/providers/") + + app.include_router(models_router) app.include_router(admin_router) app.include_router(balance_router) From d69a8f76e1d063cd61dcf94d1d9b242f881f88e5 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 16:36:52 +0100 Subject: [PATCH 25/40] improved upstream error message handling --- routstr/proxy.py | 127 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/routstr/proxy.py b/routstr/proxy.py index aebf80a1..89660837 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -30,6 +30,104 @@ logger = get_logger(__name__) proxy_router = APIRouter() +def _extract_upstream_error_message(body_bytes: bytes) -> tuple[str, str | None]: + """Extract a human-friendly message and optional upstream error code from a response body.""" + message: str = "Upstream request failed" + upstream_code: str | None = None + if not body_bytes: + return message, upstream_code + try: + data = json.loads(body_bytes) + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + raw_msg = err.get("message") or err.get("detail") or err.get("error") + if isinstance(raw_msg, (str, int, float)): + message = str(raw_msg) + upstream_code_raw = err.get("code") or err.get("type") + if isinstance(upstream_code_raw, (str, int, float)): + upstream_code = str(upstream_code_raw) + elif "message" in data and isinstance(data["message"], (str, int, float)): + message = str(data["message"]) # type: ignore[arg-type] + elif "detail" in data and isinstance(data["detail"], (str, int, float)): + message = str(data["detail"]) # type: ignore[arg-type] + except Exception: + preview = body_bytes.decode("utf-8", errors="ignore").strip() + if preview: + message = preview[:500] + return message, upstream_code + + +async def map_upstream_error_response( + request: Request, + path: str, + upstream_response: httpx.Response, +) -> Response: + """Map upstream non-200 responses to standardized error responses. + + - Known cases are mapped to friendly messages and appropriate status codes + - Unknown errors are converted to a generic 502 + """ + status_code = upstream_response.status_code + headers = dict(upstream_response.headers) + content_type = headers.get("content-type", "") + try: + body_bytes = await upstream_response.aread() + except Exception: + body_bytes = b"" + + message, upstream_code = _extract_upstream_error_message(body_bytes) + lowered_message = message.lower() + lowered_code = (upstream_code or "").lower() + + error_type = "upstream_error" + mapped_status = 502 + + # Specific mappings + if status_code in (400, 422): + error_type = "invalid_request_error" + mapped_status = 400 + elif status_code in (401, 403): + error_type = "upstream_auth_error" + mapped_status = 502 + elif status_code == 404: + # Many providers return 404 for unknown models or routes + if path.endswith("chat/completions"): + error_type = "invalid_model" + mapped_status = 400 + if not message or message == "Upstream request failed": + message = "Requested model is not available upstream" + elif "model" in lowered_message or "model" in lowered_code: + error_type = "invalid_model" + mapped_status = 400 + if not message or message == "Upstream request failed": + message = "Requested model is not available upstream" + else: + error_type = "upstream_error" + mapped_status = 502 + elif status_code == 429: + error_type = "rate_limit_exceeded" + mapped_status = 429 + elif status_code >= 500: + error_type = "upstream_error" + mapped_status = 502 + + # Include upstream content type hint in logs for diagnostics + logger.debug( + "Mapped upstream error", + extra={ + "path": path, + "upstream_status": status_code, + "mapped_status": mapped_status, + "error_type": error_type, + "upstream_content_type": content_type, + "message_preview": message[:200], + }, + ) + + return create_error_response(error_type, message, mapped_status, request=request) + + async def handle_streaming_chat_completion( response: httpx.Response, key: ApiKey, max_cost_for_model: int ) -> StreamingResponse: @@ -362,6 +460,17 @@ async def forward_to_upstream( }, ) + # Map and return errors immediately to provide clear messages + if response.status_code != 200: + try: + mapped_error = await map_upstream_error_response( + request, path, response + ) + finally: + await response.aclose() + await client.aclose() + return mapped_error + # For chat completions, we need to handle token-based pricing if path.endswith("chat/completions"): # Check if client requested streaming @@ -655,18 +764,10 @@ async def proxy( "upstream_headers": response.headers if hasattr(response, "headers") else None, - "upstream_response": response.body - if hasattr(response, "body") - else None, }, ) - request_id = ( - request.state.request_id if hasattr(request.state, "request_id") else None - ) - raise HTTPException( - status_code=502, - detail=f"Upstream request failed, please contact support with request id: {request_id}", - ) + # Return the mapped error response generated earlier rather than masking with 502 + return response return response @@ -786,6 +887,12 @@ async def forward_get_to_upstream( "GET request forwarded successfully", extra={"path": path, "status_code": response.status_code}, ) + if response.status_code != 200: + try: + mapped = await map_upstream_error_response(request, path, response) + finally: + await response.aclose() + return mapped return StreamingResponse( response.aiter_bytes(), From 1557a7c58d072f2a796dfae8c4ec504bdd249cfc Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 16:40:48 +0100 Subject: [PATCH 26/40] remove need for ADMIN_PASSWORD, added in initial setup --- routstr/core/admin.py | 85 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 0b1245e1..c6cc8ee0 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -164,6 +164,28 @@ async def update_settings(request: Request, update: SettingsUpdate) -> dict: return data +class SetupRequest(BaseModel): + password: str + + +@admin_router.post("/api/setup") +async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]: + try: + current = SettingsService.get() + except Exception: + current = settings + if getattr(current, "admin_password", ""): + raise HTTPException(status_code=409, detail="Admin password already set") + pw = (payload.password or "").strip() + if len(pw) < 8: + raise HTTPException( + status_code=400, detail="Password must be at least 8 characters" + ) + async with create_session() as session: + await SettingsService.update({"admin_password": pw}, session) + return {"ok": True} + + class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None @@ -206,6 +228,67 @@ def login_form() -> str: """ +def setup_form() -> str: + return """ + + + + + + +
+

🔧 Initial Admin Setup

+

Create a secure password for your admin dashboard.

+
+ + + +
+
+
+ + + """ + + def info(content: str) -> str: return f""" @@ -233,7 +316,7 @@ def admin_auth() -> str: except Exception: admin_pw = os.getenv("ADMIN_PASSWORD", "") if admin_pw == "": - return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.") + return setup_form() else: return login_form() From 764bc3d7e8254e2c9b52cb0ba6aec8839da67f36 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 22 Sep 2025 17:54:28 +0100 Subject: [PATCH 27/40] fix periodic payouts --- routstr/payment/lnurl.py | 15 ++++++++++++++- routstr/wallet.py | 8 ++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index 49bc8d65..04395311 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -230,7 +230,11 @@ async def get_lnurl_invoice( async def raw_send_to_lnurl( - wallet: Wallet, proofs: list[Proof], lnurl: str, unit: str + wallet: Wallet, + proofs: list[Proof], + lnurl: str, + unit: str, + amount: int | None = None, ) -> int: """Send funds to an LNURL address. @@ -255,6 +259,11 @@ async def raw_send_to_lnurl( paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd") """ total_balance = sum(proof.amount for proof in proofs) + if amount and total_balance < amount: + raise ValueError("Amount to send is higher than available proofs.") + else: + assert isinstance(amount, int) + total_balance = amount lnurl_data = await get_lnurl_data(lnurl) if unit == "sat": @@ -285,6 +294,10 @@ async def raw_send_to_lnurl( melt_quote_resp = await wallet.melt_quote( invoice=bolt11_invoice, amount_msat=final_amount ) + + if amount: + proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True) + _ = await wallet.melt( proofs=proofs, invoice=bolt11_invoice, diff --git a/routstr/wallet.py b/routstr/wallet.py index d9c35666..c2bd83d0 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -299,7 +299,7 @@ async def periodic_payout() -> None: logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout") return while True: - await asyncio.sleep(60 * 5) + await asyncio.sleep(60 * 15) try: async with db.create_session() as session: for mint_url in settings.cashu_mints: @@ -319,7 +319,11 @@ async def periodic_payout() -> None: min_amount = 210 if unit == "sat" else 210000 if available_balance > min_amount: amount_received = await raw_send_to_lnurl( - wallet, proofs, settings.receive_ln_address, unit + wallet, + proofs, + settings.receive_ln_address, + unit, + amount=available_balance, ) logger.info( "Payout sent successfully", From da6e0a4c59b7b2085f2765099fe8d061a28413ca Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:55:16 +0000 Subject: [PATCH 28/40] Fixed the bug where refund amount is below 1 sat for a sat mint --- routstr/balance.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 76e87498..23c41f55 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -152,14 +152,19 @@ async def refund_wallet_endpoint( key: ApiKey = await validate_bearer_key(bearer_value, session) remaining_balance_msats: int = key.balance - if remaining_balance_msats <= 0: + if key.refund_currency == "sat": + remaining_balance = remaining_balance_msats // 1000 + else: + remaining_balance = remaining_balance_msats + + if remaining_balance_msats > 0 and remaining_balance <= 0: + raise HTTPException(status_code=400, detail="Balance too small to refund") + elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") # Perform refund operation first, before modifying balance try: if key.refund_address: - if key.refund_currency == "sat": - remaining_balance = remaining_balance_msats // 1000 from .core.settings import settings as global_settings await send_to_lnurl( @@ -170,14 +175,9 @@ async def refund_wallet_endpoint( ) result = {"recipient": key.refund_address} else: - refund_amount = ( - remaining_balance_msats // 1000 - if key.refund_currency == "sat" - else remaining_balance_msats - ) refund_currency = key.refund_currency or "sat" token = await send_token( - refund_amount, refund_currency, key.refund_mint_url + remaining_balance, refund_currency, key.refund_mint_url ) result = {"token": token} From a33193e4a35f50a70e4368095080d0917254807c Mon Sep 17 00:00:00 2001 From: Shroominic Date: Thu, 2 Oct 2025 14:55:02 +0800 Subject: [PATCH 29/40] dontations endpoint --- routstr/balance.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 23c41f55..883c4673 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -8,12 +8,15 @@ from pydantic import BaseModel from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session +from .core.logging import get_logger from .core.settings import settings -from .wallet import credit_balance, send_to_lnurl, send_token +from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token router = APIRouter() balance_router = APIRouter(prefix="/v1/balance") +logger = get_logger(__name__) + async def get_key_from_header( authorization: Annotated[str, Header(...)], @@ -156,7 +159,7 @@ async def refund_wallet_endpoint( remaining_balance = remaining_balance_msats // 1000 else: remaining_balance = remaining_balance_msats - + if remaining_balance_msats > 0 and remaining_balance <= 0: raise HTTPException(status_code=400, detail="Balance too small to refund") elif remaining_balance <= 0: @@ -210,6 +213,19 @@ async def refund_wallet_endpoint( return result +@router.post("/donate") +async def donate(token: str, ref: str | None = None) -> str: + try: + amount, unit, _ = await recieve_token(token) + if ref: + logger.info( + "donation received", extra={"ref": ref, "amount": amount, "unit": unit} + ) + return "Thanks!" + except Exception: + return "Invalid token." + + @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], From 6c66a37dfa78dd5a7d779deffb14d9f50b1134f3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 4 Oct 2025 20:49:45 +0800 Subject: [PATCH 30/40] ruff fmt --- routstr/wallet.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index c2bd83d0..fe34d7bb 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -152,9 +152,7 @@ async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wal global _wallets id = f"{mint_url}_{unit}" if id not in _wallets: - _wallets[id] = await Wallet.with_db( - mint_url, db=".wallet", unit=unit - ) + _wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit) if load: await _wallets[id].load_mint() From 8d9c9d93ebf82f1a21bbdf2bc3f7cec420b30ce2 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 4 Oct 2025 20:49:53 +0800 Subject: [PATCH 31/40] fix tests --- tests/integration/conftest.py | 1 + .../test_general_info_endpoints.py | 25 +++++-------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 311214f6..0c7d3ffa 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -63,6 +63,7 @@ else: # Set test environment variables before importing the app os.environ.update(test_env) +os.environ.pop("ADMIN_PASSWORD", None) from routstr.core.db import ApiKey, get_session # noqa: E402 from routstr.core.main import app, lifespan # noqa: E402 diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index f9d52c25..cfde36e1 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -271,36 +271,23 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) - async def test_admin_endpoint_unauthenticated( integration_client: AsyncClient, db_snapshot: Any ) -> None: - """Test GET /admin/ endpoint without authentication""" - - # Capture initial database state + """Test GET /admin/ endpoint without authentication shows setup form""" await db_snapshot.capture() response = await integration_client.get("/admin/") - # Should return 200 with login form (not 401/403) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] - # Response should be HTML html_content = response.text assert "" in html_content assert "" in html_content + assert "" in html_content or "