diff --git a/.env.example b/.env.example index f56007ee..8b2dbf96 100644 --- a/.env.example +++ b/.env.example @@ -44,7 +44,5 @@ HTTP_URL="" # Not used currently ONION_URL="XXX.onion" -RELAYS="wss://relay.damus.io,wss://relay.primal.net,wss://relay.snort.social,wss://relay.nostr.band" +RELAYS="wss://relay.damus.io,wss://relay.nostr.band" CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org" - -CURRENCY = "" diff --git a/pyproject.toml b/pyproject.toml index 97aada86..f192541e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi[standard]>=0.115", "aiosqlite>=0.20", - "sixty-nuts>=0.1.3", + "sixty-nuts>=0.1.4", "sqlmodel>=0.0.24", "httpx[socks]>=0.25.2", "greenlet>=3.2.1", diff --git a/router/account.py b/router/account.py index e87099d8..f9b4e6b9 100644 --- a/router/account.py +++ b/router/account.py @@ -76,6 +76,7 @@ async def refund_wallet_endpoint( status_code=400, detail="Balance too small to refund (less than 1 sat)" ) + # TODO: choose currency and mint based on what user has configured token = await wallet().send(remaining_balance_sats) result = {"msats": remaining_balance_msats, "recipient": None, "token": token} diff --git a/router/auth.py b/router/auth.py index bca663ec..1cb93dd0 100644 --- a/router/auth.py +++ b/router/auth.py @@ -6,10 +6,7 @@ from sqlmodel import col, update from .cashu import credit_balance from .db import ApiKey, AsyncSession -from .models import MODELS from .payment.cost_caculation import ( - COST_PER_REQUEST, - MODEL_BASED_PRICING, CostData, CostDataError, MaxCostData, @@ -101,15 +98,8 @@ async def validate_bearer_key( ) -async def pay_for_request( - key: ApiKey, - session: AsyncSession, - body: dict, -) -> None: - # Use global COST_PER_REQUEST as default, override if model-based pricing is enabled - cost_per_request = COST_PER_REQUEST - if MODEL_BASED_PRICING and MODELS: - cost_per_request = get_max_cost_for_model(model=body["model"]) +async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> None: + cost_per_request = get_max_cost_for_model(model=body["model"]) if key.balance < cost_per_request: raise HTTPException( diff --git a/router/cashu.py b/router/cashu.py index 3934db0c..7bf8518a 100644 --- a/router/cashu.py +++ b/router/cashu.py @@ -4,7 +4,7 @@ import time from typing import cast from sixty_nuts import Wallet -from sixty_nuts.mint import CurrencyUnit +from sixty_nuts.types import CurrencyUnit from sqlmodel import col, func, select, update from .db import ApiKey, AsyncSession, get_session @@ -17,7 +17,6 @@ PAYOUT_INTERVAL = int(os.environ.get("PAYOUT_INTERVAL", 300)) # Default 5 minut DEV_LN_ADDRESS = "routstr@minibits.cash" DEVS_DONATION_RATE = float(os.environ.get("DEVS_DONATION_RATE", 0.021)) # 2.1% NSEC = os.environ["NSEC"] # Nostr private key for the wallet -CURRENCY = cast(CurrencyUnit, os.environ.get("CURRENCY", "sat")) wallet_instance: Wallet | None = None @@ -98,16 +97,19 @@ async def periodic_payout() -> None: async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int: """Redeem a Cashu token and credit the amount to the API key balance.""" try: - amount_sats, _ = await wallet().redeem(cashu_token) + amount, unit = await wallet().redeem(cashu_token) except Exception as e: print(f"Error in credit_balance: {e}") # Ensure the balance cannot become negative if redeem fails return 0 - if amount_sats <= 0: + if amount <= 0: return 0 - amount_msats = amount_sats * 1000 + if unit == "msat": + amount_msats = amount + else: + amount_msats = amount * 1000 # Apply the balance change atomically to avoid race conditions when topping # up the same key concurrently. @@ -193,14 +195,15 @@ async def refund_balance(amount_msats: int, key: ApiKey, session: AsyncSession) return await wallet().send_to_lnurl(key.refund_address, amount=amount_sats) -async def x_cashu_refund(key: ApiKey, session: AsyncSession) -> str: - refund_token = await wallet().send(key.balance) +async def x_cashu_refund(key: ApiKey, session: AsyncSession, unit: CurrencyUnit) -> str: + refund_token = await wallet().send(key.balance, unit=unit) await session.delete(key) await session.commit() return refund_token async def redeem(cashu_token: str, lnurl: str) -> int: - amount_sats, _ = await wallet().redeem(cashu_token) - await wallet().send_to_lnurl(lnurl, amount=amount_sats) - return amount_sats + amount, unit = await wallet().redeem(cashu_token) + unit = cast(CurrencyUnit, unit) + await wallet().send_to_lnurl(lnurl, amount=amount, unit=unit) + return amount diff --git a/router/payment/helpers.py b/router/payment/helpers.py index efe4aa4e..5305cfb0 100644 --- a/router/payment/helpers.py +++ b/router/payment/helpers.py @@ -1,37 +1,58 @@ import base64 import json import os -from typing import Literal import cbor2 from fastapi import HTTPException, Response +from sixty_nuts.types import CurrencyUnit from router.models import MODELS -from router.payment.cost_caculation import COST_PER_REQUEST +from router.payment.cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING UPSTREAM_BASE_URL = os.environ["UPSTREAM_BASE_URL"] UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "") -def check_token_balance( - headers: dict, body: dict, unit: Literal["sat", "msat"] -) -> None: +def get_cost_per_request(model: str | None = None) -> int: + if MODEL_BASED_PRICING and MODELS and model: + return get_max_cost_for_model(model=model) + return COST_PER_REQUEST + + +def check_token_balance(headers: dict, body: dict) -> CurrencyUnit: if x_cashu := headers.get("x-cashu", None): cashu_token = x_cashu elif auth := headers.get("authorization", None): - cashu_token = auth.split(" ")[1] + cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else "" else: raise HTTPException(status_code=401, detail="Unauthorized") - COST_PER_REQUEST = get_max_cost_for_model(model=body["model"]) + + # Handle empty token + if not cashu_token: + raise HTTPException( + status_code=401, + detail={ + "error": { + "message": "API key or Cashu token required", + "type": "invalid_request_error", + "code": "missing_api_key", + } + }, + ) + + # Handle regular API keys (sk-*) + if cashu_token.startswith("sk-"): + # For regular API keys, return default unit + return "sat" + + cost = get_cost_per_request(model=body.get("model", None)) if cashu_token.startswith("cashuA"): _token = base64_token_json(cashu_token) amount = sum(p["amount"] for t in _token["token"] for p in t["proofs"]) - unit = _token["unit"] - if unit == "msat": - pass - elif unit == "sat": + unit: CurrencyUnit = _token["unit"] + if unit == "sat": amount *= 1000 - if amount < COST_PER_REQUEST: + if amount < cost: raise HTTPException(status_code=413, detail="Insufficient balance") elif cashu_token.startswith("cashuB"): _token = base64_token_cbor(cashu_token) @@ -39,10 +60,11 @@ def check_token_balance( unit = _token["u"] if unit == "sat": amount *= 1000 - if amount < COST_PER_REQUEST: + if amount < cost: raise HTTPException(status_code=413, detail="Insufficient balance") else: raise HTTPException(status_code=401, detail="Unauthorized") + return unit def base64_token_json(cashu_token: str) -> dict: @@ -66,6 +88,8 @@ def base64_token_cbor(cashu_token: str) -> dict: def get_max_cost_for_model(model: str) -> int: + if not MODEL_BASED_PRICING or not MODELS: + return COST_PER_REQUEST if model not in [model.id for model in MODELS]: return COST_PER_REQUEST for m in MODELS: diff --git a/router/payment/x_cashu.py b/router/payment/x_cashu.py index f3cb5b47..1b69c0b0 100644 --- a/router/payment/x_cashu.py +++ b/router/payment/x_cashu.py @@ -5,6 +5,7 @@ from typing import AsyncGenerator, Literal, cast import httpx from fastapi import BackgroundTasks, HTTPException, Request from fastapi.responses import Response, StreamingResponse +from sixty_nuts.types import CurrencyUnit from router.cashu import wallet from router.payment.cost_caculation import ( @@ -25,13 +26,13 @@ async def x_cashu_handler( request: Request, x_cashu_token: str, path: str ) -> Response | StreamingResponse: headers = dict(request.headers) - amount, _ = await redeem_token(x_cashu_token) + amount, unit = await redeem_token(x_cashu_token) headers = prepare_upstream_headers(dict(request.headers)) - return await forward_to_upstream(request, path, headers, amount) + return await forward_to_upstream(request, path, headers, amount, unit) async def forward_to_upstream( - request: Request, path: str, headers: dict, amount: int + request: Request, path: str, headers: dict, amount: int, unit: CurrencyUnit ) -> Response | StreamingResponse: """Forward request to upstream and handle the response.""" if path.startswith("v1/"): @@ -55,7 +56,7 @@ async def forward_to_upstream( ) if path.endswith("chat/completions"): - result = await handle_x_cashu_chat_completion(response, amount) + result = await handle_x_cashu_chat_completion(response, amount, unit) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) result.background = background_tasks @@ -85,7 +86,7 @@ async def forward_to_upstream( async def handle_x_cashu_chat_completion( - response: httpx.Response, amount: int + response: httpx.Response, amount: int, unit: CurrencyUnit ) -> StreamingResponse | Response: """Handle both streaming and non-streaming chat completion responses with token-based pricing.""" try: @@ -94,11 +95,11 @@ async def handle_x_cashu_chat_completion( is_streaming = content_str.startswith("data:") or "data:" in content_str if is_streaming: - print("Detected streaming response, processing SSE format") - return await handle_streaming_response(content_str, response, amount) + return await handle_streaming_response(content_str, response, amount, unit) else: - print("Detected non-streaming response, processing as JSON") - return await handle_non_streaming_response(content_str, response, amount) + return await handle_non_streaming_response( + content_str, response, amount, unit + ) except Exception as e: print(f"Error processing chat completion response: {e}") @@ -111,7 +112,7 @@ async def handle_x_cashu_chat_completion( async def handle_streaming_response( - content_str: str, response: httpx.Response, amount: int + content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit ) -> StreamingResponse: """Handle Server-Sent Events (SSE) streaming response.""" # For streaming responses, we'll extract the final usage data @@ -134,19 +135,16 @@ async def handle_streaming_response( except json.JSONDecodeError: continue - print(f"usage: {usage_data}") # If we found usage data, calculate cost and refund if usage_data and model: response_data = {"usage": usage_data, "model": model} try: cost_data = await get_cost(response_data) - print(f"Refunded {cost_data} msats") if cost_data: refund_amount = amount - cost_data.total_msats if refund_amount > 0: - refund_token = await send_refund(refund_amount) + refund_token = await send_refund(refund_amount, unit) response.headers["X-Cashu"] = refund_token - print(f"Refunded {refund_amount} msats") except Exception as e: print(f"Error calculating cost for streaming response: {e}") @@ -169,7 +167,7 @@ async def handle_streaming_response( async def handle_non_streaming_response( - content_str: str, response: httpx.Response, amount: int + content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit ) -> Response: """Handle regular JSON response.""" try: @@ -201,7 +199,7 @@ async def handle_non_streaming_response( refund_amount = amount - cost_data.total_msats print("refund: ", refund_amount) if refund_amount > 0: - refund_token = await send_refund(refund_amount) + refund_token = await send_refund(refund_amount, unit) response.headers["X-Cashu"] = refund_token print(f"Refunded {refund_amount} msats") @@ -266,9 +264,9 @@ async def redeem_token(x_cashu_token: str) -> tuple[int, Literal["sat", "msat"]] ) -async def send_refund(amount: int) -> str: +async def send_refund(amount: int, unit: CurrencyUnit, mint: str | None = None) -> str: try: - return await wallet().send(amount) + return await wallet().send(amount, unit=unit, mint_url=mint) except Exception as e: raise HTTPException( status_code=401, diff --git a/router/proxy.py b/router/proxy.py index 29239a69..9b501690 100644 --- a/router/proxy.py +++ b/router/proxy.py @@ -264,11 +264,11 @@ async def proxy( media_type="application/json", ) + # Check token balance for all requests to get currency unit + unit = check_token_balance(headers, request_body_dict) + # Handle authentication if x_cashu := headers.get("x-cashu", None): - # Check token balance before authentication for cashu tokens - if request_body_dict: - check_token_balance(headers, request_body_dict, "msat") return await x_cashu_handler(request, x_cashu, path) elif auth := headers.get("authorization", None): @@ -299,7 +299,7 @@ async def proxy( ) if response.status_code != 200 and key.refund_address == "X-CASHU": - refund_token = await x_cashu_refund(key, session) + refund_token = await x_cashu_refund(key, session, unit) response = Response( content=json.dumps( { @@ -318,7 +318,7 @@ async def proxy( return response if key.refund_address == "X-CASHU": - refund_token = await x_cashu_refund(key, session) + refund_token = await x_cashu_refund(key, session, unit) response.headers["X-Cashu"] = refund_token return response diff --git a/uv.lock b/uv.lock index 80b9d727..40e1e610 100644 --- a/uv.lock +++ b/uv.lock @@ -892,7 +892,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115" }, { name = "greenlet", specifier = ">=3.2.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, - { name = "sixty-nuts", specifier = ">=0.1.3" }, + { name = "sixty-nuts", specifier = ">=0.1.4" }, { name = "sqlmodel", specifier = ">=0.0.24" }, ] @@ -943,7 +943,7 @@ wheels = [ [[package]] name = "sixty-nuts" -version = "0.1.3" +version = "0.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bech32" }, @@ -954,9 +954,9 @@ dependencies = [ { name = "typer" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/a5/04c6b7c839fa641300911c4d10d59ac4a4c28ae23ec34b67e23fb9b4c212/sixty_nuts-0.1.3.tar.gz", hash = "sha256:b3bf69b916ba6aca03f0452d51829895f274a58f19f45c83cd32e9d8cc36dd53", size = 165092 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/24/ecab6ba4cf78ee4e32ddc1e820fdd72234850d80ccb365227ee46b5ddfe1/sixty_nuts-0.1.4.tar.gz", hash = "sha256:6b934fecee6bc8c2017313cfd2bc80e94bb9baa4ea1d176657c80b34030a52b9", size = 167747 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/7e/4a9be2b8252dddc96783b5a53c4a973012e2c19c375059c0a541ef58da54/sixty_nuts-0.1.3-py3-none-any.whl", hash = "sha256:47373e4533fcb79631d15d09b4225d4a56ace275c8d3474fabe953dcbc693f40", size = 89514 }, + { url = "https://files.pythonhosted.org/packages/ad/d6/66da093747f36728ebd5b7f1917241d3337cbd888c614454e61e134dcbbe/sixty_nuts-0.1.4-py3-none-any.whl", hash = "sha256:94ffd7fd28967413e141d7deb5b132d7e4ea6e952e35b9f3ce77d95812481145", size = 98617 }, ] [[package]]