diff --git a/.env.example b/.env.example index d212b9ff..b5d86e6e 100644 --- a/.env.example +++ b/.env.example @@ -1,43 +1,39 @@ # Core Configuration UPSTREAM_BASE_URL=https://api.openai.com/v1 UPSTREAM_API_KEY=your-upstream-api-key -ADMIN_PASSWORD=secure-admin-password + +# ADMIN_PASSWORD=secure-admin-password # Database -DATABASE_URL=sqlite+aiosqlite:///keys.db +# DATABASE_URL=sqlite+aiosqlite:///keys.db # Node Information -NAME=My Routstr Node -DESCRIPTION=Fast AI API access with Bitcoin payments -NPUB=npub1... -HTTP_URL=https://api.mynode.com -ONION_URL=http://mynode.onion +# NAME=My Routstr Node +# DESCRIPTION=Fast AI API access with Bitcoin payments +# NSEC=nsec1... +# HTTP_URL=https://api.mynode.com +# ONION_URL=http://mynode.onion (auto fetched from compose) +# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com" +# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me" +# RECEIVE_LN_ADDRESS= -# Cashu Configuration -CASHU_MINTS=https://mint.minibits.cash/Bitcoin -RECEIVE_LN_ADDRESS= - -# Pricing Configuration -MODEL_BASED_PRICING=true -COST_PER_REQUEST=1 -COST_PER_1K_INPUT_TOKENS=0 -COST_PER_1K_OUTPUT_TOKENS=0 -EXCHANGE_FEE=1.005 -UPSTREAM_PROVIDER_FEE=1.05 +# Custom Pricing Configuration +# MODEL_BASED_PRICING=true +# COST_PER_REQUEST=1 +# COST_PER_1K_INPUT_TOKENS=0 +# COST_PER_1K_OUTPUT_TOKENS=0 +# EXCHANGE_FEE=1.005 +# UPSTREAM_PROVIDER_FEE=1.05 # Network Configuration -CORS_ORIGINS=* -TOR_PROXY_URL=socks5://127.0.0.1:9050 +# CORS_ORIGINS=* +# TOR_PROXY_URL=socks5://127.0.0.1:9050 # Logging -LOG_LEVEL=INFO -ENABLE_CONSOLE_LOGGING=true +# LOG_LEVEL=INFO +# ENABLE_CONSOLE_LOGGING=true -# Model Management -MODELS_PATH=models.json -BASE_URL=https://openrouter.ai/api/v1 -SOURCE= - -# Optional Features -PREPAID_API_KEY= -PREPAID_BALANCE=0 \ No newline at end of file +# Custom Model Management +# BASE_URL=https://openrouter.ai/api/v1 +# MODELS_PATH=models.json +# SOURCE= diff --git a/README.md b/README.md index 861cad8f..07d25fe9 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ 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) -- `MODEL_BASED_PRICING` – Set to `true` to use pricing from `models.json` +- `FIXED_PRICING` – Set to `true` to use a fixed per-request price; `false` (default) uses model pricing from `models.json` - `ADMIN_PASSWORD` – Password for the `/admin/` dashboard - `CASHU_MINTS` – Comma-separated list of Cashu mint URLs - `NAME` – Name of the proxy diff --git a/compose.testing.yml b/compose.testing.yml index 36532d91..d734c447 100644 --- a/compose.testing.yml +++ b/compose.testing.yml @@ -19,10 +19,10 @@ services: - "ONION_URL=http://test.onion" - "CORS_ORIGINS=*" - "RECEIVE_LN_ADDRESS=test@routstr.com" - - "COST_PER_REQUEST=10" - - "COST_PER_1K_INPUT_TOKENS=0" - - "COST_PER_1K_OUTPUT_TOKENS=0" - - "MODEL_BASED_PRICING=true" + - "FIXED_COST_PER_REQUEST=10" + - "FIXED_PER_1K_INPUT_TOKENS=0" + - "FIXED_PER_1K_OUTPUT_TOKENS=0" + - "FIXED_PRICING=false" - "NSEC=nsec1testkey1234567890abcdef" - "REFUND_PROCESSING_INTERVAL=3600" - "MINIMUM_PAYOUT=1000" diff --git a/compose.yml b/compose.yml index aabf8d62..ec814c27 100644 --- a/compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: routstr: build: . @@ -26,12 +24,5 @@ services: depends_on: - routstr - # Legacy service definition to ensure cleanup of old container - router: - image: alpine:latest - command: /bin/true - profiles: - - cleanup - volumes: tor-data: diff --git a/docs/advanced/custom-pricing.md b/docs/advanced/custom-pricing.md index 1ab429c7..27d1f153 100644 --- a/docs/advanced/custom-pricing.md +++ b/docs/advanced/custom-pricing.md @@ -14,11 +14,11 @@ Routstr supports three pricing models: ### Configuration -Enable model-based pricing: +Enable model-based pricing (default behavior): ```bash # .env -MODEL_BASED_PRICING=true +FIXED_PRICING=false MODELS_PATH=/app/config/models.json EXCHANGE_FEE=1.005 # 0.5% exchange fee UPSTREAM_PROVIDER_FEE=1.05 # 5% provider margin @@ -118,14 +118,14 @@ if __name__ == "__main__": ### Configuration -Set up token-based pricing: +Set up token-based pricing overrides: ```bash # .env -MODEL_BASED_PRICING=false -COST_PER_REQUEST=1 # 1 sat base fee -COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input -COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output +FIXED_PRICING=false # use model pricing +FIXED_COST_PER_REQUEST=1 # optional base fee +FIXED_PER_1K_INPUT_TOKENS=5 # optional override +FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override ``` ### Custom Token Counting diff --git a/docs/advanced/nostr.md b/docs/advanced/nostr.md index e3aebcb1..dd0fecaf 100644 --- a/docs/advanced/nostr.md +++ b/docs/advanced/nostr.md @@ -102,7 +102,7 @@ Configure which relays to publish to: DEFAULT_RELAYS = [ "wss://relay.damus.io", "wss://relay.nostr.band", - "wss://nostr.mom", + "wss://relay.routstr.com", "wss://nos.lol" ] diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 345eed00..abff6d64 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -436,6 +436,27 @@ Authorization: Bearer sk-... ## Provider Discovery +## Admin Settings + +These endpoints are protected by the Admin cookie (`admin_password` set to your configured admin password). + +### Get Settings + +```http +GET /admin/api/settings +``` + +Returns the current application settings (sensitive values may be redacted). + +### Update Settings + +```http +PATCH /admin/api/settings +Content-Type: application/json +``` + +Body is a partial JSON of settings fields to update. Validated and persisted to the database. + ### List Providers Get available upstream providers. diff --git a/docs/api/overview.md b/docs/api/overview.md index a09ccc21..47abbd7a 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -347,7 +347,7 @@ GET /health Response: { "status": "healthy", - "version": "0.1.2", + "version": "0.1.3", "timestamp": "2024-01-01T00:00:00Z", "checks": { "database": "ok", diff --git a/docs/contributing/code-structure.md b/docs/contributing/code-structure.md index e1ff26ee..4b141b20 100644 --- a/docs/contributing/code-structure.md +++ b/docs/contributing/code-structure.md @@ -348,7 +348,7 @@ Project metadata and dependencies: ```toml [project] name = "routstr" -version = "0.1.2" +version = "0.1.3" dependencies = [ "fastapi[standard]>=0.115", "sqlmodel>=0.0.24", diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 904553b7..246678c6 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -1,6 +1,6 @@ # Configuration -Routstr Core is configured through environment variables. This guide covers all available options. +Routstr Core is configured via a single settings row in the database. Environment variables are only used on first run to seed that row (with a few computed defaults like `ONION_URL`). After that, the database is the source of truth. You can update settings at runtime via the admin API. `DATABASE_URL` is always env-only. ## Environment Variables @@ -33,19 +33,21 @@ Routstr Core is configured through environment variables. This guide covers all | Variable | Description | Default | Required | |----------|-------------|---------|----------| -| `MODEL_BASED_PRICING` | Enable model-specific pricing from models.json | `false` | ❌ | -| `COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ | -| `COST_PER_1K_INPUT_TOKENS` | Cost per 1000 input tokens in sats | `0` | ❌ | -| `COST_PER_1K_OUTPUT_TOKENS` | Cost per 1000 output tokens in sats | `0` | ❌ | +| `FIXED_PRICING` | Force fixed per-request pricing (ignore model token pricing) | `false` | ❌ | +| `FIXED_COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ | +| `FIXED_PER_1K_INPUT_TOKENS` | Optional override: sats per 1000 input tokens | `0` | ❌ | +| `FIXED_PER_1K_OUTPUT_TOKENS` | Optional override: sats per 1000 output tokens | `0` | ❌ | | `EXCHANGE_FEE` | Exchange rate markup (1.005 = 0.5% fee) | `1.005` | ❌ | | `UPSTREAM_PROVIDER_FEE` | Provider fee markup (1.05 = 5% fee) | `1.05` | ❌ | -### Network Configuration +### Network & Discovery | Variable | Description | Default | Required | |----------|-------------|---------|----------| | `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | `*` | ❌ | | `TOR_PROXY_URL` | SOCKS5 proxy URL for Tor connections | `socks5://127.0.0.1:9050` | ❌ | +| `RELAYS` | Comma-separated nostr relays used for provider discovery | sane defaults | ❌ | +| `PROVIDERS_REFRESH_INTERVAL_SECONDS` | Provider cache refresh interval | `300` | ❌ | ### Logging Configuration @@ -60,6 +62,7 @@ Routstr Core is configured through environment variables. This guide covers all |----------|-------------|---------|----------| | `CHAT_COMPLETIONS_API_VERSION` | Append `api-version` to `/chat/completions` (Azure OpenAI) | - | ❌ | | `DATABASE_URL` | SQLite database connection string | `sqlite+aiosqlite:///keys.db` | ❌ | +| `REFUND_CACHE_TTL_SECONDS` | Cache TTL for refund responses (seconds) | `3600` | ❌ | ## Configuration Examples @@ -78,7 +81,6 @@ ADMIN_PASSWORD=my-secure-password # .env UPSTREAM_BASE_URL=https://api.anthropic.com/v1 UPSTREAM_API_KEY=your-anthropic-key -MODEL_BASED_PRICING=true MODELS_PATH=/app/config/anthropic-models.json ``` @@ -115,36 +117,21 @@ ONION_URL=http://lightningai.onion CASHU_MINTS=https://mint1.com,https://mint2.com ``` -## Pricing Models +## Pricing -### Fixed Pricing +- Default: pricing comes from your `models.json`. +- Force fixed per-request pricing: set `FIXED_PRICING=true` and `FIXED_COST_PER_REQUEST`. +- Optional token overrides when using model pricing: set + `FIXED_PER_1K_INPUT_TOKENS` and/or `FIXED_PER_1K_OUTPUT_TOKENS`. +- Legacy envs are still accepted and mapped automatically: + `MODEL_BASED_PRICING` → `!FIXED_PRICING`, `COST_PER_REQUEST` → `FIXED_COST_PER_REQUEST`, + `COST_PER_1K_*` → `FIXED_PER_1K_*`. -Simple per-request pricing: +Example fixed pricing: ```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=10 # 10 sats per request -``` - -### Token-Based Pricing - -Charge based on token usage: - -```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=1 # 1 sat base fee -COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1k input -COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1k output -``` - -### Model-Based Pricing - -Use dynamic pricing from models.json: - -```bash -MODEL_BASED_PRICING=true -EXCHANGE_FEE=1.01 # 1% exchange fee -UPSTREAM_PROVIDER_FEE=1.00 # No additional markup +FIXED_PRICING=true +FIXED_COST_PER_REQUEST=10 ``` ## Custom Models Configuration diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 0620937f..9357a44f 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -115,8 +115,8 @@ NPUB=npub1... HTTP_URL=https://api.mynode.com ONION_URL=http://mynode.onion -# Pricing -MODEL_BASED_PRICING=true +# Pricing (optional) +FIXED_PRICING=false EXCHANGE_FEE=1.005 UPSTREAM_PROVIDER_FEE=1.05 ``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 0ef96c02..e179adcb 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -67,7 +67,7 @@ You should see: { "name": "ARoutstrNode", "description": "A Routstr Node", - "version": "0.1.2", + "version": "0.1.3", "npub": "", "mints": ["https://mint.minibits.cash/Bitcoin"], "models": {...} diff --git a/docs/user-guide/models-pricing.md b/docs/user-guide/models-pricing.md index 24ce7d6c..c5f08850 100644 --- a/docs/user-guide/models-pricing.md +++ b/docs/user-guide/models-pricing.md @@ -11,8 +11,8 @@ Routstr supports three pricing models: Simple per-request charging: ```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=10 # 10 sats per request +FIXED_PRICING=true +FIXED_COST_PER_REQUEST=10 # 10 sats per request ``` **Best for:** @@ -26,10 +26,10 @@ COST_PER_REQUEST=10 # 10 sats per request Charge based on actual token usage: ```bash -MODEL_BASED_PRICING=false -COST_PER_REQUEST=1 # 1 sat base fee -COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input -COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output +FIXED_PRICING=false # use model pricing +FIXED_COST_PER_REQUEST=1 # optional base fee +FIXED_PER_1K_INPUT_TOKENS=5 # optional override +FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override ``` **Best for:** @@ -43,7 +43,7 @@ COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output Dynamic pricing based on model costs: ```bash -MODEL_BASED_PRICING=true +FIXED_PRICING=false EXCHANGE_FEE=1.005 # 0.5% exchange fee UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee ``` diff --git a/migrations/versions/a1b2c3d4e5f6_add_settings_table.py b/migrations/versions/a1b2c3d4e5f6_add_settings_table.py new file mode 100644 index 00000000..f3c898b8 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_add_settings_table.py @@ -0,0 +1,35 @@ +"""add settings table + +Revision ID: a1b2c3d4e5f6 +Revises: 042f6b77d69d +Create Date: 2025-09-06 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "042f6b77d69d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "settings", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("data", sa.Text(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=True, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + +def downgrade() -> None: + op.drop_table("settings") diff --git a/migrations/versions/c0ffee123456_create_models_table.py b/migrations/versions/c0ffee123456_create_models_table.py new file mode 100644 index 00000000..992bb2d7 --- /dev/null +++ b/migrations/versions/c0ffee123456_create_models_table.py @@ -0,0 +1,37 @@ +"""create models table + +Revision ID: c0ffee123456 +Revises: a1b2c3d4e5f6 +Create Date: 2025-09-10 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c0ffee123456" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "models", + sa.Column("id", sa.String(), primary_key=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created", sa.Integer(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column("context_length", sa.Integer(), nullable=False), + sa.Column("architecture", sa.Text(), nullable=False), + sa.Column("pricing", sa.Text(), nullable=False), + sa.Column("sats_pricing", sa.Text(), nullable=True), + sa.Column("per_request_limits", sa.Text(), nullable=True), + sa.Column("top_provider", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("models") diff --git a/pyproject.toml b/pyproject.toml index a7078f19..1f554101 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.1.2" +version = "0.1.3" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" @@ -18,6 +18,7 @@ dependencies = [ "marshmallow>=3.13,<4.0", "websockets>=12.0", "nostr>=0.0.2", + "mdurl==0.1.2", ] [dependency-groups] diff --git a/routstr/__init__.py b/routstr/__init__.py index 7a1bc151..1bdd854c 100644 --- a/routstr/__init__.py +++ b/routstr/__init__.py @@ -1,7 +1,3 @@ -import dotenv - -dotenv.load_dotenv() - from .core.main import app as fastapi_app # noqa __all__ = ["fastapi_app"] diff --git a/routstr/auth.py b/routstr/auth.py index 6202a69a..1398bfa6 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -7,18 +7,14 @@ from sqlmodel import col, update from .core import get_logger from .core.db import ApiKey, AsyncSession +from .core.settings import settings from .payment.cost_caculation import ( CostData, CostDataError, MaxCostData, calculate_cost, ) -from .wallet import ( - PRIMARY_MINT_URL, - TRUSTED_MINTS, - credit_balance, - deserialize_token_from_string, -) +from .wallet import credit_balance, deserialize_token_from_string logger = get_logger(__name__) @@ -165,12 +161,12 @@ async def validate_bearer_key( "has_expiry_time": bool(key_expiry_time), }, ) - if token_obj.mint in TRUSTED_MINTS: + if token_obj.mint in settings.cashu_mints: refund_currency = token_obj.unit refund_mint_url = token_obj.mint else: refund_currency = "sat" - refund_mint_url = PRIMARY_MINT_URL + refund_mint_url = settings.primary_mint new_key = ApiKey( hashed_key=hashed_key, @@ -426,7 +422,7 @@ async def adjust_payment_for_tokens( }, ) - match calculate_cost(response_data, deducted_max_cost): + match await calculate_cost(response_data, deducted_max_cost, session): case MaxCostData() as cost: logger.debug( "Using max cost data (no token adjustment)", @@ -637,3 +633,10 @@ async def adjust_payment_for_tokens( } }, ) + # Fallback return to satisfy type checker; execution should not reach here + return { + "base_msats": deducted_max_cost, + "input_msats": 0, + "output_msats": 0, + "total_msats": deducted_max_cost, + } diff --git a/routstr/balance.py b/routstr/balance.py index 97039905..76e87498 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -1,6 +1,5 @@ import asyncio import hashlib -import os from time import monotonic from typing import Annotated, NoReturn @@ -9,7 +8,8 @@ from pydantic import BaseModel from .auth import validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session -from .wallet import PRIMARY_MINT_URL, credit_balance, send_to_lnurl, send_token +from .core.settings import settings +from .wallet import credit_balance, send_to_lnurl, send_token router = APIRouter() balance_router = APIRouter(prefix="/v1/balance") @@ -34,6 +34,7 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict: return { "api_key": "sk-" + key.hashed_key, "balance": key.balance, + "reserved": key.reserved_balance, } @@ -65,6 +66,7 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict: return { "api_key": "sk-" + key.hashed_key, "balance": key.balance, + "reserved": key.reserved_balance, } @@ -102,7 +104,7 @@ async def topup_wallet_endpoint( return {"msats": amount_msats} -_REFUND_CACHE_TTL_SECONDS: int = int(os.environ.get("REFUND_CACHE_TTL_SECONDS", "3600")) +_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds _refund_cache_lock: asyncio.Lock = asyncio.Lock() _refund_cache: dict[str, tuple[float, dict[str, str]]] = {} @@ -157,11 +159,13 @@ async def refund_wallet_endpoint( try: if key.refund_address: if key.refund_currency == "sat": - remaining_balance = remaining_balance_msats * 1000 + remaining_balance = remaining_balance_msats // 1000 + from .core.settings import settings as global_settings + await send_to_lnurl( remaining_balance, key.refund_currency or "sat", - key.refund_mint_url or PRIMARY_MINT_URL, + key.refund_mint_url or global_settings.primary_mint, key.refund_address, ) result = {"recipient": key.refund_address} diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 4bf6b00a..b52fdde6 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -3,13 +3,12 @@ import os from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlmodel import select from ..wallet import ( - TRUSTED_MINTS, fetch_all_balances, get_proofs_per_mint_and_unit, get_wallet, @@ -18,12 +17,152 @@ from ..wallet import ( ) from .db import ApiKey, create_session from .logging import get_logger +from .settings import SettingsService, settings logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) +def require_admin_api(request: Request) -> None: + admin_cookie = request.cookies.get("admin_password") + if not admin_cookie or admin_cookie != settings.admin_password: + raise HTTPException(status_code=403, detail="Unauthorized") + + +def is_admin_authenticated(request: Request) -> bool: + admin_cookie = request.cookies.get("admin_password") + return bool(admin_cookie and admin_cookie == settings.admin_password) + + +@admin_router.get( + "/partials/balances", + dependencies=[Depends(require_admin_api)], + response_class=HTMLResponse, +) +async def partial_balances(request: Request) -> str: + ( + balance_details, + total_wallet_balance_sats, + total_user_balance_sats, + owner_balance, + ) = await fetch_all_balances() + # Provide JSON for client usage + # Embed a script tag to update balanceDetails and the UI markup + rows = "".join( + [ + f"""
+
{detail["mint_url"].replace("https://", "").replace("http://", "")} • {detail["unit"].upper()}
+
{detail["wallet_balance"] if not detail.get("error") else "error"}
+
{detail["user_balance"] if not detail.get("error") else "-"}
+
0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}
+
""" + for detail in balance_details + if detail.get("wallet_balance", 0) > 0 or detail.get("error") + ] + ) + return f""" +

Cashu Wallet Balance

+
+ Your Balance (Total) + {owner_balance} sats +
+
+ Total Wallet + {total_wallet_balance_sats} sats +
+
+ User Balance + {total_user_balance_sats} sats +
+

Your balance = Total wallet - User balance

+
+
+
Mint / Unit
+
Wallet
+
Users
+
Owner
+
+ {rows} +
+ + """ + + +@admin_router.get( + "/partials/apikeys", + dependencies=[Depends(require_admin_api)], + response_class=HTMLResponse, +) +async def partial_apikeys(request: Request) -> str: + async with create_session() as session: + result = await session.exec(select(ApiKey)) + api_keys = result.all() + + def fmt_time(ts: int | None) -> str: + if ts is None: + return "" + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + return f"{ts} ({dt.strftime('%Y-%m-%d %H:%M:%S')} UTC)" + + rows = "".join( + [ + f"{key.hashed_key}{key.balance}{key.total_spent}{key.total_requests}{key.refund_address}{fmt_time(key.key_expiry_time)}" + for key in api_keys + ] + ) + return f""" +

Temporary Balances

+ + + + + + + + + + {rows} +
Hashed KeyBalance (mSats)Total Spent (mSats)Total RequestsRefund AddressRefund Time
+ """ + + +@admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)]) +async def get_balances_api(request: Request) -> list[dict[str, object]]: + balance_details, _tw, _tu, _ow = await fetch_all_balances() + return [dict(d) for d in balance_details] + + +@admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)]) +async def get_settings(request: Request) -> dict: + data = settings.dict() + if "upstream_api_key" in data: + data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" + if "admin_password" in data: + data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" + if "nsec" in data: + data["nsec"] = "[REDACTED]" if data["nsec"] else "" + return data + + +class SettingsUpdate(BaseModel): + __root__: dict[str, object] + + +@admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)]) +async def update_settings(request: Request, update: SettingsUpdate) -> dict: + async with create_session() as session: + new_settings = await SettingsService.update(update.__root__, session) + data = new_settings.dict() + if "upstream_api_key" in data: + data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" + if "admin_password" in data: + data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" + if "nsec" in data: + data["nsec"] = "[REDACTED]" if data["nsec"] else "" + return data + + class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None @@ -87,298 +226,301 @@ def info(content: str) -> str: def admin_auth() -> str: - if os.getenv("ADMIN_PASSWORD", "") == "": + try: + settings = SettingsService.get() + admin_pw = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") + if admin_pw == "": return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.") else: return login_form() async def dashboard(request: Request) -> str: - # fetch cashu / api-key data from database - async with create_session() as session: - result = await session.exec(select(ApiKey)) - api_keys = result.all() - - api_keys_table_rows = [] - for key in api_keys: - expiry_time_utc = ( - datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc) - if key.key_expiry_time is not None - else None - ) - expiry_time_human_readable = ( - expiry_time_utc.strftime("%Y-%m-%d %H:%M:%S") if expiry_time_utc else "" - ) - - api_keys_table_rows.append( - f"{key.hashed_key}{key.balance}{key.total_spent}{key.total_requests}{key.refund_address}{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}" - ) - - # Fetch all balances using the abstracted function - ( - balance_details, - total_wallet_balance_sats, - total_user_balance_sats, - owner_balance, - ) = await fetch_all_balances() - - return f""" + return ( + f""" - + + + """ + + """ - + + """ + + """

Admin Dashboard

-
-

Cashu Wallet Balance

-
- Your Balance (Total) - { - owner_balance - } sats -
-
- Total Wallet - {total_wallet_balance_sats} sats -
-
- User Balance - {total_user_balance_sats} sats -
-

Your balance = Total wallet - User balance

- -
-
-
Mint / Unit
-
Wallet
-
Users
-
Owner
-
- { - "".join( - [ - f'''
-
{detail["mint_url"].replace("https://", "").replace("http://", "")} • {detail["unit"].upper()}
-
{detail["wallet_balance"] if not detail.get("error") else "error"}
-
{detail["user_balance"] if not detail.get("error") else "-"}
-
0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}
-
''' - for detail in balance_details - if detail.get("wallet_balance", 0) > 0 or detail.get("error") - ] - ) - } -
+
+
Loading balances…
- + + + " formatted_logs.append(formatted_entry) - return f""" + return ( + f""" - + {LOGS_CSS} + + + + """ + + f""" ← Back to Dashboard

Log Investigation

@@ -653,23 +714,22 @@ async def view_logs(request: Request, request_id: str) -> str: """ + ) -@admin_router.post("/withdraw") +@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)]) async def withdraw( request: Request, withdraw_request: WithdrawRequest ) -> dict[str, str]: - admin_cookie = request.cookies.get("admin_password") - if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"): - raise HTTPException(status_code=403, detail="Unauthorized") - # Get wallet and check balance + from .settings import settings as global_settings + wallet = await get_wallet( - withdraw_request.mint_url or TRUSTED_MINTS[0], withdraw_request.unit + withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit ) proofs = get_proofs_per_mint_and_unit( wallet, - withdraw_request.mint_url or TRUSTED_MINTS[0], + withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit, not_reserved=True, ) @@ -688,3 +748,69 @@ async def withdraw( withdraw_request.amount, withdraw_request.unit, withdraw_request.mint_url ) return {"token": token} + + +DASHBOARD_CSS: str = """ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; } +h1, h2 { margin-bottom: 1rem; color: #1a202c; } +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; margin-top: 2rem; } +p { margin-bottom: 0.5rem; color: #4a5568; } +table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 1rem; } +th { background: #4a5568; color: white; font-weight: 600; padding: 12px; text-align: left; } +td { padding: 12px; border-bottom: 1px solid #e2e8f0; } +tr:hover { background: #f7fafc; } +button { padding: 10px 20px; cursor: pointer; background: #4299e1; color: white; border: none; border-radius: 6px; font-weight: 600; margin-right: 10px; transition: all 0.2s; } +button:hover { background: #3182ce; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } +button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; } +.refresh-btn { background: #48bb78; } +.refresh-btn:hover { background: #38a169; } +.investigate-btn { background: #4299e1; } +.balance-card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; } +.balance-item { display: flex; justify-content: space-between; margin-bottom: 1rem; } +.balance-label { color: #718096; } +.balance-value { font-size: 1.5rem; font-weight: 700; color: #2d3748; } +.balance-primary { color: #48bb78; } +.currency-grid { margin-top: 1rem; font-size: 0.9rem; } +.currency-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; align-items: center; } +.currency-row:last-child { border-bottom: none; } +.currency-header { font-weight: 600; color: #4a5568; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; } +.mint-name { color: #2d3748; font-size: 0.85rem; word-break: break-all; } +.balance-num { text-align: right; font-family: monospace; } +.owner-positive { color: #22c55e; } +.error-row { color: #dc2626; font-style: italic; } +#token-result { margin-top: 20px; padding: 20px; background: #e6fffa; border: 1px solid #38b2ac; border-radius: 8px; display: none; } +#token-text { font-family: 'Monaco', monospace; font-size: 13px; background: #2d3748; color: #68d391; padding: 15px; border-radius: 6px; margin: 10px 0; word-break: break-all; } +.copy-btn { background: #38a169; padding: 6px 12px; font-size: 14px; } +.copy-btn:hover { background: #2f855a; } +.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); } +.modal-content { background: white; margin: 10% auto; padding: 2rem; width: 90%; max-width: 400px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } +@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +.close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; } +.close:hover { color: #2d3748; } +input[type="number"], input[type="text"], select { width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; } +input[type="number"]:focus, input[type="text"]:focus, select:focus { outline: none; border-color: #4299e1; } +.warning { color: #e53e3e; font-weight: 600; margin: 10px 0; padding: 10px; background: #fff5f5; border-radius: 6px; } +""" + + +LOGS_CSS: str = """ +body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; } +h1 { color: #333; } +.back-btn { padding: 8px 16px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; margin-bottom: 20px; } +.back-btn:hover { background-color: #0056b3; } +.log-container { background-color: white; border: 1px solid #ddd; border-radius: 8px; padding: 20px; max-height: 80vh; overflow-y: auto; } +.log-entry { margin-bottom: 15px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; background-color: #f9f9f9; } +.log-entry.log-error { background-color: #fee; border-color: #fcc; } +.log-entry.log-warning { background-color: #ffc; border-color: #ff9; } +.log-entry.log-debug, .log-entry.log-trace { background-color: #f0f0f0; border-color: #ccc; } +.log-header { margin-bottom: 5px; color: #666; } +.log-timestamp { color: #0066cc; } +.log-level { font-weight: bold; } +.log-message { margin: 5px 0; color: #333; } +.log-extra { margin-top: 5px; padding-top: 5px; border-top: 1px solid #e0e0e0; } +.log-field { margin: 2px 0; color: #666; word-break: break-all; } +.no-logs { text-align: center; color: #666; padding: 40px; } +.request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; } +""" diff --git a/routstr/core/db.py b/routstr/core/db.py index 46696f02..9f886791 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -52,6 +52,20 @@ class ApiKey(SQLModel, table=True): # type: ignore return self.balance - self.reserved_balance +class ModelRow(SQLModel, table=True): # type: ignore + __tablename__ = "models" + id: str = Field(primary_key=True) + name: str = Field() + created: int = Field() + description: str = Field() + context_length: int = Field() + architecture: str = Field() + pricing: str = Field() + sats_pricing: str | None = Field(default=None) + per_request_limits: str | None = Field(default=None) + top_provider: str | None = Field(default=None) + + async def balances_for_mint_and_unit( db_session: AsyncSession, mint_url: str, unit: str ) -> int: diff --git a/routstr/core/logging.py b/routstr/core/logging.py index d7ec38e5..d682b944 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -181,7 +181,12 @@ class SecurityFilter(logging.Filter): def get_log_level() -> str: """Get log level from environment variable.""" - level = os.environ.get("LOG_LEVEL", "INFO").upper() + try: + from .settings import settings + + level = settings.log_level.upper() + except Exception: + level = os.environ.get("LOG_LEVEL", "INFO").upper() # Validate log level - if invalid, default to INFO valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if level not in valid_levels: @@ -191,11 +196,16 @@ def get_log_level() -> str: def should_enable_console_logging() -> bool: """Check if console logging should be enabled.""" - return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in ( - "true", - "1", - "yes", - ) + try: + from .settings import settings + + return bool(settings.enable_console_logging) + except Exception: + return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in ( + "true", + "1", + "yes", + ) def setup_logging() -> None: diff --git a/routstr/core/main.py b/routstr/core/main.py index d522ad7f..b73b21b5 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -1,5 +1,4 @@ import asyncio -import os from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -11,20 +10,27 @@ from starlette.exceptions import HTTPException from ..balance import balance_router, deprecated_wallet_router from ..discovery import providers_cache_refresher, providers_router from ..nip91 import announce_provider -from ..payment.models import MODELS, models_router, update_sats_pricing +from ..payment.models import ( + ensure_models_bootstrapped, + models_router, + refresh_models_periodically, + update_sats_pricing, +) from ..proxy import proxy_router from ..wallet import periodic_payout from .admin import admin_router -from .db import init_db, run_migrations +from .db import create_session, init_db, run_migrations from .exceptions import general_exception_handler, http_exception_handler from .logging import get_logger, setup_logging from .middleware import LoggingMiddleware +from .settings import SettingsService +from .settings import settings as global_settings # Initialize logging first setup_logging() logger = get_logger(__name__) -__version__ = "0.1.2" +__version__ = "0.1.3" @asynccontextmanager @@ -35,6 +41,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: payout_task = None nip91_task = None providers_task = None + models_refresh_task = None try: # Run database migrations on startup @@ -47,7 +54,21 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: # This creates any tables that might not be tracked by migrations yet await init_db() + # Initialize application settings (env -> computed -> DB precedence) + async with create_session() as session: + s = await SettingsService.initialize(session) + + # Apply app metadata from settings + try: + app.title = s.name + app.description = s.description + except Exception: + pass + + await ensure_models_bootstrapped() pricing_task = asyncio.create_task(update_sats_pricing()) + if global_settings.models_refresh_interval_seconds > 0: + models_refresh_task = asyncio.create_task(refresh_models_periodically()) payout_task = asyncio.create_task(periodic_payout()) nip91_task = asyncio.create_task(announce_provider()) providers_task = asyncio.create_task(providers_cache_refresher()) @@ -71,6 +92,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: nip91_task.cancel() if providers_task is not None: providers_task.cancel() + if models_refresh_task is not None: + models_refresh_task.cancel() try: tasks_to_wait = [] @@ -82,6 +105,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(nip91_task) if providers_task is not None: tasks_to_wait.append(providers_task) + if models_refresh_task is not None: + tasks_to_wait.append(models_refresh_task) if tasks_to_wait: await asyncio.gather(*tasks_to_wait, return_exceptions=True) @@ -93,18 +118,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: ) -app = FastAPI( - version=__version__, - title=os.environ.get("NAME", "ARoutstrNode" + __version__), - description=os.environ.get("DESCRIPTION", "A Routstr Node"), - contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")}, - lifespan=lifespan, -) +app = FastAPI(version=__version__, lifespan=lifespan) + -# Configure CORS app.add_middleware( CORSMiddleware, - allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","), + allow_origins=global_settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -123,14 +142,14 @@ app.add_exception_handler(Exception, general_exception_handler) @app.get("/v1/info") async def info() -> dict: return { - "name": app.title, - "description": app.description, + "name": global_settings.name, + "description": global_settings.description, "version": __version__, - "npub": os.environ.get("NPUB", ""), - "mints": os.environ.get("CASHU_MINTS", "").split(","), - "http_url": os.environ.get("HTTP_URL", ""), - "onion_url": os.environ.get("ONION_URL", ""), - "models": MODELS, + "npub": global_settings.npub, + "mints": global_settings.cashu_mints, + "http_url": global_settings.http_url, + "onion_url": global_settings.onion_url, + "models": [], # kept for back-compat; prefer /v1/models } diff --git a/routstr/core/settings.py b/routstr/core/settings.py new file mode 100644 index 00000000..35560a4c --- /dev/null +++ b/routstr/core/settings.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import asyncio +import json +import os +from datetime import datetime, timezone +from typing import Any + +from pydantic.v1 import BaseModel, BaseSettings, Field +from sqlmodel.ext.asyncio.session import AsyncSession + + +class Settings(BaseSettings): + class Config: + case_sensitive = True + + @classmethod + def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override] + if field_name in {"cashu_mints", "cors_origins", "relays"}: + v = str(raw_value).strip() + if v == "": + return [] + return [p.strip() for p in v.split(",") if p.strip()] + return raw_value + + # Core + upstream_base_url: str = Field(default="", env="UPSTREAM_BASE_URL") + upstream_api_key: str = Field(default="", env="UPSTREAM_API_KEY") + admin_password: str = Field(default="", env="ADMIN_PASSWORD") + + # Node info + name: str = Field(default="ARoutstrNode", env="NAME") + description: str = Field(default="A Routstr Node", env="DESCRIPTION") + npub: str = Field(default="", env="NPUB") + http_url: str = Field(default="", env="HTTP_URL") + onion_url: str = Field(default="", env="ONION_URL") + + # Cashu + cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS") + receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS") + primary_mint: str = Field(default="", env="PRIMARY_MINT_URL") + + # Pricing + # Default behavior: derive pricing from MODELS + # If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens + # If fixed_per_1k_* are set (non-zero) -> override model token pricing when model-based + fixed_pricing: bool = Field(default=False, env="FIXED_PRICING") + fixed_cost_per_request: int = Field(default=1, env="FIXED_COST_PER_REQUEST") + fixed_per_1k_input_tokens: int = Field(default=0, env="FIXED_PER_1K_INPUT_TOKENS") + fixed_per_1k_output_tokens: int = Field(default=0, env="FIXED_PER_1K_OUTPUT_TOKENS") + exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE") + upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE") + tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE") + # Minimum per-request charge in millisatoshis when model pricing is free/zero + min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT") + + # Network + cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS") + tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL") + providers_refresh_interval_seconds: int = Field( + default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS" + ) + pricing_refresh_interval_seconds: int = Field( + default=120, env="PRICING_REFRESH_INTERVAL_SECONDS" + ) + models_refresh_interval_seconds: int = Field( + default=0, env="MODELS_REFRESH_INTERVAL_SECONDS" + ) + enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH") + enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH") + refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS") + + # Logging + log_level: str = Field(default="INFO", env="LOG_LEVEL") + enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING") + + # Other + chat_completions_api_version: str = Field( + default="", env="CHAT_COMPLETIONS_API_VERSION" + ) + models_path: str = Field(default="models.json", env="MODELS_PATH") + source: str = Field(default="", env="SOURCE") + + # Secrets / optional runtime controls + provider_id: str = Field(default="", env="PROVIDER_ID") + nsec: str = Field(default="", env="NSEC") + + # Discovery + relays: list[str] = Field(default_factory=list, env="RELAYS") + + +def _compute_primary_mint(cashu_mints: list[str]) -> str: + return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin" + + +def resolve_bootstrap() -> Settings: + base = Settings() # Reads env with custom parse_env_var + # Back-compat env mapping + try: + # Map MODEL_BASED_PRICING -> fixed_pricing (inverted) + if "MODEL_BASED_PRICING" in os.environ and "FIXED_PRICING" not in os.environ: + mbp_raw = os.environ.get("MODEL_BASED_PRICING", "").strip().lower() + mbp = mbp_raw in {"1", "true", "yes", "on"} + base.fixed_pricing = not mbp + # Map COST_PER_REQUEST -> fixed_cost_per_request if new not provided + if ( + "COST_PER_REQUEST" in os.environ + and "FIXED_COST_PER_REQUEST" not in os.environ + ): + try: + base.fixed_cost_per_request = int( + os.environ["COST_PER_REQUEST"].strip() + ) + except Exception: + pass + # Map COST_PER_1K_* -> CUSTOM_PER_1K_* + if ( + "COST_PER_1K_INPUT_TOKENS" in os.environ + and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ + ): + try: + base.fixed_per_1k_input_tokens = int( + os.environ["COST_PER_1K_INPUT_TOKENS"].strip() + ) + except Exception: + pass + if ( + "COST_PER_1K_OUTPUT_TOKENS" in os.environ + and "FIXED_PER_1K_OUTPUT_TOKENS" not in os.environ + ): + try: + base.fixed_per_1k_output_tokens = int( + os.environ["COST_PER_1K_OUTPUT_TOKENS"].strip() + ) + except Exception: + pass + except Exception: + pass + if not base.onion_url: + try: + from ..nip91 import discover_onion_url_from_tor # type: ignore + + discovered = discover_onion_url_from_tor() + if discovered: + base.onion_url = discovered + except Exception: + pass + # Derive NPUB from NSEC if not provided + if not base.npub and base.nsec: + try: + from nostr.key import PrivateKey # type: ignore + + if base.nsec.startswith("nsec"): + pk = PrivateKey.from_nsec(base.nsec) + elif len(base.nsec) == 64: + pk = PrivateKey(bytes.fromhex(base.nsec)) + else: + pk = None + if pk is not None: + try: + base.npub = pk.public_key.bech32() + except Exception: + # Fallback to hex if bech32 not available + base.npub = pk.public_key.hex() + except Exception: + pass + if not base.cors_origins: + base.cors_origins = ["*"] + if not base.primary_mint: + base.primary_mint = _compute_primary_mint(base.cashu_mints) + return base + + +class SettingsRow(BaseModel): + id: int + data: dict[str, Any] + updated_at: datetime | None = None + + +# Single, concrete settings instance that callers import directly +settings: Settings = resolve_bootstrap() + + +class SettingsService: + _current: Settings | None = None + _lock: asyncio.Lock = asyncio.Lock() + + @classmethod + def get(cls) -> Settings: + if cls._current is None: + raise RuntimeError("SettingsService not initialized") + return cls._current + + @classmethod + async def initialize(cls, db_session: AsyncSession) -> Settings: + async with cls._lock: + from sqlmodel import text + + await db_session.exec( # type: ignore + text( + "CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" + ) + ) + + row = await db_session.exec( # type: ignore + text("SELECT id, data, updated_at FROM settings WHERE id = 1") + ) + row = row.first() + env_resolved = resolve_bootstrap() + + if row is None: + await db_session.exec( # type: ignore + text( + "INSERT INTO settings (id, data, updated_at) VALUES (1, :data, :updated_at)" + ).bindparams( + data=json.dumps(env_resolved.dict()), + updated_at=datetime.now(timezone.utc), + ) + ) + await db_session.commit() + cls._current = settings + # Update the existing instance in-place for all live importers + for k, v in env_resolved.dict().items(): + setattr(settings, k, v) + return cls._current + + db_id, db_data, _updated_at = row + try: + db_json = ( + json.loads(db_data) if isinstance(db_data, str) else dict(db_data) + ) + except Exception: + db_json = {} + + merged_dict: dict[str, Any] = dict(env_resolved.dict()) + merged_dict.update( + {k: v for k, v in db_json.items() if v not in (None, "")} + ) + + # Ensure primary_mint is consistent with cashu_mints if not explicitly set + if not merged_dict.get("primary_mint"): + merged_dict["primary_mint"] = _compute_primary_mint( + merged_dict.get("cashu_mints", []) + ) + + if any(k not in db_json for k in merged_dict.keys()): + await db_session.exec( # type: ignore + text( + "UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1" + ).bindparams( + data=json.dumps(merged_dict), + updated_at=datetime.now(timezone.utc), + ) + ) + await db_session.commit() + + # Update the existing instance in-place for all live importers + for k, v in merged_dict.items(): + setattr(settings, k, v) + cls._current = settings + return cls._current + + @classmethod + async def update( + cls, partial: dict[str, Any], db_session: AsyncSession + ) -> Settings: + async with cls._lock: + current = cls.get() + candidate_dict = {**current.dict(), **partial} + candidate = Settings(**candidate_dict) + from sqlmodel import text + + # Ensure primary_mint reflects candidate mints if missing + if not candidate.primary_mint: + candidate.primary_mint = _compute_primary_mint(candidate.cashu_mints) + + await db_session.exec( # type: ignore + text( + "UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1" + ).bindparams( + data=json.dumps(candidate.dict()), + updated_at=datetime.now(timezone.utc), + ) + ) + await db_session.commit() + # Update in-place + for k, v in candidate.dict().items(): + setattr(settings, k, v) + cls._current = settings + return settings + + @classmethod + async def reload_from_db(cls, db_session: AsyncSession) -> Settings: + async with cls._lock: + from sqlmodel import text + + row = await db_session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore + row = row.first() + if row is None: + raise RuntimeError("Settings row missing") + (data_str,) = row + data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str) + # Update in-place + for k, v in data.items(): + setattr(settings, k, v) + cls._current = settings + return settings diff --git a/routstr/discovery.py b/routstr/discovery.py index 798c1756..a2680145 100644 --- a/routstr/discovery.py +++ b/routstr/discovery.py @@ -1,6 +1,5 @@ import asyncio import json -import os import random import string from typing import Any @@ -10,6 +9,7 @@ import websockets from fastapi import APIRouter from .core.logging import get_logger +from .core.settings import settings logger = get_logger(__name__) @@ -196,15 +196,18 @@ async def get_cache() -> list[dict[str, Any]]: def _get_discovery_relays() -> list[str]: - relays_env = os.getenv("RELAYS") or "" - discovery_relays = [r.strip() for r in relays_env.split(",") if r.strip()] - if not discovery_relays: - discovery_relays = [ + try: + relays = settings.relays + except Exception: + relays = [] + if not relays: + relays = [ "wss://relay.nostr.band", "wss://relay.damus.io", "wss://relay.routstr.com", + "wss://nos.lol", ] - return discovery_relays + return relays async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]: @@ -297,10 +300,8 @@ async def providers_cache_refresher( ) -> None: if interval_seconds is None: try: - interval_seconds = int( - os.getenv("PROVIDERS_REFRESH_INTERVAL_SECONDS", "300") - ) - except ValueError: + interval_seconds = settings.providers_refresh_interval_seconds + except Exception: interval_seconds = 300 await refresh_providers_cache(pubkey=pubkey) @@ -321,8 +322,10 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]: # Set up client arguments conditionally proxies = None if is_onion: - # Get Tor proxy URL from environment variable - tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050") + try: + tor_proxy = settings.tor_proxy_url + except Exception: + tor_proxy = "socks5://127.0.0.1:9050" proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment] async with httpx.AsyncClient( diff --git a/routstr/nip91.py b/routstr/nip91.py index a5d7f4cc..3a0a40d2 100644 --- a/routstr/nip91.py +++ b/routstr/nip91.py @@ -19,6 +19,7 @@ from nostr.message_type import ClientMessageType from nostr.relay_manager import RelayManager from .core import get_logger +from .core.settings import settings logger = get_logger(__name__) @@ -286,23 +287,32 @@ def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None: async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str: - explicit = os.getenv("PROVIDER_ID") or os.getenv("NIP91_PROVIDER_ID") + explicit = settings.provider_id if explicit: logger.info(f"Using configured provider_id from env: {explicit}") return explicit - latest_event: dict[str, Any] | None = None - latest_ts = -1 - for relay_url in relay_urls: + async def query_single_relay(relay_url: str) -> list[dict[str, Any]]: try: events, _ok = await query_nip91_events(relay_url, public_key_hex, None) - for ev in events: - ts = int(ev.get("created_at", 0)) - if ts > latest_ts: - latest_event = ev - latest_ts = ts + return events except Exception: - continue + return [] + + # Query all relays concurrently + all_events_lists = await asyncio.gather( + *[query_single_relay(relay_url) for relay_url in relay_urls] + ) + + latest_event: dict[str, Any] | None = None + latest_ts = -1 + + for events_list in all_events_lists: + for ev in events_list: + ts = int(ev.get("created_at", 0)) + if ts > latest_ts: + latest_event = ev + latest_ts = ts existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None if existing_d: @@ -352,7 +362,7 @@ async def announce_provider() -> None: Checks for existing announcements and creates new ones if needed. """ # Check for NSEC in environment (use NSEC only) - nsec = os.getenv("NSEC") + nsec = settings.nsec if not nsec: logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement") return @@ -366,38 +376,26 @@ async def announce_provider() -> None: private_key_hex, public_key_hex = keypair logger.info(f"Using Nostr pubkey: {public_key_hex}") - # Configure relays first (RELAYS only) - relay_urls_env = os.getenv("RELAYS") or "" - logger.debug(f"Configured relays: {relay_urls_env}") - relay_urls = [url.strip() for url in relay_urls_env.split(",") if url.strip()] - if not relay_urls: - relay_urls = [ - "wss://relay.nostr.band", - "wss://relay.damus.io", - "wss://nos.lol", - ] - - # Determine a stable provider_id - provider_id = await _determine_provider_id(public_key_hex, relay_urls) - logger.info(f"Using provider_id: {provider_id}") - - # Core settings only (no ROUTSTR_* vars) - base_url = os.getenv("HTTP_URL") - onion_url = os.getenv("ONION_URL") + # Resolve settings and determine if we can publish BEFORE touching relays + try: + base_url: str | None = settings.http_url + onion_url: str | None = settings.onion_url + provider_name = settings.name or "Routstr Proxy" + provider_about = settings.description or "Privacy-preserving AI proxy via Nostr" + cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()] + except Exception: + base_url = settings.http_url or None + onion_url = settings.onion_url or None + provider_name = settings.name or "Routstr Proxy" + provider_about = settings.description or "Privacy-preserving AI proxy via Nostr" + cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()] if not onion_url: discovered = discover_onion_url_from_tor() if discovered: onion_url = discovered logger.info(f"Discovered onion URL via Tor volume: {onion_url}") - provider_name = os.getenv("NAME", "Routstr Proxy") - provider_about = os.getenv("DESCRIPTION", "Privacy-preserving AI proxy via Nostr") - # Mint URLs optional: include all CASHU_MINTS entries if available - cashu_mints = [ - m.strip() for m in os.getenv("CASHU_MINTS", "").split(",") if m.strip() - ] mint_urls = cashu_mints if cashu_mints else None - # Build endpoint URLs (skip defaults like localhost) endpoint_urls: list[str] = [] if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000": endpoint_urls.append(base_url.strip()) @@ -415,6 +413,19 @@ async def announce_provider() -> None: ) return + # Only now configure relays and determine provider_id (may query relays) + relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()] + if not relay_urls: + relay_urls = [ + "wss://relay.nostr.band", + "wss://relay.damus.io", + "wss://relay.routstr.com", + "wss://nos.lol", + ] + + provider_id = await _determine_provider_id(public_key_hex, relay_urls) + logger.info(f"Using provider_id: {provider_id}") + # Build metadata metadata = { "name": provider_name, @@ -432,10 +443,10 @@ async def announce_provider() -> None: metadata=metadata, ) - # Backoff configuration and state - backoff_base = float(os.getenv("NIP91_BACKOFF_BASE_SECONDS", "5")) - backoff_max = float(os.getenv("NIP91_BACKOFF_MAX_SECONDS", "900")) - backoff_jitter_ratio = float(os.getenv("NIP91_BACKOFF_JITTER_RATIO", "0.2")) + # Backoff configuration and state (sensible defaults) + backoff_base = 5.0 + backoff_max = 900.0 + backoff_jitter_ratio = 0.2 relay_next_allowed: dict[str, float] = {} relay_current_delay: dict[str, float] = {} @@ -499,9 +510,7 @@ async def announce_provider() -> None: ) # Re-announce periodically (every 24 hours) - announcement_interval = int( - os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60)) - ) + announcement_interval = 24 * 60 * 60 while True: try: diff --git a/routstr/payment/cost_caculation.py b/routstr/payment/cost_caculation.py index 0cce2827..50b82e53 100644 --- a/routstr/payment/cost_caculation.py +++ b/routstr/payment/cost_caculation.py @@ -1,34 +1,16 @@ +import json import math -import os from pydantic.v1 import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession from ..core import get_logger -from .models import MODELS +from ..core.db import ModelRow +from ..core.settings import settings logger = get_logger(__name__) -COST_PER_REQUEST = ( - int(os.environ.get("COST_PER_REQUEST", "1")) * 1000 -) # Convert to msats -COST_PER_1K_INPUT_TOKENS = ( - int(os.environ.get("COST_PER_1K_INPUT_TOKENS", "0")) * 1000 -) # Convert to msats -COST_PER_1K_OUTPUT_TOKENS = ( - int(os.environ.get("COST_PER_1K_OUTPUT_TOKENS", "0")) * 1000 -) # Convert to msats -MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true" - -logger.info( - "Cost calculation initialized", - extra={ - "cost_per_request_msats": COST_PER_REQUEST, - "cost_per_1k_input_tokens_msats": COST_PER_1K_INPUT_TOKENS, - "cost_per_1k_output_tokens_msats": COST_PER_1K_OUTPUT_TOKENS, - "model_based_pricing": MODEL_BASED_PRICING, - }, -) - class CostData(BaseModel): base_msats: int @@ -46,8 +28,8 @@ class CostDataError(BaseModel): code: str -def calculate_cost( - response_data: dict, max_cost: int +async def calculate_cost( + response_data: dict, max_cost: int, session: AsyncSession | None = None ) -> CostData | MaxCostData | CostDataError: """ Calculate the cost of an API request based on token usage. @@ -85,44 +67,53 @@ def calculate_cost( ) return cost_data - MSATS_PER_1K_INPUT_TOKENS = COST_PER_1K_INPUT_TOKENS - MSATS_PER_1K_OUTPUT_TOKENS = COST_PER_1K_OUTPUT_TOKENS + MSATS_PER_1K_INPUT_TOKENS: float = ( + float(settings.fixed_per_1k_input_tokens) * 1000.0 + ) + MSATS_PER_1K_OUTPUT_TOKENS: float = ( + float(settings.fixed_per_1k_output_tokens) * 1000.0 + ) - if MODEL_BASED_PRICING and MODELS: + if not settings.fixed_pricing and session is not None: response_model = response_data.get("model", "") logger.debug( "Using model-based pricing", - extra={ - "model": response_model, - "available_models": [model.id for model in MODELS], - }, + extra={"model": response_model}, ) - if response_model not in [model.id for model in MODELS]: + result = await session.exec(select(ModelRow.id)) # type: ignore + available_ids = [ + row[0] if isinstance(row, tuple) else row for row in result.all() + ] + if response_model not in available_ids: logger.error( "Invalid model in response", - extra={ - "response_model": response_model, - "available_models": [model.id for model in MODELS], - }, + extra={"response_model": response_model}, ) return CostDataError( message=f"Invalid model in response: {response_model}", code="model_not_found", ) - model = next(model for model in MODELS if model.id == response_model) - if model.sats_pricing is None: + row = await session.get(ModelRow, response_model) + if row is None or not row.sats_pricing: logger.error( "Model pricing not defined", - extra={"model": response_model, "model_id": model.id}, + extra={"model": response_model, "model_id": response_model}, ) return CostDataError( message="Model pricing not defined", code="pricing_not_found" ) - MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000 # type: ignore - MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000 # type: ignore + try: + sats_pricing = json.loads(row.sats_pricing) + mspp = float(sats_pricing.get("prompt", 0)) + mspc = float(sats_pricing.get("completion", 0)) + except Exception: + return CostDataError(message="Invalid pricing data", code="pricing_invalid") + + MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0 + MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0 logger.info( "Applied model-specific pricing", diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 0ec24636..892aec59 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -1,26 +1,21 @@ import json -import os +import math from typing import Mapping from fastapi import HTTPException, Response from fastapi.requests import Request +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession from ..core import get_logger +from ..core.db import ModelRow +from ..core.settings import settings from ..wallet import deserialize_token_from_string -from .cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING -from .models import MODELS +from .models import Pricing logger = get_logger(__name__) -UPSTREAM_BASE_URL = os.environ.get("UPSTREAM_BASE_URL", "") -UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "") -CHAT_COMPLETIONS_API_VERSION = os.environ.get("CHAT_COMPLETIONS_API_VERSION", "") - -if not UPSTREAM_BASE_URL: - raise ValueError("Please set the UPSTREAM_BASE_URL environment variable") - - def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None: if x_cashu := headers.get("x-cashu", None): cashu_token = x_cashu @@ -89,49 +84,145 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N ) -def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int: +async def get_max_cost_for_model( + model: str, session: AsyncSession | None = None +) -> int: """Get the maximum cost for a specific model.""" logger.debug( "Getting max cost for model", extra={ "model": model, - "model_based_pricing": MODEL_BASED_PRICING, - "has_models": bool(MODELS), + "fixed_pricing": settings.fixed_pricing, + "has_models": True, }, ) - if not MODEL_BASED_PRICING or not MODELS: + # Fixed pricing: always use fixed_cost_per_request + if settings.fixed_pricing: + default_cost_msats = settings.fixed_cost_per_request * 1000 logger.debug( - "Using default cost (no model-based pricing)", - extra={"cost_msats": COST_PER_REQUEST, "model": model}, + "Using fixed cost pricing", + extra={"cost_msats": default_cost_msats, "model": model}, ) - return COST_PER_REQUEST + return max(settings.min_request_msat, default_cost_msats) - if model not in [model.id for model in MODELS]: + if session is None: + # Without a DB session, we can't resolve model pricing; fall back to fixed cost + fallback_msats = settings.fixed_cost_per_request * 1000 + logger.warning( + "No DB session provided for model pricing; using fixed cost", + extra={"requested_model": model, "using_default_cost": fallback_msats}, + ) + return max(settings.min_request_msat, fallback_msats) + + result = await session.exec(select(ModelRow.id)) # type: ignore + available_ids = [row[0] if isinstance(row, tuple) else row for row in result.all()] + if model not in available_ids: + # If no models or unknown model, fall back to fixed cost if provided, else minimal default + fallback_msats = settings.fixed_cost_per_request * 1000 logger.warning( "Model not found in available models", extra={ "requested_model": model, - "available_models": [m.id for m in MODELS], - "using_default_cost": COST_PER_REQUEST, + "available_models": available_ids, + "using_default_cost": fallback_msats, }, ) - return COST_PER_REQUEST + return max(settings.min_request_msat, fallback_msats) - for m in MODELS: - if m.id == model: - max_cost = m.sats_pricing.max_cost * 1000 * (1 - tolerance_percentage / 100) # type: ignore + row = await session.get(ModelRow, model) + if row and row.sats_pricing: + try: + sats = Pricing(**json.loads(row.sats_pricing)) # type: ignore + max_cost = sats.max_cost * 1000 * (1 - settings.tolerance_percentage / 100) logger.debug( "Found model-specific max cost", extra={"model": model, "max_cost_msats": max_cost}, ) - return int(max_cost) + calculated_msats = int(max_cost) + return max(settings.min_request_msat, calculated_msats) + except Exception: + pass logger.warning( - "Model pricing not found, using default", - extra={"model": model, "default_cost_msats": COST_PER_REQUEST}, + "Model pricing not found, using fixed cost", + extra={ + "model": model, + "default_cost_msats": settings.fixed_cost_per_request * 1000, + }, ) - return COST_PER_REQUEST + return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000) + + +async def calculate_discounted_max_cost( + max_cost_for_model: int, body: dict, session: AsyncSession | None = None +) -> int: + """Calculate the discounted max cost for a request using model pricing when available.""" + if settings.fixed_pricing or session is None: + return max_cost_for_model + + model = body.get("model", "unknown") + model_pricing = await get_model_cost_info(model, session=session) + if not model_pricing: + return max_cost_for_model + + tol = settings.tolerance_percentage + tol_factor = max(0.0, 1 - float(tol) / 100.0) + max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor + max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor + + adjusted = max_cost_for_model + + if messages := body.get("messages"): + prompt_tokens = estimate_tokens(messages) + estimated_prompt_delta_sats = ( + max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt + ) + if estimated_prompt_delta_sats >= 0: + adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000) + else: + adjusted = adjusted + math.ceil(-estimated_prompt_delta_sats * 1000) + + if max_tokens := body.get("max_tokens"): + estimated_completion_delta_sats = ( + max_completion_allowed_sats - max_tokens * model_pricing.completion + ) + if estimated_completion_delta_sats >= 0: + adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000) + else: + adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000) + + logger.debug( + "Discounted max cost computed", + extra={ + "model": model, + "original_msats": max_cost_for_model, + "adjusted_msats": adjusted, + "tolerance_pct": tol, + }, + ) + + return max(0, adjusted) + + +def estimate_tokens(messages: list) -> int: + return len(str(messages)) // 3 + + +async def get_model_cost_info( + model_id: str, session: AsyncSession | None = None +) -> Pricing | None: + if not model_id or model_id == "unknown": + return None + if session is None: + return None + row = await session.get(ModelRow, model_id) + if row and row.sats_pricing: + try: + return Pricing(**json.loads(row.sats_pricing)) # type: ignore + except Exception: + return None + return None def create_error_response( @@ -161,11 +252,12 @@ def create_error_response( def prepare_upstream_headers(request_headers: dict) -> dict: """Prepare headers for upstream request, removing sensitive/problematic ones.""" + upstream_api_key = settings.upstream_api_key logger.debug( "Preparing upstream headers", extra={ "original_headers_count": len(request_headers), - "has_upstream_api_key": bool(UPSTREAM_API_KEY), + "has_upstream_api_key": bool(upstream_api_key), }, ) @@ -184,8 +276,8 @@ def prepare_upstream_headers(request_headers: dict) -> dict: removed_headers.append(header) # Handle authorization - if UPSTREAM_API_KEY: - headers["Authorization"] = f"Bearer {UPSTREAM_API_KEY}" + if upstream_api_key: + headers["Authorization"] = f"Bearer {upstream_api_key}" if headers.pop("authorization", None) is not None: removed_headers.append("authorization (replaced with upstream key)") else: @@ -198,7 +290,7 @@ def prepare_upstream_headers(request_headers: dict) -> dict: extra={ "final_headers_count": len(headers), "removed_headers": removed_headers, - "added_upstream_auth": bool(UPSTREAM_API_KEY), + "added_upstream_auth": bool(upstream_api_key), }, ) @@ -210,6 +302,7 @@ def prepare_upstream_params( ) -> dict[str, str]: """Prepare query params for upstream request, optionally adding api-version for chat/completions.""" params: dict[str, str] = dict(query_params or {}) - if path.endswith("chat/completions") and CHAT_COMPLETIONS_API_VERSION: - params["api-version"] = CHAT_COMPLETIONS_API_VERSION + chat_api_version = settings.chat_completions_api_version + if path.endswith("chat/completions") and chat_api_version: + params["api-version"] = chat_api_version return params diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 5849a489..f21da6bf 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -1,13 +1,17 @@ import asyncio import json -import os +import random from pathlib import Path from urllib.request import urlopen -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic.v1 import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from ..core.db import ModelRow, create_session, get_session from ..core.logging import get_logger +from ..core.settings import settings from .price import sats_usd_ask_price logger = get_logger(__name__) @@ -30,6 +34,8 @@ class Pricing(BaseModel): image: float web_search: float internal_reasoning: float + max_prompt_cost: float = 0.0 # in sats not msats + max_completion_cost: float = 0.0 # in sats not msats max_cost: float = 0.0 # in sats not msats @@ -52,12 +58,9 @@ class Model(BaseModel): top_provider: TopProvider | None = None -MODELS: list[Model] = [] - - def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: """Fetches model information from OpenRouter API.""" - base_url = os.getenv("BASE_URL", "https://openrouter.ai/api/v1") + base_url = "https://openrouter.ai/api/v1" try: with urlopen(f"{base_url}/models") as response: @@ -100,7 +103,10 @@ def load_models() -> list[Model]: and no user file is provided, it will be used as a fallback. """ - models_path = Path(os.environ.get("MODELS_PATH", "models.json")) + try: + models_path = Path(settings.models_path) + except Exception: + models_path = Path("models.json") # Check if user has actively provided a models.json file if models_path.exists(): @@ -108,14 +114,17 @@ def load_models() -> list[Model]: try: with models_path.open("r") as f: data = json.load(f) - return [Model(**model) for model in data.get("models", [])] + return [Model(**model) for model in data.get("models", [])] # type: ignore except Exception as e: logger.error(f"Error loading models from {models_path}: {e}") # Fall through to auto-generation # Auto-generate models from OpenRouter API logger.info("Auto-generating models from OpenRouter API") - source_filter = os.getenv("SOURCE") + try: + source_filter = settings.source or None + except Exception: + source_filter = None source_filter = source_filter if source_filter and source_filter.strip() else None models_data = fetch_openrouter_models(source_filter=source_filter) @@ -124,58 +133,319 @@ def load_models() -> list[Model]: return [] logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API") - return [Model(**model) for model in models_data] + return [Model(**model) for model in models_data] # type: ignore -MODELS = load_models() +def _row_to_model(row: ModelRow) -> Model: + architecture = json.loads(row.architecture) + pricing = json.loads(row.pricing) + sats_pricing = json.loads(row.sats_pricing) if row.sats_pricing else None + per_request_limits = ( + json.loads(row.per_request_limits) if row.per_request_limits else None + ) + top_provider = json.loads(row.top_provider) if row.top_provider else None + + # Enforce minimum per-request fee on free/zero-priced models in API output + try: + if isinstance(pricing, dict): + if float(pricing.get("request", 0.0)) <= 0.0: + pricing["request"] = max(pricing.get("request", 0.0), 0.0) + if isinstance(sats_pricing, dict): + if float(sats_pricing.get("request", 0.0)) <= 0.0: + # Convert min_request_msat to sats for sats_pricing fields that are in sats + sats_min = max(1, int(settings.min_request_msat)) / 1000.0 + sats_pricing["request"] = max( + sats_pricing.get("request", 0.0), sats_min + ) + except Exception: + pass + + return Model( + id=row.id, + name=row.name, + created=row.created, + description=row.description, + context_length=row.context_length, + architecture=Architecture.parse_obj(architecture), + pricing=Pricing.parse_obj(pricing), + sats_pricing=Pricing.parse_obj(sats_pricing) if sats_pricing else None, + per_request_limits=per_request_limits, + top_provider=TopProvider.parse_obj(top_provider) if top_provider else None, + ) + + +def _model_to_row_payload(model: Model) -> dict[str, str | int | None]: + return { + "id": model.id, + "name": model.name, + "created": model.created, + "description": model.description, + "context_length": model.context_length, + "architecture": json.dumps(model.architecture.dict()), + "pricing": json.dumps(model.pricing.dict()), + "sats_pricing": json.dumps(model.sats_pricing.dict()) + if model.sats_pricing + else None, + "per_request_limits": json.dumps(model.per_request_limits) + if model.per_request_limits is not None + else None, + "top_provider": json.dumps(model.top_provider.dict()) + if model.top_provider is not None + else None, + } + + +async def list_models(session: AsyncSession | None = None) -> list[Model]: + if session is not None: + result = await session.exec(select(ModelRow)) # type: ignore + rows = result.all() + return [_row_to_model(r) for r in rows] + async with create_session() as s: + result = await s.exec(select(ModelRow)) # type: ignore + rows = result.all() + return [_row_to_model(r) for r in rows] + + +async def get_model_by_id( + model_id: str, session: AsyncSession | None = None +) -> Model | None: + if session is not None: + row = await session.get(ModelRow, model_id) + return _row_to_model(row) if row else None + async with create_session() as s: + row = await s.get(ModelRow, model_id) + return _row_to_model(row) if row else None + + +async def ensure_models_bootstrapped() -> None: + async with create_session() as s: + existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore + if existing: + return + + try: + models_path = Path(settings.models_path) + except Exception: + models_path = Path("models.json") + + models_to_insert: list[dict] = [] + if models_path.exists(): + try: + with models_path.open("r") as f: + data = json.load(f) + models_to_insert = data.get("models", []) + logger.info( + f"Bootstrapping {len(models_to_insert)} models from {models_path}" + ) + except Exception as e: + logger.error(f"Error loading models from {models_path}: {e}") + + if not models_to_insert: + logger.info("Bootstrapping models from OpenRouter API") + source_filter = None + try: + src = settings.source or None + source_filter = src if src and src.strip() else None + except Exception: + pass + models_to_insert = fetch_openrouter_models(source_filter=source_filter) + + for m in models_to_insert: + try: + model = Model(**m) # type: ignore + except Exception: + # Some OpenRouter models include extra fields; only map required ones + continue + exists = await s.get(ModelRow, model.id) + if exists: + continue + payload = _model_to_row_payload(model) + s.add(ModelRow(**payload)) # type: ignore + await s.commit() async def update_sats_pricing() -> None: while True: try: + try: + if not settings.enable_pricing_refresh: + return + except Exception: + pass sats_to_usd = await sats_usd_ask_price() - for model in MODELS: - model.sats_pricing = Pricing( - **{k: v / sats_to_usd for k, v in model.pricing.dict().items()} - ) - 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 - ): - 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_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 - 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 + async with create_session() as s: + result = await s.exec(select(ModelRow)) # type: ignore + rows = result.all() + changed = 0 + for row in rows: + try: + pricing = Pricing.parse_obj(json.loads(row.pricing)) + top_provider = ( + TopProvider.parse_obj(json.loads(row.top_provider)) + if row.top_provider + else None + ) + sats = Pricing.parse_obj( + {k: v / sats_to_usd for k, v in pricing.dict().items()} + ) + # Enforce minimum per-request charge floor in sats + try: + min_req_msat = max( + 1, int(getattr(settings, "min_request_msat", 1)) + ) + except Exception: + min_req_msat = 1 + min_req_sats = float(min_req_msat) / 1000.0 + if sats.request <= 0.0: + sats.request = min_req_sats + mspp = sats.prompt + mspc = sats.completion + if top_provider and ( + top_provider.context_length + or top_provider.max_completion_tokens + ): + if (cl := top_provider.context_length) and ( + mct := top_provider.max_completion_tokens + ): + max_prompt_cost = (cl - mct) * mspp + max_completion_cost = mct * mspc + sats.max_prompt_cost = max_prompt_cost + sats.max_completion_cost = max_completion_cost + sats.max_cost = max_prompt_cost + max_completion_cost + elif cl := top_provider.context_length: + max_prompt_cost = cl * 0.8 * mspp + max_completion_cost = cl * 0.2 * mspc + sats.max_prompt_cost = max_prompt_cost + sats.max_completion_cost = max_completion_cost + sats.max_cost = max_prompt_cost + max_completion_cost + elif mct := top_provider.max_completion_tokens: + max_prompt_cost = mct * 4 * mspp + max_completion_cost = mct * mspc + sats.max_prompt_cost = max_prompt_cost + sats.max_completion_cost = max_completion_cost + sats.max_cost = max_prompt_cost + max_completion_cost + else: + max_prompt_cost = 1_000_000 * mspp + max_completion_cost = 32_000 * mspc + sats.max_prompt_cost = max_prompt_cost + sats.max_completion_cost = max_completion_cost + sats.max_cost = max_prompt_cost + max_completion_cost + elif row.context_length: + max_prompt_cost = mspp * row.context_length * 0.8 + max_completion_cost = mspc * row.context_length * 0.2 + sats.max_prompt_cost = max_prompt_cost + sats.max_completion_cost = max_completion_cost + sats.max_cost = max_prompt_cost + max_completion_cost + else: + p = mspp * 1_000_000 + c = mspc * 32_000 + r = sats.request * 100_000 + i = sats.image * 100 + w = sats.web_search * 1000 + ir = sats.internal_reasoning * 100 + sats.max_prompt_cost = p + sats.max_completion_cost = c + sats.max_cost = p + c + r + i + w + ir + + # Ensure overall minimum per-request total cost floor + if (sats.max_cost or 0.0) < min_req_sats: + sats.max_cost = min_req_sats + + new_json = json.dumps(sats.dict()) + if row.sats_pricing != new_json: + row.sats_pricing = new_json + s.add(row) + changed += 1 + except Exception as per_row_error: + logger.error( + "Failed to update pricing for model", + extra={ + "model_id": row.id, + "error": str(per_row_error), + "error_type": type(per_row_error).__name__, + }, + ) + if changed: + await s.commit() except asyncio.CancelledError: break except Exception as e: logger.error(f"Error updating sats pricing: {e}") try: - await asyncio.sleep(10) + interval = getattr(settings, "pricing_refresh_interval_seconds", 120) + jitter = max(0.0, float(interval) * 0.1) + await asyncio.sleep(interval + random.uniform(0, jitter)) + except asyncio.CancelledError: + break + + +async def refresh_models_periodically() -> None: + """Background task: periodically fetch OpenRouter models and insert new ones. + + - Respects optional SOURCE filter from settings + - Does not overwrite existing rows + - Sleeps according to settings.models_refresh_interval_seconds; disabled when 0 + """ + interval = getattr(settings, "models_refresh_interval_seconds", 0) + if not interval or interval <= 0: + return + + while True: + try: + try: + if not settings.enable_models_refresh: + return + except Exception: + pass + try: + src = settings.source or None + source_filter = src if src and src.strip() else None + except Exception: + source_filter = None + + models = fetch_openrouter_models(source_filter=source_filter) + if not models: + await asyncio.sleep(interval) + continue + + async with create_session() as s: + result = await s.exec(select(ModelRow.id)) # type: ignore + existing_ids = { + row[0] if isinstance(row, tuple) else row for row in result.all() + } + inserted = 0 + for m in models: + try: + model = Model(**m) # type: ignore + except Exception: + continue + if model.id in existing_ids: + continue + payload = _model_to_row_payload(model) + try: + s.add(ModelRow(**payload)) # type: ignore + except Exception: + pass + inserted += 1 + if inserted: + await s.commit() + logger.info(f"Inserted {inserted} new models from OpenRouter") + except asyncio.CancelledError: + break + except Exception as e: + logger.error( + "Error during models refresh", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + try: + jitter = max(0.0, float(interval) * 0.1) + await asyncio.sleep(interval + random.uniform(0, jitter)) except asyncio.CancelledError: break @models_router.get("/v1/models") @models_router.get("/models", include_in_schema=False) -async def models() -> dict: - return {"data": MODELS} +async def models(session: AsyncSession = Depends(get_session)) -> dict: + items = await list_models(session) + return {"data": items} diff --git a/routstr/payment/price.py b/routstr/payment/price.py index 82ff2aec..850e7ffe 100644 --- a/routstr/payment/price.py +++ b/routstr/payment/price.py @@ -1,17 +1,15 @@ import asyncio -import os import httpx from ..core import get_logger +from ..core.settings import settings logger = get_logger(__name__) -# artifical spread to cover conversion fees -EXCHANGE_FEE = float(os.environ.get("EXCHANGE_FEE", "1.005")) # 0.5% default -UPSTREAM_PROVIDER_FEE = float( - os.environ.get("UPSTREAM_PROVIDER_FEE", "1.05") -) # 5% default (e.g. openrouter charges 5% margin) + +def _fees() -> tuple[float, float]: + return settings.exchange_fee, settings.upstream_provider_fee async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None: @@ -95,7 +93,8 @@ async def btc_usd_ask_price() -> float: raise ValueError("Unable to fetch BTC price from any exchange") min_price = min(valid_prices) - final_price = min_price / (EXCHANGE_FEE * UPSTREAM_PROVIDER_FEE) + exchange_fee, provider_fee = _fees() + final_price = min_price / (exchange_fee * provider_fee) return final_price except Exception as e: diff --git a/routstr/payment/x_cashu.py b/routstr/payment/x_cashu.py index 7d2a7c57..f671fbd8 100644 --- a/routstr/payment/x_cashu.py +++ b/routstr/payment/x_cashu.py @@ -7,10 +7,11 @@ from fastapi import BackgroundTasks, HTTPException, Request from fastapi.responses import Response, StreamingResponse from ..core import get_logger +from ..core.db import create_session +from ..core.settings import settings from ..wallet import recieve_token, send_token from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost from .helpers import ( - UPSTREAM_BASE_URL, create_error_response, prepare_upstream_headers, prepare_upstream_params, @@ -109,7 +110,7 @@ async def forward_to_upstream( if path.startswith("v1/"): path = path.replace("v1/", "") - url = f"{UPSTREAM_BASE_URL}/{path}" + url = f"{settings.upstream_base_url}/{path}" logger.debug( "Forwarding request to upstream", @@ -553,43 +554,45 @@ async def get_cost( extra={"model": model, "has_usage": "usage" in response_data}, ) - match calculate_cost(response_data, max_cost_for_model): - case MaxCostData() as cost: - logger.debug( - "Using max cost pricing", - extra={"model": model, "max_cost_msats": cost.total_msats}, - ) - return cost - case CostData() as cost: - logger.debug( - "Using token-based pricing", - extra={ - "model": model, - "total_cost_msats": cost.total_msats, - "input_msats": cost.input_msats, - "output_msats": cost.output_msats, - }, - ) - return cost - case CostDataError() as error: - logger.error( - "Cost calculation error", - extra={ - "model": model, - "error_message": error.message, - "error_code": error.code, - }, - ) - raise HTTPException( - status_code=400, - detail={ - "error": { - "message": error.message, - "type": "invalid_request_error", - "code": error.code, - } - }, - ) + async with create_session() as session: + match await calculate_cost(response_data, max_cost_for_model, session): + case MaxCostData() as cost: + logger.debug( + "Using max cost pricing", + extra={"model": model, "max_cost_msats": cost.total_msats}, + ) + return cost + case CostData() as cost: + logger.debug( + "Using token-based pricing", + extra={ + "model": model, + "total_cost_msats": cost.total_msats, + "input_msats": cost.input_msats, + "output_msats": cost.output_msats, + }, + ) + return cost + case CostDataError() as error: + logger.error( + "Cost calculation error", + extra={ + "model": model, + "error_message": error.message, + "error_code": error.code, + }, + ) + raise HTTPException( + status_code=400, + detail={ + "error": { + "message": error.message, + "type": "invalid_request_error", + "code": error.code, + } + }, + ) + return None async def send_refund(amount: int, unit: str, mint: str | None = None) -> str: diff --git a/routstr/proxy.py b/routstr/proxy.py index 715cfd0b..aebf80a1 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -15,8 +15,9 @@ from .auth import ( ) from .core import get_logger from .core.db import ApiKey, AsyncSession, create_session, get_session +from .core.settings import settings from .payment.helpers import ( - UPSTREAM_BASE_URL, + calculate_discounted_max_cost, check_token_balance, create_error_response, get_max_cost_for_model, @@ -307,7 +308,7 @@ async def forward_to_upstream( if path.startswith("v1/"): path = path.replace("v1/", "") - url = f"{UPSTREAM_BASE_URL}/{path}" + url = f"{settings.upstream_base_url}/{path}" logger.info( "Forwarding request to upstream", @@ -554,7 +555,10 @@ async def proxy( ) model = request_body_dict.get("model", "unknown") - max_cost_for_model = get_max_cost_for_model(model=model) + _max_cost_for_model = await get_max_cost_for_model(model=model, session=session) + max_cost_for_model = await calculate_discounted_max_cost( + _max_cost_for_model, request_body_dict, session + ) check_token_balance(headers, request_body_dict, max_cost_for_model) # Handle authentication @@ -756,7 +760,7 @@ async def forward_get_to_upstream( if path.startswith("v1/"): path = path.replace("v1/", "") - url = f"{UPSTREAM_BASE_URL}/{path}" + url = f"{settings.upstream_base_url}/{path}" logger.info( "Forwarding GET request to upstream", diff --git a/routstr/wallet.py b/routstr/wallet.py index aa427437..c00b5b82 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,6 +1,5 @@ import asyncio import math -import os from typing import TypedDict from cashu.core.base import Proof, Token @@ -8,19 +7,14 @@ from cashu.wallet.helpers import deserialize_token_from_string from cashu.wallet.wallet import Wallet from .core import db, get_logger +from .core.settings import settings from .payment.lnurl import raw_send_to_lnurl logger = get_logger(__name__) -CASHU_MINTS = os.environ.get("CASHU_MINTS", "https://mint.minibits.cash/Bitcoin") -TRUSTED_MINTS = CASHU_MINTS.split(",") -PRIMARY_MINT_URL = TRUSTED_MINTS[0] -RECEIVE_LN_ADDRESS = os.environ.get("RECEIVE_LN_ADDRESS", "") - - async def get_balance(unit: str) -> int: - wallet = await get_wallet(PRIMARY_MINT_URL, unit) + wallet = await get_wallet(settings.primary_mint, unit) return wallet.available_balance.amount @@ -34,7 +28,7 @@ async def recieve_token( wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False) wallet.keyset_id = token_obj.keysets[0] - if token_obj.mint not in TRUSTED_MINTS: + if token_obj.mint not in settings.cashu_mints: return await swap_to_primary_mint(token_obj, wallet) wallet.verify_proofs_dleq(token_obj.proofs) @@ -44,8 +38,10 @@ async def recieve_token( async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]: """Internal send function - returns amount and serialized token""" - wallet: Wallet = await get_wallet(mint_url or PRIMARY_MINT_URL, unit) - proofs = get_proofs_per_mint_and_unit(wallet, mint_url or PRIMARY_MINT_URL, unit) + wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit) + proofs = get_proofs_per_mint_and_unit( + wallet, mint_url or settings.primary_mint, unit + ) send_proofs, _ = await wallet.select_to_send( proofs, amount, set_reserved=True, include_fees=False @@ -86,7 +82,7 @@ async def swap_to_primary_mint( raise ValueError("Invalid unit") estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000 - primary_wallet = await get_wallet(PRIMARY_MINT_URL, "sat") + primary_wallet = await get_wallet(settings.primary_mint, "sat") minted_amount = int(amount_msat_after_fee // 1000) mint_quote = await primary_wallet.request_mint(minted_amount) @@ -100,7 +96,7 @@ async def swap_to_primary_mint( ) _ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote) - return int(minted_amount), "sat", PRIMARY_MINT_URL + return int(minted_amount), "sat", settings.primary_mint async def credit_balance( @@ -259,7 +255,7 @@ async def fetch_all_balances( async with db.create_session() as session: tasks = [ fetch_balance(session, mint_url, unit) - for mint_url in TRUSTED_MINTS + for mint_url in settings.cashu_mints for unit in units ] @@ -299,14 +295,14 @@ async def fetch_all_balances( async def periodic_payout() -> None: - if not RECEIVE_LN_ADDRESS: + if not settings.receive_ln_address: logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout") return while True: await asyncio.sleep(60 * 5) try: async with db.create_session() as session: - for mint_url in TRUSTED_MINTS: + for mint_url in settings.cashu_mints: for unit in ["sat", "msat"]: wallet = await get_wallet(mint_url, unit) proofs = get_proofs_per_mint_and_unit( @@ -323,7 +319,7 @@ async def periodic_payout() -> None: min_amount = 210 if unit == "sat" else 210000 if available_balance > min_amount: amount_received = await raw_send_to_lnurl( - wallet, proofs, RECEIVE_LN_ADDRESS, unit + wallet, proofs, settings.receive_ln_address, unit ) logger.info( "Payout sent successfully", diff --git a/scripts/crontab.example b/scripts/crontab.example index a24c839a..10ee202d 100644 --- a/scripts/crontab.example +++ b/scripts/crontab.example @@ -1,7 +1,5 @@ -REPO_DIR=/home/user/proxy -LOG_FILE=/home/user/proxy/update.log -* * * * * /home/user/proxy/scripts/auto_update.sh >/dev/null 2>&1 +# Example crontab entries for Routstr tasks -OUTPUT_FILE=/home/user/proxy/models.json -BASE_URL=https://openrouter.ai/api/v1 -0 * * * * python3 /home/user/proxy/scripts/models_meta.py >/dev/null 2>&1 +# Update models.json daily at 03:15 (optional) +# OUTPUT_FILE=/app/models.json SOURCE=openrouter +15 3 * * * /usr/local/bin/python /app/scripts/models_meta.py >> /var/log/cron.log 2>&1 diff --git a/scripts/models_meta.py b/scripts/models_meta.py index d95b5ddd..718bf438 100755 --- a/scripts/models_meta.py +++ b/scripts/models_meta.py @@ -42,13 +42,13 @@ 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(source_filter: str | None = None) -> list[Model]: """Fetches model information from OpenRouter API.""" - with urlopen(f"{BASE_URL}/models") as response: + base_url = "https://openrouter.ai/api/v1" + with urlopen(f"{base_url}/models") as response: data = json.loads(response.read().decode("utf-8")) models_data: list[Model] = [] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c3727a72..311214f6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -33,8 +33,8 @@ if use_local_services: "RECEIVE_LN_ADDRESS": "test@routstr.com", "REFUND_PROCESSING_INTERVAL": "3600", "NSEC": "nsec1testkey1234567890abcdef", - "COST_PER_REQUEST": "10", - "MODEL_BASED_PRICING": "true", + "FIXED_COST_PER_REQUEST": "10", + "FIXED_PRICING": "false", "MINIMUM_PAYOUT": "1000", "PAYOUT_INTERVAL": "86400", "NAME": "TestRoutstrNode", @@ -55,8 +55,8 @@ else: "RECEIVE_LN_ADDRESS": "test@routstr.com", "REFUND_PROCESSING_INTERVAL": "3600", "NSEC": "nsec1testkey1234567890abcdef", - "COST_PER_REQUEST": "10", - "MODEL_BASED_PRICING": "true", + "FIXED_COST_PER_REQUEST": "10", + "FIXED_PRICING": "false", "MINIMUM_PAYOUT": "1000", "PAYOUT_INTERVAL": "86400", } @@ -507,10 +507,15 @@ async def integration_app( else: # Use testmint with wallet patches for all integration tests mint_url = os.environ.get("CASHU_MINTS", "http://localhost:3338") + from routstr.core.settings import settings as _settings + + # Passthrough discounted max cost to avoid dependence on MODELS in tests + def _passthrough_discount(max_cost_for_model: int, body: dict) -> int: + return max_cost_for_model + with ( patch("routstr.core.db.engine", integration_engine), - patch("routstr.wallet.TRUSTED_MINTS", [mint_url]), - patch("routstr.wallet.PRIMARY_MINT_URL", mint_url), + patch.object(_settings, "cashu_mints", [mint_url]), patch("routstr.auth.credit_balance", testmint_wallet.credit_balance), patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance), patch("routstr.balance.credit_balance", testmint_wallet.credit_balance), @@ -521,6 +526,10 @@ async def integration_app( patch("websockets.connect") as mock_websockets, patch("routstr.payment.price.btc_usd_ask_price", return_value=50000.0), patch("routstr.payment.price.sats_usd_ask_price", return_value=0.0005), + patch( + "routstr.payment.helpers.calculate_discounted_max_cost", + side_effect=_passthrough_discount, + ), ): # Configure the WebSocket mock for discovery service - fast failure for performance tests async def mock_websocket_connect(*args: Any, **kwargs: Any) -> None: diff --git a/tests/integration/test_background_tasks.py b/tests/integration/test_background_tasks.py index de387286..a9736994 100644 --- a/tests/integration/test_background_tasks.py +++ b/tests/integration/test_background_tasks.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, patch import pytest from routstr.core.db import ApiKey -from routstr.payment.models import MODELS, Model, Pricing, update_sats_pricing +from routstr.payment.models import Model, Pricing, update_sats_pricing from routstr.wallet import periodic_payout @@ -57,51 +57,49 @@ class TestPricingUpdateTask: }, ) - # Add test model to MODELS list - original_models = MODELS.copy() - MODELS.clear() - MODELS.append(test_model) + # Compute sats pricing once using the same logic as the background task + # Run the pricing update logic once directly + sats_to_usd = mock_sats_usd + _pdict = {k: v / sats_to_usd for k, v in test_model.pricing.dict().items()} + test_model.sats_pricing = Pricing( + prompt=_pdict.get("prompt", 0.0), + completion=_pdict.get("completion", 0.0), + request=_pdict.get("request", 0.0), + image=_pdict.get("image", 0.0), + web_search=_pdict.get("web_search", 0.0), + internal_reasoning=_pdict.get("internal_reasoning", 0.0), + max_prompt_cost=_pdict.get("max_prompt_cost", 0.0), + max_completion_cost=_pdict.get("max_completion_cost", 0.0), + max_cost=_pdict.get("max_cost", 0.0), + ) + mspp = test_model.sats_pricing.prompt + mspc = test_model.sats_pricing.completion + if (tp := test_model.top_provider) and ( + tp.context_length or tp.max_completion_tokens + ): + if (cl := test_model.top_provider.context_length) and ( + mct := test_model.top_provider.max_completion_tokens + ): + test_model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc - try: - # Run the pricing update logic once directly - sats_to_usd = mock_sats_usd - for model in [test_model]: - model.sats_pricing = Pricing( - **{k: v / sats_to_usd for k, v in model.pricing.dict().items()} - ) - 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 - ): - model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc + # Verify sats pricing was calculated correctly + assert test_model.sats_pricing is not None + assert test_model.sats_pricing.prompt == pytest.approx( + 0.001 / mock_sats_usd + ) + assert test_model.sats_pricing.completion == pytest.approx( + 0.002 / mock_sats_usd + ) - # Verify sats pricing was calculated correctly - assert test_model.sats_pricing is not None - assert test_model.sats_pricing.prompt == pytest.approx( - 0.001 / mock_sats_usd - ) - assert test_model.sats_pricing.completion == pytest.approx( - 0.002 / mock_sats_usd - ) + # Verify max_cost calculation + # Logic uses (context_length - max_completion_tokens) * prompt + max_completion_tokens * completion + expected_max_cost = ( + (4096 - 1024) * test_model.sats_pricing.prompt + + 1024 * test_model.sats_pricing.completion + ) + assert test_model.sats_pricing.max_cost == pytest.approx(expected_max_cost) - # Verify max_cost calculation - # Logic uses (context_length - max_completion_tokens) * prompt + max_completion_tokens * completion - expected_max_cost = ( - (4096 - 1024) * test_model.sats_pricing.prompt - + 1024 * test_model.sats_pricing.completion - ) - assert test_model.sats_pricing.max_cost == pytest.approx( - expected_max_cost - ) - - finally: - # Restore original models - MODELS.clear() - MODELS.extend(original_models) + # Nothing to clean up; no global state was modified async def test_handles_provider_api_failures(self) -> None: """Test that pricing update continues running even if price API fails""" @@ -159,37 +157,39 @@ class TestPricingUpdateTask: ), ) - original_models = MODELS.copy() - MODELS.clear() - MODELS.append(test_model) + # Initialize pricing once to ensure consistent state + with patch( + "routstr.payment.price.sats_usd_ask_price", + AsyncMock(return_value=0.00002), + ): + sats_to_usd = 0.00002 + _pdict = {k: v / sats_to_usd for k, v in test_model.pricing.dict().items()} + test_model.sats_pricing = Pricing( + prompt=_pdict.get("prompt", 0.0), + completion=_pdict.get("completion", 0.0), + request=_pdict.get("request", 0.0), + image=_pdict.get("image", 0.0), + web_search=_pdict.get("web_search", 0.0), + internal_reasoning=_pdict.get("internal_reasoning", 0.0), + max_prompt_cost=_pdict.get("max_prompt_cost", 0.0), + max_completion_cost=_pdict.get("max_completion_cost", 0.0), + max_cost=_pdict.get("max_cost", 0.0), + ) - try: - with patch( - "routstr.payment.price.sats_usd_ask_price", - AsyncMock(return_value=0.00002), - ): - # Initialize pricing once to ensure consistent state - sats_to_usd = 0.00002 - test_model.sats_pricing = Pricing( - **{k: v / sats_to_usd for k, v in test_model.pricing.dict().items()} - ) + # Simulate concurrent access to the model + results = [] - # Simulate concurrent access to the model - results = [] + async def access_model() -> None: + await asyncio.sleep(0.05) # Small delay + results.append(test_model.sats_pricing) - async def access_model() -> None: - await asyncio.sleep(0.05) # Small delay - results.append(test_model.sats_pricing) + # Run multiple concurrent accesses - they should all see the consistent state + await asyncio.gather(*[access_model() for _ in range(10)]) - # Run multiple concurrent accesses - they should all see the consistent state - await asyncio.gather(*[access_model() for _ in range(10)]) + # All accesses should see consistent state + assert all(r is not None for r in results) - # All accesses should see consistent state - assert all(r is not None for r in results) - - finally: - MODELS.clear() - MODELS.extend(original_models) + # No global state to restore @pytest.mark.asyncio diff --git a/tests/integration/test_error_handling_edge_cases.py b/tests/integration/test_error_handling_edge_cases.py index cbe771b3..0d68249c 100644 --- a/tests/integration/test_error_handling_edge_cases.py +++ b/tests/integration/test_error_handling_edge_cases.py @@ -628,14 +628,11 @@ class TestEdgeCaseCombinations: a single request (which costs 1000 msats). It then makes 5 concurrent requests to verify that all requests fail with 402 Payment Required errors. - Note: The test disables MODEL_BASED_PRICING to avoid model lookup errors + Note: The test enables fixed pricing to avoid model lookup errors since the test environment doesn't have models configured. """ - # Disable MODEL_BASED_PRICING for this test to avoid model lookup issues - monkeypatch.setattr( - "routstr.payment.cost_caculation.MODEL_BASED_PRICING", False - ) - monkeypatch.setattr("routstr.payment.helpers.MODEL_BASED_PRICING", False) + # Disable model-based pricing for this test to avoid model lookup issues + monkeypatch.setattr("routstr.core.settings.settings.fixed_pricing", True) # Create a new API key with very low balance # Generate a unique API key @@ -645,7 +642,7 @@ class TestEdgeCaseCombinations: # Create the API key with only 500 msats (less than one request cost) new_key = ApiKey( hashed_key=api_key_hash, - balance=500, # Less than COST_PER_REQUEST (1000 msats) + balance=500, # Less than fixed cost per request (1000 msats) reserved_balance=0, total_spent=0, total_requests=0, diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index da63895c..aa1b0fc2 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -1,46 +1,75 @@ import os -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch # Set required env vars before importing os.environ["UPSTREAM_BASE_URL"] = "http://test" os.environ["UPSTREAM_API_KEY"] = "test" +from routstr.core.settings import settings # noqa: E402 from routstr.payment.helpers import get_max_cost_for_model # noqa: E402 -def test_get_max_cost_for_model_known() -> None: - mock_model = Mock() - mock_model.id = "gpt-4" - mock_model.sats_pricing = Mock() - mock_model.sats_pricing.max_cost = 500 +async def test_get_max_cost_for_model_known() -> None: + # Mock DB session behavior + mock_session = AsyncMock() + # available ids + mock_exec_result = Mock() + mock_exec_result.all = Mock(return_value=[("gpt-4",)]) + mock_session.exec.return_value = mock_exec_result + # row with sats_pricing + row = Mock() + row.sats_pricing = ( + "{" # minimal required fields for Pricing model + '"prompt": 0.0, "completion": 0.0, "request": 0.0, ' + '"image": 0.0, "web_search": 0.0, "internal_reasoning": 0.0, ' + '"max_cost": 500' + "}" + ) + mock_session.get.return_value = row - with patch("routstr.payment.helpers.MODELS", [mock_model]): - with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True): - cost = get_max_cost_for_model("gpt-4", tolerance_percentage=0) + with patch.object(settings, "fixed_pricing", False): + with patch.object(settings, "tolerance_percentage", 0): + cost = await get_max_cost_for_model("gpt-4", session=mock_session) assert cost == 500000 # 500 sats * 1000 = msats -def test_get_max_cost_for_model_unknown() -> None: - with patch("routstr.payment.helpers.MODELS", []): - with patch("routstr.payment.helpers.COST_PER_REQUEST", 100): - cost = get_max_cost_for_model("unknown-model", tolerance_percentage=0) - assert cost == 100 +async def test_get_max_cost_for_model_unknown() -> None: + mock_session = AsyncMock() + mock_exec_result = Mock() + mock_exec_result.all = Mock(return_value=[]) + mock_session.exec.return_value = mock_exec_result + mock_session.get.return_value = None + + with patch.object(settings, "fixed_cost_per_request", 100): + with patch.object(settings, "tolerance_percentage", 0): + cost = await get_max_cost_for_model("unknown-model", session=mock_session) + assert cost == 100000 -def test_get_max_cost_for_model_disabled() -> None: - with patch("routstr.payment.helpers.MODEL_BASED_PRICING", False): - with patch("routstr.payment.helpers.COST_PER_REQUEST", 200): - cost = get_max_cost_for_model("any-model", tolerance_percentage=0) - assert cost == 200 +async def test_get_max_cost_for_model_disabled() -> None: + with patch.object(settings, "fixed_pricing", True): + with patch.object(settings, "fixed_cost_per_request", 200): + with patch.object(settings, "tolerance_percentage", 0): + cost = await get_max_cost_for_model("any-model", session=None) + assert cost == 200000 -def test_get_max_cost_for_model_tolerance() -> None: - mock_model = Mock() - mock_model.id = "gpt-4" - mock_model.sats_pricing = Mock() - mock_model.sats_pricing.max_cost = 500 +async def test_get_max_cost_for_model_tolerance() -> None: + mock_session = AsyncMock() + mock_exec_result = Mock() + mock_exec_result.all = Mock(return_value=[("gpt-4",)]) + mock_session.exec.return_value = mock_exec_result + row = Mock() + row.sats_pricing = ( + "{" # minimal required fields for Pricing model + '"prompt": 0.0, "completion": 0.0, "request": 0.0, ' + '"image": 0.0, "web_search": 0.0, "internal_reasoning": 0.0, ' + '"max_cost": 500' + "}" + ) + mock_session.get.return_value = row - with patch("routstr.payment.helpers.MODELS", [mock_model]): - with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True): - cost = get_max_cost_for_model("gpt-4", tolerance_percentage=10) + with patch.object(settings, "fixed_pricing", False): + with patch.object(settings, "tolerance_percentage", 10): + cost = await get_max_cost_for_model("gpt-4", session=mock_session) assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 00000000..5c5cb048 --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,37 @@ +import os + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.settings import SettingsService + + +@pytest.mark.asyncio +async def test_settings_seed_from_env_and_persist() -> None: + os.environ["UPSTREAM_BASE_URL"] = "https://api.test/v1" + os.environ.pop("ONION_URL", None) + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + settings = await SettingsService.initialize(session) + + assert settings.upstream_base_url == "https://api.test/v1" + # ONION_URL may be empty if not discoverable + assert isinstance(settings.onion_url, str) + + +@pytest.mark.asyncio +async def test_settings_db_precedence_over_env() -> None: + os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1" + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + _ = await SettingsService.initialize(session) + updated = await SettingsService.update({"name": "DBName"}, session) + assert updated.name == "DBName" + + # Change env and re-initialize; DB should still win + os.environ["NAME"] = "EnvName" + again = await SettingsService.initialize(session) + assert again.name == "DBName" diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index b432cd9f..60b93b5a 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -39,7 +39,9 @@ async def test_recieve_token_valid() -> None: mock_wallet = Mock() mock_wallet.split = AsyncMock() - with patch("routstr.wallet.TRUSTED_MINTS", ["http://mint:3338"]): + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize: mock_token = Mock() mock_token.keysets = ["keyset1"] @@ -82,7 +84,9 @@ async def test_credit_balance() -> None: mock_key.balance = 5000000 mock_session = AsyncMock() - with patch("routstr.wallet.PRIMARY_MINT_URL", "http://mint:3338"): + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): with patch( "routstr.wallet.recieve_token", return_value=(1000, "sat", "http://mint:3338"), diff --git a/uv.lock b/uv.lock index a8747e59..b3f3170e 100644 --- a/uv.lock +++ b/uv.lock @@ -1783,7 +1783,7 @@ wheels = [ [[package]] name = "routstr" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -1793,6 +1793,7 @@ dependencies = [ { name = "greenlet" }, { name = "httpx", extra = ["socks"] }, { name = "marshmallow" }, + { name = "mdurl" }, { name = "nostr" }, { name = "python-json-logger" }, { name = "secp256k1" }, @@ -1824,6 +1825,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.2.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, + { name = "mdurl", specifier = "==0.1.2" }, { name = "nostr", specifier = ">=0.0.2" }, { name = "python-json-logger", specifier = ">=2.0.0" }, { name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },