alot payment and auth changes

This commit is contained in:
Shroominic
2025-04-28 14:28:38 +08:00
parent 5a8d4ed31e
commit 1dc6dff96b
3 changed files with 154 additions and 111 deletions
+53 -87
View File
@@ -6,8 +6,8 @@ from typing import Literal
from fastapi import HTTPException
from pydantic import BaseModel, Field
from .redeem import redeem
from .db import ApiKey, create_session
from .redeem import credit_balance
from .db import ApiKey, AsyncSession
from .price import btc_usd_ask_price
RECIEIVE_LN_ADDRESS = os.environ["RECIEIVE_LN_ADDRESS"]
@@ -21,74 +21,53 @@ COST_PER_1K_OUTPUT_TOKENS = (
MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true"
def _hash_api_key(api_key: str) -> str:
"""Hashes the API key using SHA256."""
return hashlib.sha256(api_key.encode()).hexdigest()
async def validate_api_key(api_key: str) -> ApiKey:
async def validate_bearer_key(bearer_key: str, session: AsyncSession) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
"""
if not api_key:
if not bearer_key:
raise HTTPException(status_code=401, detail="api-key or cashu-token required")
hashed_key = _hash_api_key(api_key)
if bearer_key.startswith("sk-"):
if exsisting_key := await session.get(ApiKey, bearer_key[3:]):
return exsisting_key
async with create_session() as session:
if key := await session.get(ApiKey, hashed_key):
return key
if api_key.startswith("cashu"):
try:
# Redeem the original cashu key
amount = await redeem(api_key, RECIEIVE_LN_ADDRESS)
amount_msats = amount * 1000 # Convert sats to msats
# Store the hash and the redeemed amount using SQLModel
new_key = ApiKey(hashed_key=hashed_key, balance=amount_msats)
session.add(new_key)
await session.commit()
await session.refresh(new_key)
return new_key
except Exception as e:
print(f"Redemption failed: {e}")
raise HTTPException(
status_code=401, detail=f"Invalid or expired cashu key: {e}"
)
if api_key.startswith("sk-"):
if exsisting_key := await session.get(ApiKey, api_key[3:]):
if bearer_key.startswith("cashu"):
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
if exsisting_key := await session.get(ApiKey, hashed_key):
return exsisting_key
raise HTTPException(status_code=401, detail="Invalid API key")
async def pay_for_request(api_key: str) -> None:
"""Deducts the cost of a request from the balance associated with the API key hash using SQLModel."""
hashed_key = _hash_api_key(api_key)
async with create_session() as session:
key_record = await session.get(ApiKey, hashed_key)
if not key_record: # This should not happen
raise HTTPException(status_code=401, detail="API key not validated")
if key_record.balance < COST_PER_REQUEST:
new_key = ApiKey(hashed_key=hashed_key, balance=0)
await credit_balance(bearer_key, new_key, session)
await session.refresh(new_key)
return new_key
except Exception as e:
print(f"Redemption failed: {e}")
raise HTTPException(
status_code=402, detail="Insufficient balance"
) # 402 Payment Required
# Charge the base cost for the request
key_record.balance -= COST_PER_REQUEST
key_record.total_spent += COST_PER_REQUEST
key_record.total_requests += 1
session.add(key_record)
await session.commit()
await session.refresh(key_record)
status_code=401, detail=f"Invalid or expired cashu key: {e}"
)
print(bearer_key)
raise HTTPException(status_code=401, detail="Invalid API key")
async def adjust_payment_for_tokens(api_key: str, response_data: dict) -> dict:
async def pay_for_request(key: ApiKey, session: AsyncSession) -> None:
if key.balance < COST_PER_REQUEST:
raise HTTPException(status_code=402, detail="Insufficient balance")
# Charge the base cost for the request
key.balance -= COST_PER_REQUEST
key.total_spent += COST_PER_REQUEST
key.total_requests += 1
session.add(key)
await session.commit()
await session.refresh(key)
async def adjust_payment_for_tokens(
key: ApiKey, response_data: dict, session: AsyncSession
) -> dict:
"""
Adjusts the payment based on token usage in the response.
This is called after the initial payment and the upstream request is complete.
@@ -131,39 +110,26 @@ async def adjust_payment_for_tokens(api_key: str, response_data: dict) -> dict:
if cost_difference == 0:
return cost_data # No adjustment needed
hashed_key = _hash_api_key(api_key)
async with create_session() as session:
key_record = await session.get(ApiKey, hashed_key)
if key_record is None:
if cost_difference > 0:
# Need to charge more
if key.balance < cost_difference:
print(
f"Warning: API key not found when adjusting payment: {hashed_key[:10]}..."
f"Warning: Insufficient balance for token-based pricing adjustment: {key.hashed_key[:10]}..."
)
return cost_data
if cost_difference > 0:
# Need to charge more
if key_record.balance < cost_difference:
print(
f"Warning: Insufficient balance for token-based pricing adjustment: {hashed_key[:10]}..."
)
# Still proceed but log the issue - we already provided the service
else:
key_record.balance -= cost_difference
key_record.total_spent += cost_difference
cost_data["total_msats"] = COST_PER_REQUEST + cost_difference
# Still proceed but log the issue - we already provided the service
else:
# Refund some of the base cost
refund = abs(cost_difference)
key_record.balance += refund
key_record.total_spent -= refund
cost_data["total_msats"] = COST_PER_REQUEST - refund
key.balance -= cost_difference
key.total_spent += cost_difference
cost_data["total_msats"] = COST_PER_REQUEST + cost_difference
else:
# Refund some of the base cost
refund = abs(cost_difference)
key.balance += refund
key.total_spent -= refund
cost_data["total_msats"] = COST_PER_REQUEST - refund
session.add(key_record)
await session.commit()
print("cost_data:", cost_data)
session.add(key)
await session.commit()
return cost_data
+13 -8
View File
@@ -1,11 +1,12 @@
import os
import json
from fastapi import APIRouter, Request, BackgroundTasks
from fastapi import APIRouter, Request, BackgroundTasks, Depends
from fastapi.responses import Response, StreamingResponse
import httpx
import re
from .auth import validate_api_key, pay_for_request, adjust_payment_for_tokens
from .auth import validate_bearer_key, pay_for_request, adjust_payment_for_tokens
from .db import AsyncSession, get_session
UPSTREAM_BASE_URL = os.environ["UPSTREAM_BASE_URL"]
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "")
@@ -16,12 +17,14 @@ proxy_router = APIRouter()
@proxy_router.api_route(
"/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"]
)
async def proxy(request: Request, path: str):
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
):
auth = request.headers.get("Authorization", "")
api_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
await validate_api_key(api_key)
await pay_for_request(api_key)
key = await validate_bearer_key(bearer_key, session)
await pay_for_request(key, session)
# Prepare headers, removing sensitive/problematic ones
headers = dict(request.headers)
@@ -100,7 +103,7 @@ async def proxy(request: Request, path: str):
):
# Found usage data, calculate cost
cost_data = await adjust_payment_for_tokens(
api_key, data
key, data, session
)
# Format as SSE and yield
cost_json = json.dumps({"cost": cost_data})
@@ -136,7 +139,9 @@ async def proxy(request: Request, path: str):
try:
content = await response.aread()
response_json = json.loads(content)
cost_data = await adjust_payment_for_tokens(api_key, response_json)
cost_data = await adjust_payment_for_tokens(
key, response_json, session
)
response_json["cost"] = cost_data
return Response(
content=json.dumps(response_json).encode(),
+88 -16
View File
@@ -1,21 +1,32 @@
import httpx
import os
from cashu.core.base import Token # type: ignore
from cashu.wallet.wallet import Wallet # type: ignore
from cashu.core.settings import settings # type: ignore
from cashu.wallet.helpers import deserialize_token_from_string, receive # type: ignore
from .db import ApiKey, AsyncSession
async def _initialize_wallet(mint_url: str) -> Wallet:
WALLET = None
async def _initialize_wallet(mint_url: str | None = None) -> Wallet:
"""Initializes and loads a Cashu wallet."""
global WALLET
if WALLET is not None:
return WALLET
if mint_url is None:
mint_url = "https://mint.minibits.cash/Bitcoin"
wallet = await Wallet.with_db(
mint_url,
db=os.path.join(settings.cashu_dir, "temp"),
db=".",
load_all_keysets=True,
unit="sat", # todo change to msat
)
await wallet.load_mint_info()
if not hasattr(wallet, "keyset_id") or wallet.keyset_id is None:
await wallet.activate_keyset()
await wallet.load_proofs(reload=True)
WALLET = wallet
return wallet
@@ -29,16 +40,15 @@ async def _handle_token_receive(wallet: Wallet, token_obj: Token) -> int:
if amount_received <= 0:
raise ValueError("Token contained no value.")
return amount_received
return amount_received * 1000
async def _get_lnurl_invoice(callback_url: str, amount_sat: int) -> tuple[str, dict]:
async def _get_lnurl_invoice(callback_url: str, amount_msat: int) -> tuple[str, dict]:
"""Requests an invoice from the LNURL callback URL."""
amount_msats = amount_sat * 1000
async with httpx.AsyncClient() as client:
response = await client.get(
callback_url,
params={"amount": amount_msats},
params={"amount": amount_msat},
follow_redirects=True,
)
response.raise_for_status() # Raise exception for non-2xx status codes
@@ -49,11 +59,11 @@ async def _get_lnurl_invoice(callback_url: str, amount_sat: int) -> tuple[str, d
async def _pay_invoice_with_cashu(
wallet: Wallet, bolt11_invoice: str, amount_to_send_sat: int
wallet: Wallet, bolt11_invoice: str, amount_to_send_msat: int
) -> int:
"""Pays a BOLT11 invoice using Cashu proofs via melt."""
quote = await wallet.melt_quote(bolt11_invoice, amount_to_send_sat)
quote = await wallet.melt_quote(bolt11_invoice, amount_to_send_msat)
proofs_to_melt, _ = await wallet.select_to_send(
wallet.proofs, quote.amount + quote.fee_reserve
@@ -66,6 +76,49 @@ async def _pay_invoice_with_cashu(
return quote.amount
async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int:
token_obj: Token = deserialize_token_from_string(cashu_token)
wallet: Wallet = await _initialize_wallet(token_obj.mint)
amount_msats = await _handle_token_receive(wallet, token_obj)
key.balance += amount_msats
session.add(key)
await session.commit()
return amount_msats
async def refund_balance(amount: int, key: ApiKey, session: AsyncSession) -> int:
wallet = await _initialize_wallet()
if key.balance < amount:
raise ValueError("Insufficient balance.")
if amount <= 0:
amount = key.balance
key.balance -= amount
session.add(key)
await session.commit()
if key.refund_address is None:
raise ValueError("Refund address not set.")
return await send_to_lnurl(wallet, key.refund_address, amount)
async def create_token(
amount_msats: int, mint: str = "https://mint.minibits.cash/Bitcoin"
) -> str:
wallet = await _initialize_wallet(mint)
balance = wallet.available_balance
amount_sats = amount_msats // 1000
if balance < amount_sats:
raise ValueError("Insufficient balance on mint.")
print(balance, amount_sats)
if balance > amount_sats:
print("splitting")
_, send_proofs = await wallet.split(wallet.proofs, amount_sats)
else:
print("no splitting")
send_proofs = wallet.proofs
token = await wallet._make_tokenv4(send_proofs)
return token.serialize()
async def redeem(cashu_token: str, lnurl: str) -> int:
"""
Redeems a Cashu token and sends the amount to an LNURL address.
@@ -75,7 +128,7 @@ async def redeem(cashu_token: str, lnurl: str) -> int:
lnurl: The LNURL string (can be bech32, user@host, or direct URL).
Returns:
The amount in satoshis that was successfully sent.
The amount in millisatoshis that was successfully sent.
Raises:
Exception: If any step of the process fails (token receive, LNURL fetch, invoice payment).
@@ -88,17 +141,36 @@ async def redeem(cashu_token: str, lnurl: str) -> int:
# if USE_BALANCE_ON_INVALID_TOKEN:
# amount_received = wallet.available_balance
return await send_to_lnurl(wallet, lnurl, amount_received)
async def send_to_lnurl(wallet: Wallet, lnurl: str, amount_msat: int) -> int:
"""
Sends funds from a Cashu wallet to an LNURL address.
Args:
wallet: The initialized Cashu wallet with available balance.
lnurl: The LNURL string (can be bech32, user@host, or direct URL).
amount_msat: The amount in millisatoshis to send.
Returns:
The amount in millisatoshis that was successfully sent.
Raises:
ValueError: If amount is outside LNURL limits or other validation errors.
Exception: If LNURL fetch or invoice payment fails.
"""
callback_url, min_sendable, max_sendable = await get_lnurl_data(lnurl)
if not (min_sendable <= amount_received * 1000 <= max_sendable):
if not (min_sendable <= amount_msat <= max_sendable):
raise ValueError(
f"Amount {amount_received} sat is outside LNURL limits "
f"Amount {amount_msat / 1000} sat is outside LNURL limits "
f"({min_sendable / 1000} - {max_sendable / 1000} sat)."
)
# subtract estimated fees
amount_to_send = amount_received - int(max(2, amount_received * 0.01))
amount_to_send = amount_msat - int(max(2000, amount_msat * 0.01))
# Note: We pass amount_received directly. The actual amount paid might be adjusted
# Note: We pass amount_msat directly. The actual amount paid might be adjusted
# slightly by the melt quote based on the invoice details.
bolt11_invoice, _ = await _get_lnurl_invoice(callback_url, amount_to_send)
@@ -176,7 +248,7 @@ if __name__ == "__main__":
# Removed try-except block, script will crash on error
print(f"Attempting to redeem token and pay LNURL: {lnurl}")
amount_sent = await redeem(cashu_token, lnurl)
print(f"✅ Successfully sent {amount_sent} sat.")
print(f"✅ Successfully sent {amount_sent / 1000} sat ({amount_sent} msat).")
# Removed try-except block for KeyboardInterrupt
asyncio.run(main())