diff --git a/.gitignore b/.gitignore index ff38fa90..b643d7da 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ dist/ # Development .notes .*keys.db +*.db-shm +*.db-wal .*wallet.sqlite3 *models.json .cashu diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 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 b52fdde6..c6cc8ee0 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 @@ -163,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 @@ -205,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""" @@ -232,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() @@ -529,6 +613,9 @@ async def dashboard(request: Request) -> str: + @@ -750,6 +837,721 @@ 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.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; } @@ -785,7 +1587,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/db.py b/routstr/core/db.py index 9f886791..6bc3bd90 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -79,6 +79,8 @@ async def balances_for_mint_and_unit( async def init_db() -> None: """Initializes the database and creates tables if they don't exist.""" async with engine.begin() as conn: + if DATABASE_URL.startswith("sqlite"): + await conn.exec_driver_sql("PRAGMA journal_mode=WAL") await conn.run_sync(SQLModel.metadata.create_all) diff --git a/routstr/core/main.py b/routstr/core/main.py index b73b21b5..b5f81701 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -1,4 +1,5 @@ import asyncio +import os from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -30,7 +31,10 @@ from .settings import settings as global_settings setup_logging() logger = get_logger(__name__) -__version__ = "0.1.3" +if os.getenv("VERSION_SUFFIX") is not None: + __version__ = f"0.1.4-{os.getenv('VERSION_SUFFIX')}" +else: + __version__ = "0.1.4" @asynccontextmanager @@ -158,6 +162,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/core/settings.py b/routstr/core/settings.py index 35560a4c..9cdecbc0 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -64,7 +64,7 @@ class Settings(BaseSettings): default=120, env="PRICING_REFRESH_INTERVAL_SECONDS" ) models_refresh_interval_seconds: int = Field( - default=0, env="MODELS_REFRESH_INTERVAL_SECONDS" + default=360, 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") @@ -234,7 +234,7 @@ class SettingsService: merged_dict: dict[str, Any] = dict(env_resolved.dict()) merged_dict.update( - {k: v for k, v in db_json.items() if v not in (None, "")} + {k: v for k, v in db_json.items() if v not in (None, "", []) and v} ) # Ensure primary_mint is consistent with cashu_mints if not explicitly set 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 f21da6bf..44d23c0f 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 @@ -94,6 +97,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 +130,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 @@ -175,6 +192,29 @@ def _row_to_model(row: ModelRow) -> Model: def _model_to_row_payload(model: Model) -> dict[str, str | int | None]: + # Apply fees to USD pricing when storing in database + exchange_fee = settings.exchange_fee + upstream_provider_fee = settings.upstream_provider_fee + total_fee_multiplier = exchange_fee * upstream_provider_fee + + # Create adjusted pricing with fees applied + adjusted_pricing = model.pricing.dict() + for key in [ + "prompt", + "completion", + "request", + "image", + "web_search", + "internal_reasoning", + ]: + if key in adjusted_pricing: + adjusted_pricing[key] = adjusted_pricing[key] * total_fee_multiplier + + # Also adjust max costs if present + for key in ["max_prompt_cost", "max_completion_cost", "max_cost"]: + if key in adjusted_pricing: + adjusted_pricing[key] = adjusted_pricing[key] * total_fee_multiplier + return { "id": model.id, "name": model.name, @@ -182,7 +222,7 @@ def _model_to_row_payload(model: Model) -> dict[str, str | int | None]: "description": model.description, "context_length": model.context_length, "architecture": json.dumps(model.architecture.dict()), - "pricing": json.dumps(model.pricing.dict()), + "pricing": json.dumps(adjusted_pricing), "sats_pricing": json.dumps(model.sats_pricing.dict()) if model.sats_pricing else None, @@ -240,7 +280,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 +289,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 +434,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/payment/x_cashu.py b/routstr/payment/x_cashu.py index f671fbd8..dc1385ee 100644 --- a/routstr/payment/x_cashu.py +++ b/routstr/payment/x_cashu.py @@ -46,7 +46,7 @@ async def x_cashu_handler( ) return await forward_to_upstream( - request, path, headers, amount, unit, max_cost_for_model + request, path, headers, amount, unit, max_cost_for_model, mint ) except Exception as e: error_message = str(e) @@ -105,6 +105,7 @@ async def forward_to_upstream( amount: int, unit: str, max_cost_for_model: int, + mint: str, ) -> Response | StreamingResponse: """Forward request to upstream and handle the response.""" if path.startswith("v1/"): @@ -159,7 +160,7 @@ async def forward_to_upstream( }, ) - refund_token = await send_refund(amount - 60, unit) + refund_token = await send_refund(amount - 60, unit, mint) logger.info( "Refund processed for failed upstream request", @@ -197,7 +198,7 @@ async def forward_to_upstream( ) result = await handle_x_cashu_chat_completion( - response, amount, unit, max_cost_for_model + response, amount, unit, max_cost_for_model, mint ) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) @@ -242,7 +243,7 @@ async def forward_to_upstream( async def handle_x_cashu_chat_completion( - response: httpx.Response, amount: int, unit: str, max_cost_for_model: int + response: httpx.Response, amount: int, unit: str, max_cost_for_model: int, mint: str ) -> StreamingResponse | Response: """Handle both streaming and non-streaming chat completion responses with token-based pricing.""" logger.debug( @@ -267,11 +268,11 @@ async def handle_x_cashu_chat_completion( if is_streaming: return await handle_streaming_response( - content_str, response, amount, unit, max_cost_for_model + content_str, response, amount, unit, max_cost_for_model, mint ) else: return await handle_non_streaming_response( - content_str, response, amount, unit, max_cost_for_model + content_str, response, amount, unit, max_cost_for_model, mint ) except Exception as e: @@ -298,6 +299,7 @@ async def handle_streaming_response( amount: int, unit: str, max_cost_for_model: int, + mint: str, ) -> StreamingResponse: """Handle Server-Sent Events (SSE) streaming response.""" logger.debug( @@ -372,7 +374,7 @@ async def handle_streaming_response( }, ) - refund_token = await send_refund(refund_amount, unit) + refund_token = await send_refund(refund_amount, unit, mint) response_headers["X-Cashu"] = refund_token logger.info( @@ -424,6 +426,7 @@ async def handle_non_streaming_response( amount: int, unit: str, max_cost_for_model: int, + mint: str, ) -> Response: """Handle regular JSON response.""" logger.debug( @@ -484,7 +487,7 @@ async def handle_non_streaming_response( ) if refund_amount > 0: - refund_token = await send_refund(refund_amount, unit) + refund_token = await send_refund(refund_amount, unit, mint) response_headers["X-Cashu"] = refund_token logger.info( diff --git a/routstr/proxy.py b/routstr/proxy.py index aebf80a1..38b8fc45 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 @@ -527,6 +636,12 @@ async def proxy( if request_body: try: request_body_dict = json.loads(request_body) + + if "max_tokens" in request_body_dict: + raise HTTPException( + status_code=400, + detail={"error": "max_tokens must be an integer (without quotes)"}, + ) logger.debug( "Request body parsed", extra={ @@ -655,18 +770,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 +893,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_database_consistency.py b/tests/integration/test_database_consistency.py index 5c2bbe68..3faeef50 100644 --- a/tests/integration/test_database_consistency.py +++ b/tests/integration/test_database_consistency.py @@ -549,9 +549,9 @@ class TestPerformance: max_time = max(times) # Average should be well under 100ms - assert avg_time < 100, ( - f"{op_type} average time {avg_time}ms exceeds 100ms" - ) + assert ( + avg_time < 100 + ), f"{op_type} average time {avg_time}ms exceeds 100ms" # No single operation should exceed 200ms assert max_time < 200, f"{op_type} max time {max_time}ms exceeds 200ms" diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index f9d52c25..e885f8a5 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 "