diff --git a/migrations/versions/614c0a740e68_add_provider_settings_to_upstream_.py b/migrations/versions/614c0a740e68_add_provider_settings_to_upstream_.py index 12549432..bf437082 100644 --- a/migrations/versions/614c0a740e68_add_provider_settings_to_upstream_.py +++ b/migrations/versions/614c0a740e68_add_provider_settings_to_upstream_.py @@ -6,7 +6,6 @@ Create Date: 2026-02-13 22:36:53.608737 """ import sqlalchemy as sa -import sqlmodel from alembic import op # revision identifiers, used by Alembic. diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 710626bc..75a43d49 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -809,6 +809,44 @@ class TopupRequest(BaseModel): amount: int +class TopupTokenRequest(BaseModel): + token: str + + +@admin_router.post( + "/api/upstream-providers/{provider_id}/topup-token", + dependencies=[Depends(require_admin_api)], +) +async def topup_provider_with_token( + provider_id: int, payload: TopupTokenRequest +) -> dict: + """Redeem a Cashu token for an upstream provider.""" + async with create_session() as session: + provider = await session.get(UpstreamProviderRow, provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + resp = await client.post( + f"{clean_url}/v1/balance/topup", + json={"cashu_token": payload.token}, + headers={"Authorization": f"Bearer {provider.api_key}"}, + ) + + if resp.status_code == 200: + return {"ok": True, "message": "Token redeemed successfully"} + else: + logger.error(f"Upstream token topup failed: {resp.text}") + try: + error_detail = resp.json() + except Exception: + error_detail = resp.text + return {"ok": False, "message": f"Upstream error: {error_detail}"} + + @admin_router.post( "/api/upstream-providers/{provider_id}/topup", dependencies=[Depends(require_admin_api)], @@ -835,7 +873,49 @@ async def initiate_provider_topup( f"Initiating top-up for provider {provider_id}", extra={"amount": payload.amount}, ) + + # For Routstr providers, we might be doing a Lightning top-up or a direct token transfer + if provider.provider_type == "routstr": + # UI sends sats for Routstr topup + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + # Proxy the request to upstream Routstr + # Use the actual API key from the database + resp = await client.post( + f"{clean_url}/v1/balance/lightning/invoice", + json={ + "amount_sats": int(payload.amount), + "purpose": "topup", + "api_key": provider.api_key, + }, + headers={"Authorization": f"Bearer {provider.api_key}"}, + ) + + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "topup_data": { + "payment_request": data.get("bolt11"), + "invoice_id": data.get("invoice_id"), + "status": "pending", + }, + } + else: + logger.error(f"Upstream topup request failed: {resp.text}") + # Check if it's JSON error + try: + error_detail = resp.json() + except Exception: + error_detail = resp.text + raise HTTPException( + status_code=resp.status_code, detail=error_detail + ) + topup_data = await upstream_instance.initiate_topup(payload.amount) + logger.info( "Top-up initiated successfully", extra={ @@ -886,6 +966,23 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj if not provider: raise HTTPException(status_code=404, detail="Provider not found") + # For Routstr providers, proxy the status check + if provider.provider_type == "routstr": + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + resp = await client.get( + f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status", + headers={"Authorization": f"Bearer {provider.api_key}"}, + ) + if resp.status_code == 200: + status_data = resp.json() + return {"ok": True, "paid": status_data.get("status") == "paid"} + else: + logger.error(f"Upstream status check failed: {resp.text}") + return {"ok": False, "paid": False} + upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -913,7 +1010,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj dependencies=[Depends(require_admin_api)], ) async def get_provider_balance(provider_id: int) -> dict[str, object]: - """Get the current account balance for the upstream provider.""" + """Get the current balance for an upstream provider account.""" from ..upstream.helpers import _instantiate_provider async with create_session() as session: @@ -921,6 +1018,27 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") + # For Routstr providers, proxy the balance check + if provider.provider_type == "routstr": + import httpx + + async with httpx.AsyncClient() as client: + clean_url = provider.base_url.rstrip("/") + resp = await client.get( + f"{clean_url}/v1/balance/info", + headers={"Authorization": f"Bearer {provider.api_key}"}, + ) + if resp.status_code == 200: + data = resp.json() + # Return balance in sats + balance = data.get("balance", 0) + if isinstance(balance, (int, float)): + return {"ok": True, "balance_data": balance // 1000} + return {"ok": True, "balance_data": balance} + else: + logger.error(f"Failed to fetch Routstr balance: {resp.text}") + return {"ok": False, "balance_data": None} + upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1090,3 +1208,71 @@ async def get_log_dates_api(request: Request) -> dict[str, object]: continue return {"dates": dates} + + +@admin_router.post( + "/api/upstream-providers/{provider_id}/routstr/refund", + dependencies=[Depends(require_admin_api)], +) +async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]: + """Refund balance from an upstream Routstr provider back to the local wallet.""" + from ..upstream.helpers import _instantiate_provider + from ..upstream.routstr import RoutstrUpstreamProvider + + async with create_session() as session: + provider_row = await session.get(UpstreamProviderRow, provider_id) + if not provider_row: + raise HTTPException(status_code=404, detail="Provider not found") + + if provider_row.provider_type != "routstr": + raise HTTPException( + status_code=400, detail="Refund only supported for Routstr providers" + ) + + provider = _instantiate_provider(provider_row) + if not isinstance(provider, RoutstrUpstreamProvider): + raise HTTPException(status_code=400, detail="Invalid provider instance") + + try: + # Request refund from upstream + data = await provider.refund_balance() + if "error" in data: + # If the upstream returned an OpenAI-style error (like the model unknown error) + # it means the request likely didn't even reach the refund endpoint handler + # but was intercepted by the proxy layer. + error_info = data.get("error", {}) + message = ( + error_info.get("message") + if isinstance(error_info, dict) + else str(error_info) + ) + return { + "ok": False, + "message": f"Upstream refund failed: {message}", + } + + token = data.get("token") + if not token: + return {"ok": False, "message": "Upstream did not return a token"} + + # Receive token into local wallet + from ..wallet import recieve_token + + try: + # Use current wallet to receive + await recieve_token(token) + return { + "ok": True, + "message": "Successfully received refund from upstream provider", + } + except Exception as e: + logger.error(f"Failed to receive refund token: {e}") + return { + "ok": False, + "message": f"Failed to receive refund token: {str(e)}", + "token": token, + } + + except Exception as e: + logger.exception(f"Refund failed for provider {provider_id}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/routstr/proxy.py b/routstr/proxy.py index 7f72ff75..b4d1f4ae 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -138,11 +138,6 @@ async def proxy( ) -> Response | StreamingResponse: headers = dict(request.headers) - if "x-cashu" not in headers and "authorization" not in headers.keys(): - return create_error_response( - "unauthorized", "Unauthorized", 401, request=request - ) - is_responses_api = path.startswith("v1/responses") or path.startswith("responses") request_body = await request.body() request_body_dict = parse_request_body_json(request_body, path) @@ -153,6 +148,7 @@ async def proxy( model_id = request_body_dict.get("model", "unknown") model_obj = get_model_instance(model_id) + if not model_obj: return create_error_response( "invalid_model", f"Model '{model_id}' not found", 400, request=request diff --git a/routstr/upstream/routstr.py b/routstr/upstream/routstr.py index 7fdd99fb..9cf38db8 100644 --- a/routstr/upstream/routstr.py +++ b/routstr/upstream/routstr.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Mapping +from typing import TYPE_CHECKING, Any import httpx @@ -147,3 +147,24 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider): extra={"url": url, "error": str(e)}, ) return [] + + async def refund_balance(self) -> dict[str, Any]: + """Request a refund from the upstream Routstr node. + + Returns: + Dict containing refund result and token + """ + url = f"{self.base_url}/v1/balance/refund" + headers = {"Authorization": f"Bearer {self.api_key}"} + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, timeout=30.0) + response.raise_for_status() + return response.json() + except Exception as e: + logger.error( + "Failed to request refund from upstream Routstr", + extra={"url": url, "error": str(e)}, + ) + return {"error": str(e)} diff --git a/ui/components/providers/RoutstrProviderCard.tsx b/ui/components/providers/RoutstrProviderCard.tsx index f07f72db..63a8c5f5 100644 --- a/ui/components/providers/RoutstrProviderCard.tsx +++ b/ui/components/providers/RoutstrProviderCard.tsx @@ -83,12 +83,6 @@ export function RoutstrProviderCard({ > {provider.enabled ? 'Enabled' : 'Disabled'} - - NIP-91 - {!hasMint && (