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/balance.py b/routstr/balance.py index 76e87498..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(...)], @@ -152,14 +155,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 +178,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} @@ -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"], diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 234e8aad..e02647a1 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 ..payment.models import sync_models_with_api from ..wallet import ( fetch_all_balances, @@ -16,7 +17,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 @@ -164,6 +165,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 +229,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 +317,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() @@ -591,6 +675,9 @@ async def dashboard(request: Request) -> str: + @@ -833,7 +920,6 @@ async def withdraw( ) return {"token": token} - class SyncModelsRequest(BaseModel): delete_removed: bool = False @@ -864,6 +950,721 @@ async def sync_models(request: Request, sync_request: SyncModelsRequest) -> dict } +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.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)] +) +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; } @@ -899,7 +1700,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/core/main.py b/routstr/core/main.py index f06b9b9e..a0157f45 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 @@ -157,6 +157,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) diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 6dc4b8ff..c44d3010 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -11,7 +11,7 @@ from ..core import get_logger from ..core.db import ModelRow from ..core.settings import settings from ..wallet import deserialize_token_from_string -from .models import Pricing +from .models import Pricing, compute_effective_max_cost_msats logger = get_logger(__name__) @@ -72,7 +72,8 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000 ) - if max_cost_for_model > amount_msat: + fee_buffer = 60 + if max_cost_for_model > amount_msat + fee_buffer: raise HTTPException( status_code=413, detail={ @@ -133,16 +134,17 @@ async def get_max_cost_for_model( row = await session.get(ModelRow, model) if row and row.sats_pricing: try: - sats = Pricing(**json.loads(row.sats_pricing)) # type: ignore - max_cost = sats.max_cost * 1000 * (1 - settings.tolerance_percentage / 100) - logger.debug( - "Found model-specific max cost", - extra={"model": model, "max_cost_msats": max_cost}, - ) - calculated_msats = int(max_cost) - return max(settings.min_request_msat, calculated_msats) + sats_dict = json.loads(row.sats_pricing) except Exception: - pass + sats_dict = None + if isinstance(sats_dict, dict): + effective_msats = compute_effective_max_cost_msats(sats_dict) + if effective_msats > 0: + logger.debug( + "Found model-specific max cost", + extra={"model": model, "max_cost_msats": effective_msats}, + ) + return effective_msats logger.warning( "Model pricing not found, using fixed cost", @@ -201,6 +203,8 @@ async def calculate_discounted_max_cost( else: adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000) + adjusted = min(max_cost_for_model, adjusted) + logger.debug( "Discounted max cost computed", extra={ 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/payment/models.py b/routstr/payment/models.py index 1c0329cb..4e87d261 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -58,6 +58,39 @@ class Model(BaseModel): top_provider: TopProvider | None = None +def compute_effective_max_cost_msats( + pricing: Pricing | dict | None, +) -> int: + if pricing is None: + try: + return max(1, int(settings.min_request_msat)) + except Exception: + return 1 + + pricing_obj = ( + pricing if isinstance(pricing, Pricing) else Pricing.parse_obj(pricing) # type: ignore[arg-type] + ) + + try: + tolerance = float(getattr(settings, "tolerance_percentage", 0.0)) + except Exception: + tolerance = 0.0 + + tolerance_factor = max(0.0, 1.0 - tolerance / 100.0) + + try: + min_request_msat = max(1, int(settings.min_request_msat)) + except Exception: + min_request_msat = 1 + + base_msats = int(float(pricing_obj.max_cost or 0.0) * 1000.0 * tolerance_factor) + + if base_msats <= 0: + return min_request_msat + + return max(min_request_msat, base_msats) + + def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: """Fetches model information from OpenRouter API.""" base_url = "https://openrouter.ai/api/v1" @@ -83,6 +116,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 @@ -94,6 +130,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 +163,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 @@ -160,6 +210,10 @@ def _row_to_model(row: ModelRow) -> Model: except Exception: pass + if isinstance(sats_pricing, dict): + effective_msats = compute_effective_max_cost_msats(sats_pricing) + sats_pricing["max_cost"] = effective_msats / 1000.0 + return Model( id=row.id, name=row.name, @@ -240,7 +294,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 +303,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: @@ -455,6 +513,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: 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(), diff --git a/routstr/wallet.py b/routstr/wallet.py index d9c35666..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() @@ -299,7 +297,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 +317,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", 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 "