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… |
+
+
+
+
+
+
+
+
+
+
+
×
+
Edit Model:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Architecture (JSON)
+
+
+
Pricing (JSON)
+
+
+
Per Request Limits (JSON, optional) — leave blank to clear
+
+
+
Top Provider (JSON, optional) — leave blank to clear
+
+
+
+
+
+
+
+
+
+
+
+
×
+
Create Model
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Architecture (JSON)
+
+
+
Pricing (JSON)
+
+
+
Per Request Limits (JSON, optional)
+
+
+
Top Provider (JSON, optional)
+
+
+
+
+
+
+
+
+
+
+
+
×
+
Batch Add Models
+
Paste JSON in the format like models.example.json
+
+
+
+
+
+
+
+
+
+
+ """
+ )
+
+
+@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 "