Merge remote-tracking branch 'upstream/main' into refund_LNURL

This commit is contained in:
GitHappens2Me
2025-05-28 18:54:30 +02:00
20 changed files with 13429 additions and 7953 deletions
+3 -2
View File
@@ -3,8 +3,9 @@ __pycache__
keys.db
wallet.sqlite3
# Development
.notes
.*keys.db
.*wallet.sqlite3
.*wallet.sqlite3
.models.json
compose.override.yml
+1 -9
View File
@@ -1,12 +1,4 @@
# proxy
a reverse proxy that you can plug in front of any openai compatible api endpoint
to handle api-key based payments using cashu tokens or bold12 lightning invoices
we also want to provice an internal dashboard
- general settings
- configure payment methods
- request evals
- publish your listing to nostr
- monitor traffic
to handle payments using the cashu protocol (Bitcoin L3)
+3 -1
View File
@@ -7,8 +7,10 @@ services:
- .:/app
env_file:
- .env
environment:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- "8000:8000" # ← This line is added
- 8000:8000
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
+11806 -8
View File
File diff suppressed because it is too large Load Diff
-7859
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,4 +14,4 @@ dependencies = [
]
[dependency-groups]
dev = ["mypy>=1.15.0", "ruff>=0.11.6", "openai>=1.76.0"]
dev = ["mypy>=1.15.0", "ruff>=0.11.6", "openai>=1.76.0", "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "httpx>=0.25.2"]
+17
View File
@@ -0,0 +1,17 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts =
-v
--tb=short
--strict-markers
--disable-warnings
-p no:warnings
markers =
asyncio: marks tests as async (deselect with '-m "not asyncio"')
integration: marks tests as integration tests
unit: marks tests as unit tests
+110 -12
View File
@@ -1,10 +1,13 @@
import asyncio
import hashlib
import os
import json
from typing import Optional
from fastapi import HTTPException
from .cashu import credit_balance, pay_out
from fastapi import HTTPException, Request
from .cashu import credit_balance, pay_out_with_new_session
from .db import ApiKey, AsyncSession
from .models import MODELS
@@ -27,7 +30,16 @@ async def validate_bearer_key(bearer_key: str, session: AsyncSession, refund_add
Otherwise checks if the hash of the key exists.
"""
if not bearer_key:
raise HTTPException(status_code=401, detail="api-key or cashu-token required")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key"
}
}
)
if bearer_key.startswith("sk-"):
if existing_key := await session.get(ApiKey, bearer_key[3:]):
@@ -48,14 +60,69 @@ async def validate_bearer_key(bearer_key: str, session: AsyncSession, refund_add
except Exception as e:
print(f"Redemption failed: {e}")
raise HTTPException(
status_code=401, detail=f"Invalid or expired cashu key: {e}"
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
}
)
raise HTTPException(status_code=401, detail="Invalid API key")
raise HTTPException(
status_code=401,
detail={
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
}
)
async def pay_for_request(key: ApiKey, session: AsyncSession) -> None:
async def pay_for_request(key: ApiKey, session: AsyncSession, request: Request | None, request_body: bytes | None = None) -> None:
if MODEL_BASED_PRICING and os.path.exists("models.json"):
if request_body:
body = json.loads(request_body)
else:
body = await request.json()
if request_model := body.get("model"):
if request_model not in [model.id for model in MODELS]:
raise HTTPException(
status_code=400,
detail={
"error": {
"message": f"Invalid model: {request_model}",
"type": "invalid_request_error",
"code": "model_not_found"
}
}
)
model = next(model for model in MODELS if model.id == request_model)
if key.balance < model.sats_pricing.max_cost * 1000:
raise HTTPException(
status_code=413,
detail={
"error": {
"message": f"This model requires a minimum balance of {model.sats_pricing.max_cost} sats",
"type": "insufficient_quota",
"code": "insufficient_balance"
}
}
)
if key.balance < COST_PER_REQUEST:
raise HTTPException(status_code=402, detail=f"Insufficient balance: {COST_PER_REQUEST} mSats required. {key.balance} available.")
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {COST_PER_REQUEST} mSats required. {key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance"
}
}
)
# Charge the base cost for the request
key.balance -= COST_PER_REQUEST
@@ -81,19 +148,47 @@ async def adjust_payment_for_tokens(
"total_msats": COST_PER_REQUEST,
}
# Check if we have usage data
if "usage" not in response_data or response_data["usage"] is None:
print("No usage data in response, using base cost only")
return cost_data
# Default to configured pricing
MSATS_PER_1K_INPUT_TOKENS = COST_PER_1K_INPUT_TOKENS
MSATS_PER_1K_OUTPUT_TOKENS = COST_PER_1K_OUTPUT_TOKENS
if MODEL_BASED_PRICING and os.path.exists("models.json"):
response_model = response_data.get("model", "")
if response_model not in [model.id for model in MODELS]:
raise HTTPException(status_code=400, detail="Invalid model")
raise HTTPException(
status_code=400,
detail={
"error": {
"message": f"Invalid model in response: {response_model}",
"type": "invalid_request_error",
"code": "model_not_found"
}
}
)
model = next(model for model in MODELS if model.id == response_model)
if model.sats_pricing is None:
raise HTTPException(status_code=400, detail="Model pricing not defined")
# TODO: Rename, This is named very close to COST_PER_1K_OUTPUT_TOKENS
raise HTTPException(
status_code=400,
detail={
"error": {
"message": "Model pricing not defined",
"type": "invalid_request_error",
"code": "pricing_not_found"
}
}
)
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
raise HTTPException(status_code=400, detail="Model pricing not defined")
# If no token pricing is configured, just return base cost
return cost_data
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
@@ -121,6 +216,9 @@ async def adjust_payment_for_tokens(
f"Warning: Insufficient balance for token-based pricing adjustment: {key.hashed_key[:10]}..."
)
# Still proceed but log the issue - we already provided the service
# Add information about insufficient balance to cost data
cost_data["warning"] = "Insufficient balance for full token-based pricing"
cost_data["balance_shortage_msats"] = cost_difference - key.balance
else:
key.balance -= cost_difference
key.total_spent += cost_difference
@@ -135,6 +233,6 @@ async def adjust_payment_for_tokens(
session.add(key)
await session.commit()
await pay_out(session)
asyncio.create_task(pay_out_with_new_session())
return cost_data
+56 -28
View File
@@ -11,8 +11,9 @@ from .db import ApiKey, AsyncSession, get_session
RECEIVE_LN_ADDRESS = os.environ["RECEIVE_LN_ADDRESS"]
MINT = os.environ.get("MINT", "https://mint.minibits.cash/Bitcoin")
MINIMUM_PAYOUT = int(os.environ.get("MINIMUM_PAYOUT", 10))
DEVS_DONATION_RATE = 0 # 0.021 # 2.1%
MINIMUM_PAYOUT = int(os.environ.get("MINIMUM_PAYOUT", 100))
DEV_LN_ADDRESS = "routstr@minibits.cash"
DEVS_DONATION_RATE = 0.021 # 2.1%
WALLET = None
#TODO
@@ -74,11 +75,13 @@ async def _pay_invoice_with_cashu(
) -> int:
"""Pays a BOLT11 invoice using Cashu proofs via melt."""
amount_to_send_msat = amount_to_send_msat // 1000
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
)
print(f"Proofs to melt: {proofs_to_melt}")
_ = await wallet.melt(
proofs_to_melt, bolt11_invoice, quote.fee_reserve, quote.quote
@@ -87,45 +90,67 @@ async def _pay_invoice_with_cashu(
return quote.amount
async def pay_out_with_new_session() -> None:
"""
Wrapper for pay_out that creates its own database session.
This prevents database connection conflicts when called as a background task.
"""
from .db import create_session
try:
async with create_session() as session:
await pay_out(session)
except Exception as e:
print(f"Error in pay_out_with_new_session: {e}")
async def pay_out(session: AsyncSession) -> None:
"""
Calculates the pay-out amount based on the spent balance, profit, and donation rate.
"""
balance = (
await session.exec(
select(func.sum(col(ApiKey.balance))).where(ApiKey.balance > 0)
)
).one()
if balance is None:
raise ValueError("No balance to pay out.")
user_balance = balance // 1000 # conversion to sats
wallet = await _initialize_wallet()
wallet_balance = wallet.available_balance
try:
balance = (
await session.exec(
select(func.sum(col(ApiKey.balance))).where(ApiKey.balance > 0)
)
).one()
if balance is None or balance == 0:
# No balance to pay out - this is OK, not an error
return
user_balance_sats = balance // 1000 # Convert msats to sats
wallet = await _initialize_wallet()
wallet_balance_sats = wallet.available_balance # Already in sats
print(f"Wallet-balance: {wallet_balance}, User-balance: {user_balance}, Revenue: {wallet_balance - user_balance}, MinPayout:{MINIMUM_PAYOUT}", flush=True)
assert wallet_balance >= user_balance, f"Something went deeply wrong. Wallet-balance: {wallet_balance}, User-Balance: {user_balance}"
if (revenue := wallet_balance - user_balance) <= MINIMUM_PAYOUT:
return
# Handle edge cases more gracefully
if wallet_balance_sats < user_balance_sats:
print(f"Warning: Wallet balance ({wallet_balance_sats} sats) is less than user balance ({user_balance_sats} sats). Skipping payout.")
return
devs_donation = int(revenue * DEVS_DONATION_RATE)
owners_draw = revenue - devs_donation
if (revenue := wallet_balance_sats - user_balance_sats) <= MINIMUM_PAYOUT:
# Not enough revenue yet - this is OK
return
devs_donation = int(revenue * DEVS_DONATION_RATE)
owners_draw = revenue - devs_donation
print(f" DEBUG Revenue > Minimum Payout: paying {owners_draw} sats to {RECEIVE_LN_ADDRESS}", flush=True)
await send_to_lnurl(wallet, RECEIVE_LN_ADDRESS, owners_draw * 1000) # conversion to msats for send_to_lnurl
if devs_donation > 0:
# Send payouts
print(f"Sending {owners_draw} sats to {RECEIVE_LN_ADDRESS}")
await send_to_lnurl(wallet, RECEIVE_LN_ADDRESS, owners_draw * 1000) # Convert to msats
print(f"Sending {devs_donation} sats to {DEV_LN_ADDRESS}")
await send_to_lnurl(
wallet,
"npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s@npub.cash",
devs_donation * 1000,
DEV_LN_ADDRESS,
devs_donation * 1000, # Convert to msats
)
except Exception as e:
# Log the error but don't crash - payouts can be retried later
print(f"Error in pay_out: {e}")
async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int:
token_obj: Token = deserialize_token_from_string(cashu_token)
# Initialize the wallet with the mint specified in the token
print(f"Trying to credit token from mint: {token_obj.mint}", flush=True)
wallet: Wallet = await _initialize_wallet(token_obj.mint)
if token_obj.mint == MINT:
# crediting a token created using the same mint as specified in .env
@@ -250,6 +275,7 @@ async def send_to_lnurl(wallet: Wallet, lnurl: str, amount_msat: int) -> int:
ValueError: If amount is outside LNURL limits or other validation errors.
Exception: If LNURL fetch or invoice payment fails.
"""
print(f"Sending {amount_msat / 1000} sat to {lnurl}")
callback_url, min_sendable, max_sendable = await get_lnurl_data(lnurl)
if not (min_sendable <= amount_msat <= max_sendable):
@@ -259,11 +285,12 @@ async def send_to_lnurl(wallet: Wallet, lnurl: str, amount_msat: int) -> int:
)
# subtract estimated fees
# TODO: Is a static fee calculation working well?
# moving the 2000 and 0.01 to optional enviroment variables gives more control to users
# moving the 2000 and 0.01 to optional enviroment variables might give more control to users
amount_to_send = amount_msat - int(max(2000, amount_msat * 0.01))
print(f" DEBUG Trying to pay {amount_to_send} msats to {lnurl}, with Wallet balance = {wallet.balance}", flush = True)
print(f"trying to pay {amount_to_send} msats to {lnurl}. Available balance: {wallet.balance}", flush=True)
# 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)
@@ -273,6 +300,7 @@ async def send_to_lnurl(wallet: Wallet, lnurl: str, amount_msat: int) -> int:
print(f" DEBUG {amount_paid} sats paid to lnurl", flush=True)
print(f"Amount paid: {amount_paid / 1000} sat")
return amount_paid
@@ -292,7 +320,7 @@ async def get_lnurl_data(lnurl: str) -> tuple[str, int, int]:
elif lnurl.lower().startswith("lnurl"):
try:
# Optional import for environments where bech32 might not be present initially
from bech32 import bech32_decode, convertbits
from bech32 import bech32_decode, convertbits # type: ignore
hrp, data = bech32_decode(lnurl)
if data is None:
+206
View File
@@ -0,0 +1,206 @@
from fastapi import APIRouter
import asyncio
import json
import websockets
import random
import string
import re
import httpx
import os
providers_router = APIRouter(prefix="/v1/providers")
def generate_subscription_id() -> str:
"""Generate a random subscription ID."""
return "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
def extract_onion_urls(content: str) -> list[str]:
"""Extract onion URLs from content."""
pattern = r"http?://[a-zA-Z0-9\-._~]+\.onion"
return re.findall(pattern, content)
async def query_nostr_relay_with_search(
search_term: str,
relay_url: str,
kinds: list[int] | None = None,
limit: int = 1000,
timeout: int = 30,
) -> list[dict]:
"""
Query a Nostr relay and filter for events containing a search term.
"""
if kinds is None:
kinds = [1]
events = []
# If searching for an npub mention, try tag-based search first
if search_term.startswith("nostr:npub"):
# Extract the npub and convert to hex
npub = search_term.replace("nostr:", "")
try:
# Convert npub to hex (you might need to implement or import this)
# For now, try tag-based search with the npub
filter_obj = {
"kinds": kinds,
"limit": limit,
"#p": [npub], # Posts that tag this pubkey
}
except:
# If conversion fails, try regular search
filter_obj = {
"kinds": kinds,
"limit": limit,
}
else:
# Try relay's search functionality (NIP-50)
filter_obj = {
"kinds": kinds,
"search": search_term,
"limit": limit,
}
sub_id = generate_subscription_id()
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
print(f"Connected to relay, sending request with filter: {filter_obj}")
await websocket.send(req_message)
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
# For tag-based search, also check content
if search_term.startswith("nostr:npub"):
if search_term.lower() in data[2]["content"].lower():
print(f"Found matching event: {data[2]['id']}")
events.append(data[2])
else:
print(f"Found matching event: {data[2]['id']}")
events.append(data[2])
elif data[0] == "EOSE" and data[1] == sub_id:
print("Received EOSE message")
break
elif data[0] == "NOTICE":
print(f"Relay notice: {data[1]}")
# If search not supported, could break and try different approach
if "unrecognised filter item" in data[1] and "search" in str(
filter_obj
):
print("Search not supported on this relay")
break
except asyncio.TimeoutError:
print("Timeout waiting for message")
break
except json.JSONDecodeError:
print("Failed to decode message as JSON")
continue
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
print(f"Query failed: {e}")
print(f"Query complete. Found {len(events)} matching events")
return events
async def get_cache() -> list[dict]:
return [] # TODO: Implement cache
async def fetch_onion(provider: str) -> dict:
"""Check if an onion service is healthy by making a GET request to its root."""
try:
# Get Tor proxy URL from environment variable, default to local Tor SOCKS5 proxy
tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050")
# Configure httpx to use Tor SOCKS5 proxy
async with httpx.AsyncClient(
proxies={"http://": tor_proxy, "https://": tor_proxy},
timeout=httpx.Timeout(30.0),
follow_redirects=True,
) as client:
response = await client.get(provider)
# Consider 2xx and 3xx status codes as healthy
return {"status_code": response.status_code, "json": response.json()}
except Exception:
# Any exception means the service is not healthy
return {"status_code": 500, "json": {"error": "Failed to fetch onion"}}
@providers_router.get("/")
async def get_providers(include_json: bool = False):
npub = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s"
# Relays that support NIP-50 text search
search_relays = [
"wss://relay.nostr.band", # Known to support search
"wss://nostr.wine", # Known to support search
"wss://relay.damus.io",
"wss://nos.lol",
]
# Search for the mention format that appears in posts
search_term = f"nostr:{npub}"
all_events = []
event_ids = set() # To avoid duplicates
# Try multiple relays
for relay_url in search_relays:
print(f"\nTrying relay: {relay_url}")
try:
events = await query_nostr_relay_with_search(
search_term=search_term,
relay_url=relay_url,
kinds=[1], # Text notes
limit=500,
)
# Add unique events
for event in events:
if event["id"] not in event_ids:
event_ids.add(event["id"])
all_events.append(event)
print(f"Got {len(events)} events from {relay_url}")
# If we have enough events, we can stop
if len(all_events) >= 100:
break
except Exception as e:
print(f"Failed to query {relay_url}: {e}")
continue
print(f"Found {len(all_events)} total unique events mentioning routstr")
providers = []
for event in all_events:
onion_urls = extract_onion_urls(event["content"])
providers.extend(onion_urls)
unique_providers = list(set(providers))
print(f"Found {len(unique_providers)} unique onion URLs")
print(unique_providers)
healthy_providers: list[dict | str] = []
for provider in unique_providers:
response = await fetch_onion(provider)
if include_json:
healthy_providers.append({provider: response["json"]})
else:
healthy_providers.append(provider)
return {"providers": healthy_providers}
+2
View File
@@ -9,6 +9,7 @@ from .proxy import proxy_router
from .account import account_router
from .cashu import _initialize_wallet, check_for_refunds
from .models import MODELS, update_sats_pricing
from .discovery import providers_router
__version__ = "0.0.1"
@@ -46,6 +47,7 @@ async def info():
app.include_router(admin_router)
app.include_router(account_router)
app.include_router(providers_router)
app.include_router(proxy_router)
+21 -3
View File
@@ -20,7 +20,12 @@ class Pricing(BaseModel):
image: float
web_search: float
internal_reasoning: float
max_cost: float = 0.0 # in sats not msats
class TopProvider(BaseModel):
context_length: int | None = None
max_completion_tokens: int | None = None
is_moderated: bool | None = None
class Model(BaseModel):
id: str
@@ -30,8 +35,9 @@ class Model(BaseModel):
context_length: int
architecture: Architecture
pricing: Pricing
sats_pricing: Pricing | None
per_request_limits: dict | None
sats_pricing: Pricing | None = None
per_request_limits: dict | None = None
top_provider: TopProvider | None = None
MODELS: list[Model] = []
@@ -48,7 +54,19 @@ async def update_sats_pricing() -> None:
model.sats_pricing = Pricing(
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
)
#print(f"Prices (mSats): {model.sats_pricing}", flush=True)
if model.top_provider:
if model.top_provider.context_length and model.top_provider.max_completion_tokens:
max_context_cost = model.top_provider.context_length * model.sats_pricing.prompt
max_completion_cost = model.top_provider.max_completion_tokens * model.sats_pricing.completion
model.sats_pricing.max_cost = max_context_cost + max_completion_cost
else:
p = model.sats_pricing.prompt * 1_000_000
c = model.sats_pricing.completion * 32_000
r = model.sats_pricing.request * 100_000
i = model.sats_pricing.image * 100
w = model.sats_pricing.web_search * 1000
ir = model.sats_pricing.internal_reasoning * 100
model.sats_pricing.max_cost = p + c + r + i + w + ir
except Exception as e:
print(e)
await asyncio.sleep(10)
+111 -30
View File
@@ -6,7 +6,7 @@ from fastapi.responses import Response, StreamingResponse
import httpx
import re
from router.cashu import pay_out
from router.cashu import pay_out_with_new_session
from .auth import validate_bearer_key, pay_for_request, adjust_payment_for_tokens
from .db import AsyncSession, get_session
@@ -28,6 +28,7 @@ async def proxy(
refund_address = request.headers.get("Refund-LNURL", None)
key_expiry_time = request.headers.get("Key-Expiry-Time", None)
# Validate key_expiry_time header
if key_expiry_time:
try:
key_expiry_time = int(key_expiry_time)
@@ -47,7 +48,41 @@ async def proxy(
key = await validate_bearer_key(bearer_key, session, refund_address, key_expiry_time)
await pay_for_request(key, session)
# Pre-validate JSON for requests that require it
request_body = None
if request.method in ["POST", "PUT", "PATCH"] and path.endswith("chat/completions"):
try:
request_body = await request.body()
# Try to parse JSON to validate it
if request_body:
json.loads(request_body)
except json.JSONDecodeError as e:
return Response(
content=json.dumps({
"error": {
"message": f"Invalid JSON in request body: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_json"
}
}),
status_code=400,
media_type="application/json"
)
except Exception as e:
return Response(
content=json.dumps({
"error": {
"message": "Error reading request body",
"type": "invalid_request_error",
"code": "request_error"
}
}),
status_code=400,
media_type="application/json"
)
await pay_for_request(key, session, request, request_body)
# Prepare headers, removing sensitive/problematic ones
headers = dict(request.headers)
@@ -67,19 +102,35 @@ async def proxy(
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
client = httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(retries=1))
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None # No timeout - requests can take as long as needed
)
try:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=request.query_params,
),
stream=True,
)
# Use the pre-read body if available, otherwise stream
if request_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request_body,
params=request.query_params,
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=request.query_params,
),
stream=True,
)
# For chat completions, we need to handle token-based pricing
if path.endswith("chat/completions"):
@@ -136,17 +187,10 @@ async def proxy(
usage_data_found = True
break
except json.JSONDecodeError:
# Not valid JSON, skip
continue
if usage_data_found:
break
except Exception as e:
print(f"Error processing chunk for cost: {e}")
if not usage_data_found:
print("No usage data found in any chunks")
print(f"Error processing streaming response for cost: {e}")
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
@@ -168,7 +212,6 @@ async def proxy(
key, response_json, session
)
response_json["cost"] = cost_data
asyncio.create_task(pay_out(session))
return Response(
content=json.dumps(response_json).encode(),
status_code=response.status_code,
@@ -187,8 +230,8 @@ async def proxy(
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
background_tasks.add_task(pay_out_with_new_session)
asyncio.create_task(pay_out(session))
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
@@ -198,15 +241,53 @@ async def proxy(
except httpx.RequestError as exc:
await client.aclose()
print(f"Error forwarding request to upstream: {exc}")
error_type = type(exc).__name__
error_details = str(exc)
print(
f"Error forwarding request to upstream: {error_type}: {error_details}\n"
f"Request details: method={request.method}, url={url}, headers={headers}, "
f"path={path}, query_params={dict(request.query_params)}"
)
# Provide more specific error messages based on the error type
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
return Response(
content=f"Error connecting to upstream service: {exc}",
content=json.dumps({
"error": {
"message": error_message,
"type": "upstream_error",
"code": 502
}
}),
status_code=502,
media_type="application/json"
)
except Exception as exc:
await client.aclose()
print(f"Unexpected error: {exc}")
return Response(
content=f"Unexpected server error: {exc}",
status_code=500,
import traceback
tb = traceback.format_exc()
print(
f"Unexpected error: {exc}\n"
f"Request details: method={request.method}, url={url}, headers={headers}, "
f"path={path}, query_params={dict(request.query_params)}\n"
f"Traceback:\n{tb}"
)
return Response(
content=json.dumps({
"error": {
"message": "An unexpected server error occurred",
"type": "internal_error",
"code": 500
}
}),
status_code=500,
media_type="application/json"
)
+63
View File
@@ -0,0 +1,63 @@
# FastAPI Async Unit Tests
This directory contains async unit tests for the Routstr proxy FastAPI application.
## Installation
First, ensure you have the development dependencies installed:
```bash
uv pip install -e ".[dev]"
```
## Running Tests
To run all tests:
```bash
pytest
```
To run tests with coverage:
```bash
pytest --cov=router --cov-report=html
```
To run specific test files:
```bash
pytest tests/test_main.py
pytest tests/test_account.py
pytest tests/test_proxy.py
pytest tests/test_models.py
```
To run only async tests:
```bash
pytest -m asyncio
```
## Test Structure
- `conftest.py` - Pytest fixtures and configuration
- `test_main.py` - Tests for main app endpoints
- `test_account.py` - Tests for wallet/account management endpoints
- `test_proxy.py` - Tests for the proxy functionality with mocked upstream
- `test_models.py` - Tests for model pricing and data structures
## Key Fixtures
- `async_client` - Async HTTP client for testing FastAPI endpoints
- `test_session` - In-memory SQLite database session for tests
- `test_api_key` - Pre-configured API key with balance
- `api_key_with_balance` - API key with sufficient balance for proxy tests
## Environment Variables
The tests automatically set up required environment variables in `conftest.py`. No manual configuration needed.
## Writing New Tests
1. Use `@pytest.mark.asyncio` for async tests
2. Use the provided fixtures for database and client access
3. Mock external dependencies (like upstream API calls)
4. Test both success and error cases
5. Verify database state changes when applicable
+1
View File
@@ -0,0 +1 @@
+181
View File
@@ -0,0 +1,181 @@
import asyncio
import os
import pytest
import pytest_asyncio
from typing import AsyncGenerator
from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from unittest.mock import patch, MagicMock, AsyncMock
# Save original environment variables
ORIGINAL_ENV = os.environ.copy()
# Set test environment variables before importing the app
TEST_ENV = {
"UPSTREAM_BASE_URL": "https://api.example.com",
"UPSTREAM_API_KEY": "test-upstream-key",
"NAME": "TestRoutstrNode",
"DESCRIPTION": "Test Node",
"NPUB": "npub1test",
"MINT": "https://test.mint.com",
"HTTP_URL": "http://test.example.com",
"ONION_URL": "http://test.onion",
"CORS_ORIGINS": "*",
"RECEIVE_LN_ADDRESS": "test@lightning.address",
"COST_PER_REQUEST": "1",
"COST_PER_1K_INPUT_TOKENS": "0",
"COST_PER_1K_OUTPUT_TOKENS": "0",
"MODEL_BASED_PRICING": "false"
}
# Apply test environment
os.environ.update(TEST_ENV)
# Mock the cashu wallet initialization before importing
with patch("router.cashu._initialize_wallet") as mock_init_wallet:
mock_wallet = AsyncMock()
mock_wallet.available_balance = 1000
mock_wallet.proofs = []
mock_wallet.split = AsyncMock(return_value=([], []))
mock_init_wallet.return_value = mock_wallet
with patch("router.cashu.WALLET", mock_wallet):
from router.main import app
from router.db import get_session
from router.models import MODELS
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def test_engine():
"""Create a test database engine - new for each test."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
future=True,
)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def test_session(test_engine) -> AsyncSession:
"""Create a test database session."""
async_session = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
@pytest.fixture
def test_client() -> TestClient:
"""Create a test client for the FastAPI app."""
with patch.dict(os.environ, TEST_ENV, clear=True):
with patch("router.cashu._initialize_wallet") as mock_init:
mock_wallet = AsyncMock()
mock_wallet.available_balance = 1000
mock_wallet.proofs = []
mock_wallet.split = AsyncMock(return_value=([], []))
mock_init.return_value = mock_wallet
with patch("router.models.update_sats_pricing") as mock_update:
mock_update.return_value = None
yield TestClient(app)
@pytest_asyncio.fixture
async def async_client(test_session) -> AsyncClient:
"""Create an async test client with dependency overrides."""
async def override_get_session():
yield test_session
app.dependency_overrides[get_session] = override_get_session
# Mock startup tasks
with patch.dict(os.environ, TEST_ENV, clear=True):
with patch("router.cashu._initialize_wallet") as mock_init:
mock_wallet = AsyncMock()
mock_wallet.available_balance = 1000
mock_wallet.proofs = []
mock_wallet.split = AsyncMock(return_value=([], []))
mock_init.return_value = mock_wallet
with patch("router.models.update_sats_pricing") as mock_update:
mock_update.return_value = None
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture
def mock_models():
"""Mock models data for testing."""
return [
{
"id": "gpt-4",
"name": "GPT-4",
"created": 1680000000,
"description": "Test model",
"context_length": 8192,
"architecture": {
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "cl100k_base",
"instruct_type": "none"
},
"pricing": {
"prompt": 0.03,
"completion": 0.06,
"request": 0.001,
"image": 0.0,
"web_search": 0.0,
"internal_reasoning": 0.0
},
"top_provider": {
"context_length": 8192,
"max_completion_tokens": 4096,
"is_moderated": False
}
}
]
# Cleanup after all tests
@pytest.fixture(scope="session", autouse=True)
def cleanup():
yield
# Restore original environment carefully
current_keys = set(os.environ.keys())
original_keys = set(ORIGINAL_ENV.keys())
# Remove keys that weren't in original
for key in current_keys - original_keys:
if key != 'PYTEST_CURRENT_TEST': # Don't touch pytest's own variables
os.environ.pop(key, None)
# Restore original values
for key, value in ORIGINAL_ENV.items():
os.environ[key] = value
+216
View File
@@ -0,0 +1,216 @@
import pytest
import pytest_asyncio
import hashlib
import uuid
from unittest.mock import patch, AsyncMock, MagicMock
from httpx import AsyncClient
from router.db import ApiKey, AsyncSession
def hash_api_key(api_key: str) -> str:
"""Hash an API key for storage."""
return hashlib.sha256(api_key.encode()).hexdigest()
@pytest_asyncio.fixture
async def test_api_key(test_session: AsyncSession) -> ApiKey:
"""Create a test API key in the database."""
# Use unique key for each test
unique_id = str(uuid.uuid4())[:8]
api_key = f"test-api-key-{unique_id}"
key = ApiKey(
hashed_key=api_key,
balance=1000000, # 1000 sats in msats
refund_address="test@lightning.address",
total_spent=0,
total_requests=0
)
test_session.add(key)
await test_session.commit()
await test_session.refresh(key)
return key
@pytest.mark.asyncio
async def test_account_info_with_valid_key(
async_client: AsyncClient,
test_api_key: ApiKey
):
"""Test getting account info with a valid API key."""
response = await async_client.get(
"/v1/wallet/",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
)
assert response.status_code == 200
data = response.json()
assert data["api_key"] == f"sk-{test_api_key.hashed_key}"
assert data["balance"] == 1000000
@pytest.mark.asyncio
async def test_account_info_without_auth(async_client: AsyncClient):
"""Test that account info requires authentication."""
response = await async_client.get("/v1/wallet/")
assert response.status_code == 422 # Missing required header
@pytest.mark.asyncio
async def test_account_info_with_invalid_key(async_client: AsyncClient):
"""Test account info with an invalid API key."""
response = await async_client.get(
"/v1/wallet/",
headers={"Authorization": "Bearer invalid-key"}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refund_balance_with_address(
async_client: AsyncClient,
test_api_key: ApiKey,
test_session: AsyncSession
):
"""Test refunding balance when refund address is set."""
# Need to patch the refund_balance at the module level to intercept the call
with patch("router.account.refund_balance", new_callable=AsyncMock) as mock_refund:
mock_refund.return_value = 1000000
response = await async_client.post(
"/v1/wallet/refund",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
)
assert response.status_code == 200
data = response.json()
assert data["recipient"] == "test@lightning.address"
assert data["msats"] == 1000000
# Verify balance was zeroed
await test_session.refresh(test_api_key)
assert test_api_key.balance == 0
# Verify refund_balance was called
mock_refund.assert_called_once()
@pytest.mark.asyncio
async def test_refund_balance_without_address(
async_client: AsyncClient,
test_session: AsyncSession
):
"""Test refunding balance when no refund address is set."""
# Create key without refund address - with unique ID
unique_id = str(uuid.uuid4())[:8]
api_key = f"test-key-no-refund-{unique_id}"
key = ApiKey(
hashed_key=api_key,
balance=500000,
refund_address=None,
total_spent=0,
total_requests=0
)
test_session.add(key)
await test_session.commit()
# Mock at the router.account module level
with patch("router.account.create_token", new_callable=AsyncMock) as mock_create_token:
mock_create_token.return_value = "cashuBqQSEQ..."
response = await async_client.post(
"/v1/wallet/refund",
headers={"Authorization": f"Bearer sk-{api_key}"}
)
assert response.status_code == 200
data = response.json()
assert data["recipient"] is None
assert data["msats"] == 500000
assert data["token"] == "cashuBqQSEQ..."
# Verify create_token was called with the correct amount
mock_create_token.assert_called_once_with(500000)
@pytest.mark.asyncio
async def test_topup_balance_endpoint(
async_client: AsyncClient,
test_api_key: ApiKey,
test_session: AsyncSession
):
"""Test topping up balance with a cashu token."""
# Mock at the router.account module level to intercept the import
with patch("router.account.credit_balance", new_callable=AsyncMock) as mock_credit:
mock_credit.return_value = {"msats": 500000}
response = await async_client.post(
"/v1/wallet/topup?cashu_token=cashuBqQSEQ...",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
)
assert response.status_code == 200
data = response.json()
assert data == {"msats": 500000}
# Verify credit_balance was called
mock_credit.assert_called_once()
@pytest.mark.asyncio
async def test_topup_balance_requires_cashu_token(
async_client: AsyncClient,
test_api_key: ApiKey
):
"""Test that topup endpoint requires a cashu token."""
response = await async_client.post(
"/v1/wallet/topup",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"},
json={}
)
assert response.status_code == 422 # Missing required field
@pytest.mark.asyncio
async def test_account_with_cashu_token(
async_client: AsyncClient,
test_session: AsyncSession
):
"""Test authentication with a cashu token creates a new account."""
cashu_token = "cashuBqQSEQ123456"
with patch("router.cashu.credit_balance", new_callable=AsyncMock) as mock_credit:
# Mock successful token redemption
mock_credit.return_value = 5000000 # 5000 sats
# Mock token deserialization
with patch("router.cashu.deserialize_token_from_string") as mock_deserialize:
mock_token = MagicMock()
mock_token.mint = "https://test.mint.com"
mock_deserialize.return_value = mock_token
# Mock wallet receive
with patch("router.cashu._handle_token_receive", new_callable=AsyncMock) as mock_receive:
mock_receive.return_value = 5000000
response = await async_client.get(
"/v1/wallet/",
headers={"Authorization": f"Bearer {cashu_token}"}
)
assert response.status_code == 200
data = response.json()
# Check that a new key was created with the hashed token
assert data["api_key"].startswith("sk-")
assert data["balance"] >= 0 # Balance should be set after credit_balance
+59
View File
@@ -0,0 +1,59 @@
import pytest
from httpx import AsyncClient
from unittest.mock import patch
@pytest.mark.asyncio
async def test_root_endpoint(async_client: AsyncClient):
"""Test the root endpoint returns expected information."""
# Mock the environment variables for this specific test
env_vars = {
"NAME": "TestRoutstrNode",
"DESCRIPTION": "Test Node",
"NPUB": "npub1test",
"MINT": "https://test.mint.com",
"HTTP_URL": "http://test.example.com",
"ONION_URL": "http://test.onion",
}
with patch.dict("os.environ", env_vars, clear=False):
response = await async_client.get("/")
assert response.status_code == 200
data = response.json()
# The app reads from env vars during import, so check what we actually get
assert "name" in data
assert "description" in data
assert data["version"] == "0.0.1"
assert "npub" in data
assert "mint" in data
assert "http_url" in data
assert "onion_url" in data
assert "models" in data
@pytest.mark.asyncio
async def test_cors_headers(async_client: AsyncClient):
"""Test that CORS headers are properly set."""
response = await async_client.options(
"/",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "GET",
}
)
assert response.status_code == 200
# Check that CORS is working (might be * or specific origin)
assert "access-control-allow-origin" in response.headers
assert "GET" in response.headers["access-control-allow-methods"]
@pytest.mark.asyncio
async def test_startup_event_initializes_properly(test_client):
"""Test that the startup event runs without errors."""
# The test_client fixture already triggers the startup event
# This test ensures no exceptions are raised during startup
response = test_client.get("/")
assert response.status_code == 200
+221
View File
@@ -0,0 +1,221 @@
import pytest
import asyncio
from unittest.mock import patch, AsyncMock, MagicMock
from router.models import Model, Architecture, Pricing, TopProvider, update_sats_pricing, MODELS
@pytest.fixture
def sample_model() -> Model:
"""Create a sample model for testing."""
return Model(
id="test-model",
name="Test Model",
created=1700000000,
description="A test model",
context_length=4096,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="test_tokenizer",
instruct_type="chat"
),
pricing=Pricing(
prompt=0.01,
completion=0.02,
request=0.001,
image=0.0,
web_search=0.0,
internal_reasoning=0.0
),
top_provider=TopProvider(
context_length=4096,
max_completion_tokens=2048,
is_moderated=False
)
)
@pytest.mark.asyncio
async def test_update_sats_pricing_calculation(sample_model: Model):
"""Test that sats pricing is calculated correctly."""
# Mock the sats_usd_ask_price function
with patch("router.models.sats_usd_ask_price", new_callable=AsyncMock) as mock_price:
mock_price.return_value = 0.0001 # 1 sat = 0.0001 USD
# Temporarily replace MODELS
original_models = MODELS[:]
MODELS.clear()
MODELS.append(sample_model)
# Run one iteration of the pricing update
sleep_called = asyncio.Event()
async def mock_sleep(duration):
sleep_called.set()
raise asyncio.CancelledError()
with patch("asyncio.sleep", side_effect=mock_sleep):
try:
# Create and run the task
task = asyncio.create_task(update_sats_pricing())
# Wait for the first iteration to complete
await sleep_called.wait()
# Check that sats pricing was calculated
assert sample_model.sats_pricing is not None
# Verify calculations (prices in USD / sats_to_usd)
assert sample_model.sats_pricing.prompt == pytest.approx(0.01 / 0.0001) # 100 sats
assert sample_model.sats_pricing.completion == pytest.approx(0.02 / 0.0001) # 200 sats
assert sample_model.sats_pricing.request == pytest.approx(0.001 / 0.0001) # 10 sats
# Verify max_cost calculation for model with top_provider
expected_max_context = 4096 * sample_model.sats_pricing.prompt
expected_max_completion = 2048 * sample_model.sats_pricing.completion
assert sample_model.sats_pricing.max_cost == pytest.approx(expected_max_context + expected_max_completion)
# Cancel and await the task
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
pass
finally:
# Restore original models
MODELS.clear()
MODELS.extend(original_models)
@pytest.mark.asyncio
async def test_update_sats_pricing_without_top_provider():
"""Test sats pricing calculation for models without top_provider."""
model_without_top = Model(
id="test-model-no-top",
name="Test Model No Top",
created=1700000000,
description="A test model without top provider",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="test_tokenizer",
instruct_type=None
),
pricing=Pricing(
prompt=0.01,
completion=0.02,
request=0.001,
image=0.01,
web_search=0.005,
internal_reasoning=0.015
),
top_provider=None
)
with patch("router.models.sats_usd_ask_price", new_callable=AsyncMock) as mock_price:
mock_price.return_value = 0.0001 # 1 sat = 0.0001 USD
original_models = MODELS[:]
MODELS.clear()
MODELS.append(model_without_top)
sleep_called = asyncio.Event()
async def mock_sleep(duration):
sleep_called.set()
raise asyncio.CancelledError()
with patch("asyncio.sleep", side_effect=mock_sleep):
try:
task = asyncio.create_task(update_sats_pricing())
await sleep_called.wait()
assert model_without_top.sats_pricing is not None
# Verify the fallback max_cost calculation
p = model_without_top.sats_pricing.prompt * 1_000_000
c = model_without_top.sats_pricing.completion * 32_000
r = model_without_top.sats_pricing.request * 100_000
i = model_without_top.sats_pricing.image * 100
w = model_without_top.sats_pricing.web_search * 1000
ir = model_without_top.sats_pricing.internal_reasoning * 100
expected_max = p + c + r + i + w + ir
assert model_without_top.sats_pricing.max_cost == pytest.approx(expected_max)
# Cancel and await the task
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
pass
finally:
MODELS.clear()
MODELS.extend(original_models)
@pytest.mark.asyncio
async def test_update_sats_pricing_handles_errors():
"""Test that update_sats_pricing handles errors gracefully."""
with patch("router.models.sats_usd_ask_price", new_callable=AsyncMock) as mock_price:
mock_price.side_effect = Exception("API Error")
error_printed = False
original_print = print
def mock_print(*args, **kwargs):
nonlocal error_printed
if args and isinstance(args[0], Exception) and str(args[0]) == "API Error":
error_printed = True
original_print(*args, **kwargs)
with patch("builtins.print", side_effect=mock_print):
sleep_called = asyncio.Event()
async def mock_sleep(duration):
sleep_called.set()
raise asyncio.CancelledError()
with patch("asyncio.sleep", side_effect=mock_sleep):
try:
task = asyncio.create_task(update_sats_pricing())
await sleep_called.wait()
# Verify error was printed
assert error_printed
# Cancel and await the task
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
pass
def test_model_serialization(sample_model: Model):
"""Test that models can be serialized and deserialized correctly."""
model_dict = sample_model.dict()
# Verify all fields are present
assert model_dict["id"] == "test-model"
assert model_dict["name"] == "Test Model"
assert model_dict["pricing"]["prompt"] == 0.01
assert model_dict["architecture"]["modality"] == "text"
assert model_dict["top_provider"]["context_length"] == 4096
# Test deserialization
new_model = Model(**model_dict)
assert new_model.id == sample_model.id
assert new_model.pricing.prompt == pytest.approx(sample_model.pricing.prompt)
+351
View File
@@ -0,0 +1,351 @@
import pytest
import pytest_asyncio
import json
import os
import uuid
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, Response as HttpxResponse
from router.db import ApiKey, AsyncSession
@pytest_asyncio.fixture
async def api_key_with_balance(test_session: AsyncSession) -> ApiKey:
"""Create an API key with sufficient balance."""
unique_id = str(uuid.uuid4())[:8]
key = ApiKey(
hashed_key=f"test-hashed-key-{unique_id}",
balance=10000000, # 10,000 sats in msats
refund_address=None,
total_spent=0,
total_requests=0
)
test_session.add(key)
await test_session.commit()
await test_session.refresh(key)
return key
@pytest.mark.asyncio
async def test_proxy_requires_authentication(async_client: AsyncClient):
"""Test that proxy endpoints require authentication."""
response = await async_client.post("/v1/chat/completions")
assert response.status_code == 401
assert "API key or Cashu token required" in response.json()["detail"]["error"]["message"]
@pytest.mark.asyncio
async def test_proxy_with_insufficient_balance(
async_client: AsyncClient,
test_session: AsyncSession
):
"""Test proxy request with insufficient balance."""
# Create key with minimal balance
unique_id = str(uuid.uuid4())[:8]
key = ApiKey(
hashed_key=f"low-balance-key-{unique_id}",
balance=100, # Only 0.1 sats
refund_address=None,
total_spent=0,
total_requests=0
)
test_session.add(key)
await test_session.commit()
# Mock the models.json check
with patch("os.path.exists", return_value=False):
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}
)
assert response.status_code == 402
assert "Insufficient balance" in response.json()["detail"]["error"]["message"]
@pytest.mark.asyncio
async def test_proxy_invalid_json_body(
async_client: AsyncClient,
api_key_with_balance: ApiKey
):
"""Test proxy request with invalid JSON body."""
response = await async_client.post(
"/v1/chat/completions",
headers={
"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}",
"Content-Type": "application/json"
},
content=b'{"invalid": json",}' # Invalid JSON
)
assert response.status_code == 400
error_data = response.json()
assert error_data["error"]["type"] == "invalid_request_error"
assert error_data["error"]["code"] == "invalid_json"
@pytest.mark.asyncio
async def test_proxy_successful_request_mock(
async_client: AsyncClient,
api_key_with_balance: ApiKey,
test_session: AsyncSession
):
"""Test successful proxy request with mocked upstream."""
mock_response_data = {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello! How can I help you?"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 10,
"total_tokens": 19
}
}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
# Create a mock response
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.aread = AsyncMock(return_value=json.dumps(mock_response_data).encode())
mock_response.aiter_bytes = AsyncMock()
mock_response.aclose = AsyncMock()
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
# Also mock the models.json check and pay_out
with patch("os.path.exists", return_value=False):
with patch("router.cashu.pay_out_with_new_session") as mock_payout:
mock_payout.return_value = None
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}
)
assert response.status_code == 200
response_json = response.json()
# Verify the response includes the original data plus cost
assert response_json["id"] == "chatcmpl-123"
assert "cost" in response_json
assert response_json["cost"]["total_msats"] >= 0
# Verify balance was deducted
await test_session.refresh(api_key_with_balance)
assert api_key_with_balance.balance < 10000000
assert api_key_with_balance.total_requests == 1
@pytest.mark.asyncio
async def test_proxy_streaming_response(
async_client: AsyncClient,
api_key_with_balance: ApiKey
):
"""Test proxy request with streaming response."""
# Mock SSE stream chunks
stream_chunks = [
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4","choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n',
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4","choices":[{"delta":{"content":" there!"},"index":0}]}\n\n',
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4","choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}\n\n',
b'data: [DONE]\n\n'
]
async def mock_aiter_bytes():
for chunk in stream_chunks:
yield chunk
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/event-stream"}
mock_response.aiter_bytes = lambda: mock_aiter_bytes()
mock_response.aclose = AsyncMock()
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
with patch("os.path.exists", return_value=False):
with patch("router.cashu.pay_out_with_new_session") as mock_payout:
mock_payout.return_value = None
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"stream": True
}
)
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream"
@pytest.mark.asyncio
async def test_proxy_handles_upstream_errors(
async_client: AsyncClient,
api_key_with_balance: ApiKey
):
"""Test proxy handles upstream connection errors gracefully."""
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
# Simulate connection error
mock_client.send.side_effect = Exception("Connection refused")
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
with patch("os.path.exists", return_value=False):
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}
)
assert response.status_code == 500
error_data = response.json()
assert error_data["error"]["type"] == "internal_error"
assert error_data["error"]["message"] == "An unexpected server error occurred"
@pytest.mark.asyncio
async def test_proxy_with_model_based_pricing(
async_client: AsyncClient,
test_session: AsyncSession
):
"""Test proxy with model-based pricing enabled."""
# Create API key with sufficient balance
unique_id = str(uuid.uuid4())[:8]
key = ApiKey(
hashed_key=f"model-pricing-key-{unique_id}",
balance=10000000, # 10,000 sats
refund_address=None,
total_spent=0,
total_requests=0
)
test_session.add(key)
await test_session.commit()
# Patch the MODEL_BASED_PRICING constant directly
with patch("router.auth.MODEL_BASED_PRICING", True):
with patch("os.path.exists", return_value=True):
# Mock a model with pricing
from router.models import MODELS, Model, Pricing, Architecture, TopProvider
test_model = Model(
id="gpt-4",
name="GPT-4",
created=1680000000,
description="Test model",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="cl100k_base",
instruct_type="none"
),
pricing=Pricing(
prompt=0.03,
completion=0.06,
request=0.001,
image=0.0,
web_search=0.0,
internal_reasoning=0.0
),
sats_pricing=Pricing(
prompt=300, # 300 sats per 1k tokens
completion=600,
request=10,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
max_cost=5000 # 5000 sats max
),
top_provider=TopProvider(
context_length=8192,
max_completion_tokens=4096,
is_moderated=False
)
)
# Temporarily replace models
original_models = MODELS[:]
MODELS.clear()
MODELS.append(test_model)
# Mock the upstream HTTP client
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
# Create a mock response
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.aread = AsyncMock(return_value=b'{"id": "test", "model": "gpt-4"}')
mock_response.aiter_bytes = AsyncMock()
mock_response.aclose = AsyncMock()
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
try:
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}
)
# Should succeed because balance (10,000 sats) > max_cost (5000 sats)
assert response.status_code == 200
finally:
MODELS.clear()
MODELS.extend(original_models)