Merge branch 'main' into kwsantiago/62-comprehensive-tests

This commit is contained in:
Shroominic
2025-08-06 20:56:11 -03:00
14 changed files with 121 additions and 129 deletions
+2 -2
View File
@@ -24,13 +24,13 @@ MODEL_BASED_PRICING = "true"
# EXCHANGE_FEE = "1.005" # 0.5 % currency exchange fee
# password used to log into admin interface
ADMIN_PASSWORD="CHANGE-THIS"
# ADMIN_PASSWORD=""
# Public Endpoint
HTTP_URL="https://your.domain.com"
# Tor Endpoint (copy from docker logs)
ONION_URL=".onion"
# ONION_URL=".onion"
RELAYS="wss://relay.routstr.com,wss://relay.nostr.band"
CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
+1
View File
@@ -11,6 +11,7 @@ wallet.sqlite3
.cashu
.relay
relay-data
.dockerignore
compose.override.yml
+24 -10
View File
@@ -32,22 +32,32 @@ sequenceDiagram
- **Cashu Wallet Integration** Accept Lightning payments and redeem eCash tokens before forwarding requests
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin` to view balances and API keys
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
## Getting Started
### Requirements
### Running the proxy using Docker
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=your-openai-api-key \
ghcr.io/routstr/proxy:latest
```
### Development Requirements
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
- A Cashu wallet secret (`NSEC`) and Lightning address for receiving payments
### Installation
```bash
uv sync --dev # install dependencies
uv sync # install dependencies
```
Create a `.env` file based on `.env.example` and fill in the required values:
@@ -78,15 +88,18 @@ The most common settings are shown below. See `.env.example` for the full list.
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `RECEIVE_LN_ADDRESS` Lightning address that receives payouts
- `MINIMUM_PAYOUT` Minimum sats before forwarding earnings
- `MODEL_BASED_PRICING` Set to `true` to use pricing from `models.json`
- `REFUND_PROCESSING_INTERVAL` Seconds between automatic refunds
- `ADMIN_PASSWORD` Password for the `/admin` dashboard
- `ADMIN_PASSWORD` Password for the `/admin/` dashboard
- `CASHU_MINTS` Comma-separated list of Cashu mint URLs
- `NAME` Name of the proxy
- `DESCRIPTION` Description of the proxy
- `NPUB` Nostr public key of the proxy
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
## Withdrawing Balance
Go to `https://<your.routstr.proxy>/admin/` (NOTE: be sure to add the '/' at the end), enter the `ADMIN_PASSWORD` you set above and withdraw your balance as a Cashu token.
Go to `https://<your.routstr.proxy>/admin/` (NOTE: be sure to add the '/' at the end), enter the `ADMIN_PASSWORD` you set above and withdraw your balance as a Cashu token.
## Example Client
@@ -147,7 +160,8 @@ The proxy should implement either a dedicated endpoint to communicate minimum eC
To use this feature, you'll need a client that handles both OpenAI API calls and eCash header management. The following clients provide seamless integration:
- **[cashu-402-client](https://github.com/9qeklajc/ecash-402-client)** rust client with automatic wallet management
- **[routstr-chat](https://github.com/routstr/routstr-chat)** chat app for the routstr network
- **[otrta-client](https://github.com/routstr/otrta-client)** rust web app for the routstr network
clients automatically:
+2
View File
@@ -12,6 +12,8 @@ services:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- 8000:8000
extra_hosts: # Needed to access locally running models
- "host.docker.internal:host-gateway"
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
+1
View File
@@ -276,6 +276,7 @@ def setup_logging() -> None:
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
},
"root": {
"level": log_level,
+5 -18
View File
@@ -10,7 +10,7 @@ from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..proxy import proxy_router
from ..wallet import check_for_refunds, periodic_payout
from ..wallet import periodic_payout
from .admin import admin_router
from .db import init_db
from .logging import get_logger, setup_logging
@@ -19,23 +19,20 @@ from .logging import get_logger, setup_logging
setup_logging()
logger = get_logger(__name__)
__version__ = "0.0.1"
__version__ = "0.1.0"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Application startup initiated", extra={"version": __version__})
# Initialize task variables to None
pricing_task = None
refund_task = None
payout_task = None
try:
await init_db()
pricing_task = asyncio.create_task(update_sats_pricing())
refund_task = asyncio.create_task(check_for_refunds())
payout_task = asyncio.create_task(periodic_payout())
yield
@@ -49,21 +46,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
finally:
logger.info("Application shutdown initiated")
# Cancel tasks if they were created
if refund_task:
refund_task.cancel()
if pricing_task:
pricing_task.cancel()
if payout_task:
payout_task.cancel()
pricing_task.cancel()
payout_task.cancel()
try:
# Only gather tasks that were created
tasks_to_wait = [
task for task in [pricing_task, refund_task, payout_task] if task
]
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
await asyncio.gather(pricing_task, payout_task, return_exceptions=True)
logger.info("Background tasks stopped successfully")
except Exception as e:
logger.error(
+12 -10
View File
@@ -1,5 +1,6 @@
import json
import os
from typing import Optional
from fastapi import HTTPException, Response
@@ -10,17 +11,12 @@ from .models import MODELS
logger = get_logger(__name__)
UPSTREAM_BASE_URL = os.environ["UPSTREAM_BASE_URL"]
UPSTREAM_BASE_URL = os.environ.get("UPSTREAM_BASE_URL", "")
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "")
logger.info(
"Payment helpers initialized",
extra={
"upstream_base_url": UPSTREAM_BASE_URL,
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
"model_based_pricing": MODEL_BASED_PRICING,
},
)
if not UPSTREAM_BASE_URL:
raise ValueError("Please set the UPSTREAM_BASE_URL environment variable")
def get_cost_per_request(model: str | None = None) -> int:
@@ -153,7 +149,9 @@ def get_max_cost_for_model(model: str) -> int:
return COST_PER_REQUEST
def create_error_response(error_type: str, message: str, status_code: int) -> Response:
def create_error_response(
error_type: str, message: str, status_code: int, token: Optional[str] = None
) -> Response:
"""Create a standardized error response."""
logger.info(
"Creating error response",
@@ -164,6 +162,9 @@ def create_error_response(error_type: str, message: str, status_code: int) -> Re
},
)
response_headers = {}
if token:
response_headers["X-Cashu"] = token
return Response(
content=json.dumps(
{
@@ -176,6 +177,7 @@ def create_error_response(error_type: str, message: str, status_code: int) -> Re
),
status_code=status_code,
media_type="application/json",
headers=dict(response_headers),
)
+18 -35
View File
@@ -89,43 +89,25 @@ async def update_sats_pricing() -> None:
model.sats_pricing = Pricing(
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
)
if model.top_provider:
if (
model.top_provider.context_length
and model.top_provider.max_completion_tokens
mspp = model.sats_pricing.prompt
mspc = model.sats_pricing.completion
if (tp := model.top_provider) and (
tp.context_length or tp.max_completion_tokens
):
if (cl := model.top_provider.context_length) and (
mct := 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
)
elif model.top_provider.context_length:
max_context_cost = (
model.top_provider.context_length
* model.sats_pricing.prompt
)
max_completion_cost = 32_000 * model.sats_pricing.completion
model.sats_pricing.max_cost = (
max_context_cost + max_completion_cost
)
elif model.top_provider.max_completion_tokens:
max_completion_cost = (
model.top_provider.max_completion_tokens
* model.sats_pricing.completion
)
max_context_cost = 1_048_576 * model.sats_pricing.prompt
model.sats_pricing.max_cost = max_completion_cost
model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc
elif cl := model.top_provider.context_length:
model.sats_pricing.max_cost = cl * 0.8 * mspp + cl * 0.2 * mspc
elif mct := model.top_provider.max_completion_tokens:
model.sats_pricing.max_cost = mct * 4 * mspp + mct * mspc
else:
model.sats_pricing.max_cost = (
1_048_576 * model.sats_pricing.prompt
+ 32_000 * model.sats_pricing.completion
)
model.sats_pricing.max_cost = 1_000_000 * mspp + 32_000 * mspc
elif model.context_length:
model.sats_pricing.max_cost = (
model.sats_pricing.prompt * model.context_length * 0.8
) + (model.sats_pricing.completion * model.context_length * 0.2)
else:
p = model.sats_pricing.prompt * 1_000_000
c = model.sats_pricing.completion * 32_000
@@ -145,5 +127,6 @@ async def update_sats_pricing() -> None:
@models_router.get("/v1/models")
@models_router.get("/models")
async def models() -> dict:
return {"data": MODELS}
-5
View File
@@ -18,7 +18,6 @@ async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Kraken API."""
api = "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"
try:
logger.debug("Fetching BTC price from Kraken")
response = await client.get(api)
price_data = response.json()
price = float(price_data["result"]["XXBTZUSD"]["c"][0])
@@ -40,7 +39,6 @@ async def coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Coinbase API."""
api = "https://api.coinbase.com/v2/prices/BTC-USD/spot"
try:
logger.debug("Fetching BTC price from Coinbase")
response = await client.get(api)
price_data = response.json()
price = float(price_data["data"]["amount"])
@@ -62,7 +60,6 @@ async def binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USDT price from Binance API."""
api = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"
try:
logger.debug("Fetching BTC price from Binance")
response = await client.get(api)
price_data = response.json()
price = float(price_data["price"])
@@ -82,7 +79,6 @@ async def binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
async def btc_usd_ask_price() -> float:
"""Get the highest BTC/USD price from multiple exchanges with fee adjustment."""
logger.debug("Starting BTC price aggregation from multiple exchanges")
async with httpx.AsyncClient(timeout=30.0) as client:
try:
@@ -113,7 +109,6 @@ async def btc_usd_ask_price() -> float:
async def sats_usd_ask_price() -> float:
"""Get the USD price per satoshi."""
logger.debug("Calculating satoshi price from BTC price")
try:
btc_price = await btc_usd_ask_price()
+19 -15
View File
@@ -8,12 +8,7 @@ from fastapi.responses import Response, StreamingResponse
from ..core import get_logger
from ..wallet import CurrencyUnit, recieve_token, send_token
from .cost_caculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
from .helpers import (
UPSTREAM_BASE_URL,
create_error_response,
@@ -68,21 +63,30 @@ async def x_cashu_handler(
"token_already_spent",
"The provided CASHU token has already been spent",
400,
x_cashu_token,
)
elif "invalid token" in error_message.lower():
if "invalid token" in error_message.lower():
return create_error_response(
"invalid_token", "The provided CASHU token is invalid", 400
"invalid_token",
"The provided CASHU token is invalid",
400,
x_cashu_token,
)
elif "mint error" in error_message.lower():
if "mint error" in error_message.lower():
return create_error_response(
"mint_error", f"CASHU mint error: {error_message}", 422
)
else:
# Generic error for other cases
return create_error_response(
"cashu_error", f"CASHU token processing failed: {error_message}", 400
"mint_error", f"CASHU mint error: {error_message}", 422, x_cashu_token
)
# Generic error for other cases
return create_error_response(
"cashu_error",
f"CASHU token processing failed: {error_message}",
400,
x_cashu_token,
)
async def forward_to_upstream(
request: Request, path: str, headers: dict, amount: int, unit: CurrencyUnit
+2 -5
View File
@@ -11,7 +11,8 @@ logger = get_logger(__name__)
CurrencyUnit = Literal["sat", "msat"]
TRUSTED_MINTS = os.environ["CASHU_MINTS"].split(",")
CASHU_MINTS = os.environ.get("CASHU_MINTS", "https://mint.minibits.cash/Bitcoin")
TRUSTED_MINTS = CASHU_MINTS.split(",")
PRIMARY_MINT_URL = TRUSTED_MINTS[0]
@@ -157,10 +158,6 @@ async def send_to_lnurl(amount: int, unit: CurrencyUnit, lnurl: str) -> dict[str
raise NotImplementedError
async def check_for_refunds() -> None:
logger.warning("check_for_refunds, temporary not implemented")
async def periodic_payout() -> None:
logger.warning("periodic_payout, temporary not implemented")
+17 -9
View File
@@ -43,24 +43,33 @@ class Model(TypedDict):
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "models.json")
BASE_URL = os.getenv("BASE_URL", "https://openrouter.ai/api/v1")
SOURCE = os.getenv("SOURCE")
def fetch_openrouter_models() -> list[Model]:
def fetch_openrouter_models(source_filter: str | None = None) -> list[Model]:
"""Fetches model information from OpenRouter API."""
with urlopen(f"{BASE_URL}/models") as response:
data = json.loads(response.read().decode("utf-8"))
models_data: list[Model] = []
for model in data.get("data", []):
# Skip models with '(free)' in the name or id = 'openrouter/auto'
model_id = model.get("id", "")
if source_filter:
source_prefix = f"{source_filter}/"
if not model_id.startswith(source_prefix):
continue
model = dict(model)
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if (
"(free)" in model.get("name", "")
or model.get("id") == "openrouter/auto"
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
):
continue
# Skip free Gemini 2.5 Pro Exp
if model.get("id") == "google/gemini-2.5-pro-exp-03-25":
continue
models_data.append(model)
@@ -68,10 +77,9 @@ def fetch_openrouter_models() -> list[Model]:
def main() -> None:
models = fetch_openrouter_models()
source_filter = SOURCE if SOURCE and SOURCE.strip() else None
models = fetch_openrouter_models(source_filter=source_filter)
# Print the first model data in a nicely indented JSON format
# print(json.dumps(models[0], indent=4))
print(f"Writing {len(models)} models to {OUTPUT_FILE}")
with open(OUTPUT_FILE, "w") as f:
+17 -13
View File
@@ -87,11 +87,18 @@ async def test_update_sats_pricing_calculation(sample_model: Model) -> None:
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.top_provider is not None
assert sample_model.top_provider.context_length is not None
assert sample_model.top_provider.max_completion_tokens is not None
assert sample_model.sats_pricing.max_cost == pytest.approx(
expected_max_context + expected_max_completion
(
sample_model.top_provider.context_length
- sample_model.top_provider.max_completion_tokens
)
* sample_model.sats_pricing.prompt
+ sample_model.top_provider.max_completion_tokens
* sample_model.sats_pricing.completion
)
# Cancel and await the task
@@ -159,16 +166,13 @@ async def test_update_sats_pricing_without_top_provider() -> None:
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
model_without_top.context_length
* 0.8
* model_without_top.sats_pricing.prompt
+ model_without_top.context_length
* 0.2
* model_without_top.sats_pricing.completion
)
# Cancel and await the task
Generated
+1 -7
View File
@@ -1742,7 +1742,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.1.0"
source = { editable = "." }
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "cashu" },
@@ -1758,8 +1758,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "aiohttp" },
{ name = "cashu" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "mypy" },
{ name = "openai" },
@@ -1768,7 +1766,6 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-benchmark" },
{ name = "pytest-cov" },
{ name = "rich" },
{ name = "ruff" },
]
@@ -1788,8 +1785,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "aiohttp", specifier = ">=3.9.0" },
{ name = "cashu", specifier = ">=0.17.0" },
{ name = "fastapi", specifier = ">=0.115.14" },
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "mypy", specifier = ">=1.15.0" },
{ name = "openai", specifier = ">=1.76.0" },
@@ -1798,7 +1793,6 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
{ name = "pytest-benchmark", specifier = ">=4.0.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "rich", specifier = ">=14.1.0" },
{ name = "ruff", specifier = ">=0.11.6" },
]