diff --git a/.env.example b/.env.example index 44782248..4a9b3123 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/.gitignore b/.gitignore index 97617323..de97c16f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ wallet.sqlite3 .cashu .relay relay-data +.dockerignore compose.override.yml diff --git a/README.md b/README.md index e4d60e14..b452bacc 100644 --- a/README.md +++ b/README.md @@ -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:///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:///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: diff --git a/compose.yml b/compose.yml index 0b3ba57e..8b0f67f2 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/router/core/logging.py b/router/core/logging.py index b6217cc9..9820b747 100644 --- a/router/core/logging.py +++ b/router/core/logging.py @@ -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, diff --git a/router/core/main.py b/router/core/main.py index d46a28d0..54707837 100644 --- a/router/core/main.py +++ b/router/core/main.py @@ -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( diff --git a/router/payment/helpers.py b/router/payment/helpers.py index 6b58882b..002b4093 100644 --- a/router/payment/helpers.py +++ b/router/payment/helpers.py @@ -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), ) diff --git a/router/payment/models.py b/router/payment/models.py index ef545c81..7b56b9ff 100644 --- a/router/payment/models.py +++ b/router/payment/models.py @@ -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} diff --git a/router/payment/price.py b/router/payment/price.py index 4f0f4c88..27a26e9d 100644 --- a/router/payment/price.py +++ b/router/payment/price.py @@ -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() diff --git a/router/payment/x_cashu.py b/router/payment/x_cashu.py index 591ae837..afbc0d7e 100644 --- a/router/payment/x_cashu.py +++ b/router/payment/x_cashu.py @@ -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 diff --git a/router/wallet.py b/router/wallet.py index e2f03884..e4b30c11 100644 --- a/router/wallet.py +++ b/router/wallet.py @@ -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") diff --git a/scripts/models_meta.py b/scripts/models_meta.py index 1b441c28..d95b5ddd 100755 --- a/scripts/models_meta.py +++ b/scripts/models_meta.py @@ -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: diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index f586fd93..cb101693 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -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 diff --git a/uv.lock b/uv.lock index 99a75bad..315fbbdf 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ]