mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 19:04:47 +00:00
Merge remote-tracking branch 'upstream/main' into refund_LNURL
This commit is contained in:
+3
-2
@@ -3,8 +3,9 @@ __pycache__
|
||||
keys.db
|
||||
wallet.sqlite3
|
||||
|
||||
|
||||
# Development
|
||||
.notes
|
||||
.*keys.db
|
||||
.*wallet.sqlite3
|
||||
.*wallet.sqlite3
|
||||
.models.json
|
||||
compose.override.yml
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
-7859
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user