From ac24f10cb01b57dff0b7baff5cb9674d50f3c592 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 6 Sep 2025 14:50:55 +0100 Subject: [PATCH 01/35] cleanup compose --- .repro_worktrees/main | 1 + compose.yml | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) create mode 160000 .repro_worktrees/main diff --git a/.repro_worktrees/main b/.repro_worktrees/main new file mode 160000 index 00000000..ad068877 --- /dev/null +++ b/.repro_worktrees/main @@ -0,0 +1 @@ +Subproject commit ad068877ac897965659b0436b748aa9742973f89 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: From d2b8a4e78b4b4c3ab2e749ca9e33cb977dd79c10 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 6 Sep 2025 14:57:09 +0100 Subject: [PATCH 02/35] update env example --- .env.example | 56 ++++++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 30 deletions(-) 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= From a28277a0a80224983595b9693cf3956b634d4c29 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 6 Sep 2025 14:57:12 +0100 Subject: [PATCH 03/35] rm --- .repro_worktrees/main | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .repro_worktrees/main diff --git a/.repro_worktrees/main b/.repro_worktrees/main deleted file mode 160000 index ad068877..00000000 --- a/.repro_worktrees/main +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ad068877ac897965659b0436b748aa9742973f89 From 14718843de02c62fc654fbe229f82b3074c67b9a Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:52:21 +0100 Subject: [PATCH 04/35] feat(settings): add DB-backed Settings and SettingsService with env merge --- .../a1b2c3d4e5f6_add_settings_table.py | 35 +++ routstr/core/settings.py | 293 ++++++++++++++++++ tests/unit/test_settings.py | 37 +++ 3 files changed, 365 insertions(+) create mode 100644 migrations/versions/a1b2c3d4e5f6_add_settings_table.py create mode 100644 routstr/core/settings.py create mode 100644 tests/unit/test_settings.py 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/routstr/core/settings.py b/routstr/core/settings.py new file mode 100644 index 00000000..9e8c1d0e --- /dev/null +++ b/routstr/core/settings.py @@ -0,0 +1,293 @@ +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") + + # 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" + ) + 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") + openrouter_base_url: str = Field( + default="https://openrouter.ai/api/v1", env="BASE_URL" + ) + + # Secrets / optional runtime controls + provider_id: str = Field(default="", env="PROVIDER_ID") + nip91_provider_id: str = Field(default="", env="NIP91_PROVIDER_ID") + nsec: str = Field(default="", env="NSEC") + + # NIP-91 + relays: list[str] = Field(default_factory=list, env="RELAYS") + nip91_backoff_base_seconds: float = Field( + default=5.0, env="NIP91_BACKOFF_BASE_SECONDS" + ) + nip91_backoff_max_seconds: float = Field( + default=900.0, env="NIP91_BACKOFF_MAX_SECONDS" + ) + nip91_backoff_jitter_ratio: float = Field( + default=0.2, env="NIP91_BACKOFF_JITTER_RATIO" + ) + nip91_announcement_interval: int = Field( + default=24 * 60 * 60, env="NIP91_ANNOUNCEMENT_INTERVAL" + ) + + +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 + 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/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" From 7761d144f6a06aa4b91b7f22001749c57450f4f3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:52:30 +0100 Subject: [PATCH 05/35] feat(core): initialize and use SettingsService; update admin settings API; hook app metadata from settings --- routstr/core/admin.py | 73 +++++++++++++++++++++++++++++++++++++---- routstr/core/logging.py | 22 +++++++++---- routstr/core/main.py | 42 ++++++++++++++---------- 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 4bf6b00a..71cae6f0 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -9,7 +9,6 @@ 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,50 @@ 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) +@admin_router.get("/api/settings") +async def get_settings(request: Request) -> dict: + admin_cookie = request.cookies.get("admin_password") + if not admin_cookie or admin_cookie != settings.admin_password: + raise HTTPException(status_code=403, detail="Unauthorized") + 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") +async def update_settings(request: Request, update: SettingsUpdate) -> dict: + admin_cookie = request.cookies.get("admin_password") + if not admin_cookie or admin_cookie != settings.admin_password: + raise HTTPException(status_code=403, detail="Unauthorized") + + 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,7 +124,12 @@ 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() @@ -454,7 +496,12 @@ async def dashboard(request: Request) -> str: @admin_router.get("/", response_class=HTMLResponse) async def admin(request: Request) -> str: admin_cookie = request.cookies.get("admin_password") - if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"): + try: + settings = SettingsService.get() + admin_pw: str = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" + if admin_cookie and admin_cookie == admin_pw: return await dashboard(request) return admin_auth() @@ -462,7 +509,12 @@ async def admin(request: Request) -> str: @admin_router.get("/logs/{request_id}", response_class=HTMLResponse) async def view_logs(request: Request, request_id: str) -> str: admin_cookie = request.cookies.get("admin_password") - if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"): + try: + settings = SettingsService.get() + admin_pw: str = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" + if not admin_cookie or admin_cookie != admin_pw: return admin_auth() logger.info(f"Investigating logs for request_id: {request_id}") @@ -660,16 +712,23 @@ 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"): + try: + settings = SettingsService.get() + admin_pw: str = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" + if not admin_cookie or admin_cookie != admin_pw: 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, ) 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..04cda0dc 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 @@ -15,10 +14,12 @@ from ..payment.models import MODELS, models_router, 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() @@ -47,6 +48,17 @@ 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 + pricing_task = asyncio.create_task(update_sats_pricing()) payout_task = asyncio.create_task(periodic_payout()) nip91_task = asyncio.create_task(announce_provider()) @@ -93,18 +105,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 +129,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": MODELS, # todo maybe remove models from here } From 8286f4f0cc17f440ef3339669f89b1409b83079d Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:52:39 +0100 Subject: [PATCH 06/35] refactor(discovery,nip91): read relays and config from settings; default Tor proxy; cleanup --- routstr/discovery.py | 26 ++++++++++++----------- routstr/nip91.py | 50 +++++++++++++++++++++++++++----------------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/routstr/discovery.py b/routstr/discovery.py index 798c1756..4478399c 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,17 @@ 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", ] - return discovery_relays + return relays async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]: @@ -297,10 +299,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 +321,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..9bb6b25a 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,7 +287,7 @@ 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 or settings.nip91_provider_id if explicit: logger.info(f"Using configured provider_id from env: {explicit}") return explicit @@ -352,7 +353,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 @@ -367,9 +368,10 @@ async def announce_provider() -> None: 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()] + try: + relay_urls = [u.strip() for u in settings.relays if u.strip()] + except Exception: + relay_urls = settings.relays if not relay_urls: relay_urls = [ "wss://relay.nostr.band", @@ -382,19 +384,23 @@ async def announce_provider() -> None: 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") + 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) @@ -433,9 +439,14 @@ async def announce_provider() -> None: ) # 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")) + try: + backoff_base = settings.nip91_backoff_base_seconds + backoff_max = settings.nip91_backoff_max_seconds + backoff_jitter_ratio = settings.nip91_backoff_jitter_ratio + except Exception: + backoff_base = settings.nip91_backoff_base_seconds + backoff_max = settings.nip91_backoff_max_seconds + backoff_jitter_ratio = settings.nip91_backoff_jitter_ratio relay_next_allowed: dict[str, float] = {} relay_current_delay: dict[str, float] = {} @@ -499,9 +510,10 @@ async def announce_provider() -> None: ) # Re-announce periodically (every 24 hours) - announcement_interval = int( - os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60)) - ) + try: + announcement_interval = settings.nip91_announcement_interval + except Exception: + announcement_interval = settings.nip91_announcement_interval while True: try: From db2c6eb98a59343c933b1d7636a2bcea49afda7d Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:52:47 +0100 Subject: [PATCH 07/35] refactor(payment): switch to settings-based pricing and upstream config; support fixed vs model pricing --- routstr/payment/cost_caculation.py | 29 +++-------------- routstr/payment/helpers.py | 52 +++++++++++++++--------------- routstr/payment/models.py | 14 +++++--- routstr/payment/price.py | 13 ++++---- routstr/payment/x_cashu.py | 4 +-- 5 files changed, 48 insertions(+), 64 deletions(-) diff --git a/routstr/payment/cost_caculation.py b/routstr/payment/cost_caculation.py index 0cce2827..3c7e102c 100644 --- a/routstr/payment/cost_caculation.py +++ b/routstr/payment/cost_caculation.py @@ -1,34 +1,13 @@ import math -import os from pydantic.v1 import BaseModel from ..core import get_logger +from ..core.settings import settings from .models import MODELS 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 @@ -85,10 +64,10 @@ 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 = settings.fixed_per_1k_input_tokens * 1000 + MSATS_PER_1K_OUTPUT_TOKENS = settings.fixed_per_1k_output_tokens * 1000 - if MODEL_BASED_PRICING and MODELS: + if (not settings.fixed_pricing) and MODELS: response_model = response_data.get("model", "") logger.debug( "Using model-based pricing", diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 0ec24636..bb2fe67e 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -1,26 +1,17 @@ import json -import os from typing import Mapping from fastapi import HTTPException, Response from fastapi.requests import Request from ..core import get_logger +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 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 @@ -95,28 +86,32 @@ def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int: "Getting max cost for model", extra={ "model": model, - "model_based_pricing": MODEL_BASED_PRICING, + "fixed_pricing": settings.fixed_pricing, "has_models": bool(MODELS), }, ) - 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 default_cost_msats if model not in [model.id for model in MODELS]: + # 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, + "using_default_cost": fallback_msats, }, ) - return COST_PER_REQUEST + return fallback_msats for m in MODELS: if m.id == model: @@ -128,10 +123,13 @@ def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int: return int(max_cost) 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 settings.fixed_cost_per_request * 1000 def create_error_response( @@ -161,11 +159,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 +183,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 +197,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 +209,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..5d4d0c33 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -1,6 +1,5 @@ import asyncio import json -import os from pathlib import Path from urllib.request import urlopen @@ -8,6 +7,7 @@ from fastapi import APIRouter from pydantic.v1 import BaseModel from ..core.logging import get_logger +from ..core.settings import settings from .price import sats_usd_ask_price logger = get_logger(__name__) @@ -57,7 +57,7 @@ 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 = settings.openrouter_base_url try: with urlopen(f"{base_url}/models") as response: @@ -100,7 +100,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(): @@ -115,7 +118,10 @@ def load_models() -> list[Model]: # 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) 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..a6a2bc85 100644 --- a/routstr/payment/x_cashu.py +++ b/routstr/payment/x_cashu.py @@ -7,10 +7,10 @@ from fastapi import BackgroundTasks, HTTPException, Request from fastapi.responses import Response, StreamingResponse from ..core import get_logger +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 +109,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", From a4f28db887a81cfe60483a5a263d8dd2d80f6075 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:52:54 +0100 Subject: [PATCH 08/35] refactor(wallet): use settings for cashu mints, primary mint, and payout address --- routstr/wallet.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) 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", From 15e61d17602c45ee2552d3c8cbf702bf1d5e2ecf Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:53:02 +0100 Subject: [PATCH 09/35] refactor(proxy): use settings for upstream base; integrate new pricing flow and header prep --- routstr/proxy.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routstr/proxy.py b/routstr/proxy.py index 715cfd0b..c9b939e1 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -15,8 +15,8 @@ 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, check_token_balance, create_error_response, get_max_cost_for_model, @@ -307,7 +307,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", @@ -756,7 +756,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", From 8f22826d63e9913885074305417cc4add2e55090 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:53:11 +0100 Subject: [PATCH 10/35] fix(balance): correct sat refund conversion from msats and use settings for TTL --- routstr/balance.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 97039905..6dacf7c5 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") @@ -102,7 +102,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 +157,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} From 496900baa6bf548e06bd3a0c309a28597876763d Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:53:23 +0100 Subject: [PATCH 11/35] test: update tests to patch settings and use fixed pricing flags --- tests/integration/conftest.py | 13 +++++++------ .../integration/test_error_handling_edge_cases.py | 11 ++++------- tests/unit/test_payment_helpers.py | 15 ++++++++------- tests/unit/test_wallet.py | 8 ++++++-- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c3727a72..7f58c24f 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,11 @@ 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 + 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), 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..1f7c7600 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch 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 @@ -15,23 +16,23 @@ def test_get_max_cost_for_model_known() -> None: mock_model.sats_pricing.max_cost = 500 with patch("routstr.payment.helpers.MODELS", [mock_model]): - with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True): + with patch.object(settings, "fixed_pricing", False): cost = get_max_cost_for_model("gpt-4", tolerance_percentage=0) 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): + with patch.object(settings, "fixed_cost_per_request", 100): cost = get_max_cost_for_model("unknown-model", tolerance_percentage=0) - assert cost == 100 + 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): + with patch.object(settings, "fixed_pricing", True): + with patch.object(settings, "fixed_cost_per_request", 200): cost = get_max_cost_for_model("any-model", tolerance_percentage=0) - assert cost == 200 + assert cost == 200000 def test_get_max_cost_for_model_tolerance() -> None: @@ -41,6 +42,6 @@ def test_get_max_cost_for_model_tolerance() -> None: mock_model.sats_pricing.max_cost = 500 with patch("routstr.payment.helpers.MODELS", [mock_model]): - with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True): + with patch.object(settings, "fixed_pricing", False): cost = get_max_cost_for_model("gpt-4", tolerance_percentage=10) assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000 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"), From bb9991e7a5350a09b191e5535474ed0822b8189d Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:53:32 +0100 Subject: [PATCH 12/35] docs: update configuration/pricing docs and compose with FIXED_* vars --- compose.testing.yml | 8 ++--- docs/advanced/custom-pricing.md | 14 ++++---- docs/api/endpoints.md | 21 +++++++++++ docs/getting-started/configuration.md | 51 ++++++++++----------------- docs/getting-started/docker.md | 4 +-- docs/user-guide/models-pricing.md | 14 ++++---- 6 files changed, 60 insertions(+), 52 deletions(-) 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/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/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/getting-started/configuration.md b/docs/getting-started/configuration.md index 904553b7..360437cc 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,10 +33,10 @@ 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` | ❌ | @@ -46,6 +46,8 @@ Routstr Core is configured through environment variables. This guide covers all |----------|-------------|---------|----------| | `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 for NIP-91 | defaults applied | ❌ | +| `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/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 ``` From 939a991d6b7f2123f3fa0acf3cee6873f078aacf Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:53:41 +0100 Subject: [PATCH 13/35] chore: update version/readme; minor auth logging and settings usage --- README.md | 2 +- routstr/__init__.py | 4 ---- routstr/auth.py | 12 ++++-------- 3 files changed, 5 insertions(+), 13 deletions(-) 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/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..c6c46592 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, From be372c48ea575e3a12b775cfef3ff13148c9b6f8 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 12:24:32 +0100 Subject: [PATCH 14/35] refactor(settings): remove NIP-91 fields; keep relays under discovery; drop openrouter_base_url --- routstr/core/settings.py | 18 +----------------- routstr/nip91.py | 25 +++++++------------------ routstr/payment/models.py | 2 +- scripts/models_meta.py | 4 ++-- 4 files changed, 11 insertions(+), 38 deletions(-) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 9e8c1d0e..65e7dcba 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -69,29 +69,13 @@ class Settings(BaseSettings): ) models_path: str = Field(default="models.json", env="MODELS_PATH") source: str = Field(default="", env="SOURCE") - openrouter_base_url: str = Field( - default="https://openrouter.ai/api/v1", env="BASE_URL" - ) # Secrets / optional runtime controls provider_id: str = Field(default="", env="PROVIDER_ID") - nip91_provider_id: str = Field(default="", env="NIP91_PROVIDER_ID") nsec: str = Field(default="", env="NSEC") - # NIP-91 + # Discovery relays: list[str] = Field(default_factory=list, env="RELAYS") - nip91_backoff_base_seconds: float = Field( - default=5.0, env="NIP91_BACKOFF_BASE_SECONDS" - ) - nip91_backoff_max_seconds: float = Field( - default=900.0, env="NIP91_BACKOFF_MAX_SECONDS" - ) - nip91_backoff_jitter_ratio: float = Field( - default=0.2, env="NIP91_BACKOFF_JITTER_RATIO" - ) - nip91_announcement_interval: int = Field( - default=24 * 60 * 60, env="NIP91_ANNOUNCEMENT_INTERVAL" - ) def _compute_primary_mint(cashu_mints: list[str]) -> str: diff --git a/routstr/nip91.py b/routstr/nip91.py index 9bb6b25a..4d4ab59d 100644 --- a/routstr/nip91.py +++ b/routstr/nip91.py @@ -287,7 +287,7 @@ 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 = settings.provider_id or settings.nip91_provider_id + explicit = settings.provider_id if explicit: logger.info(f"Using configured provider_id from env: {explicit}") return explicit @@ -368,10 +368,7 @@ async def announce_provider() -> None: logger.info(f"Using Nostr pubkey: {public_key_hex}") # Configure relays first (RELAYS only) - try: - relay_urls = [u.strip() for u in settings.relays if u.strip()] - except Exception: - relay_urls = settings.relays + relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()] if not relay_urls: relay_urls = [ "wss://relay.nostr.band", @@ -438,15 +435,10 @@ async def announce_provider() -> None: metadata=metadata, ) - # Backoff configuration and state - try: - backoff_base = settings.nip91_backoff_base_seconds - backoff_max = settings.nip91_backoff_max_seconds - backoff_jitter_ratio = settings.nip91_backoff_jitter_ratio - except Exception: - backoff_base = settings.nip91_backoff_base_seconds - backoff_max = settings.nip91_backoff_max_seconds - backoff_jitter_ratio = settings.nip91_backoff_jitter_ratio + # 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] = {} @@ -510,10 +502,7 @@ async def announce_provider() -> None: ) # Re-announce periodically (every 24 hours) - try: - announcement_interval = settings.nip91_announcement_interval - except Exception: - announcement_interval = settings.nip91_announcement_interval + announcement_interval = 24 * 60 * 60 while True: try: diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 5d4d0c33..2df73cf4 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -57,7 +57,7 @@ MODELS: list[Model] = [] def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: """Fetches model information from OpenRouter API.""" - base_url = settings.openrouter_base_url + base_url = "https://openrouter.ai/api/v1" try: with urlopen(f"{base_url}/models") as response: 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] = [] From 44030ddbe567a9d2c8244b1b2cfc764c9d322cdd Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 12:26:22 +0100 Subject: [PATCH 15/35] docs: simplify discovery config (RELAYS only); drop OpenRouter BASE_URL mentions --- docs/getting-started/configuration.md | 4 ++-- scripts/crontab.example | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 360437cc..246678c6 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -40,13 +40,13 @@ Routstr Core is configured via a single settings row in the database. Environmen | `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 for NIP-91 | defaults applied | ❌ | +| `RELAYS` | Comma-separated nostr relays used for provider discovery | sane defaults | ❌ | | `PROVIDERS_REFRESH_INTERVAL_SECONDS` | Provider cache refresh interval | `300` | ❌ | ### Logging Configuration 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 From 7c9265b40d813c026320d9f964c5e2bf5cca992f Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 12:29:41 +0100 Subject: [PATCH 16/35] feat(settings): derive NPUB from NSEC during bootstrap (similar to ONION discovery) --- routstr/core/settings.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 65e7dcba..1ade5eec 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -134,6 +134,25 @@ def resolve_bootstrap() -> Settings: 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: From 918a083a122639f44f5287566fb73998b1aa1c2e Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 12:48:27 +0100 Subject: [PATCH 17/35] cleanup admin auth --- routstr/core/admin.py | 47 ++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 71cae6f0..8edd8707 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -3,7 +3,7 @@ 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 @@ -24,11 +24,19 @@ logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) -@admin_router.get("/api/settings") -async def get_settings(request: Request) -> dict: +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("/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 "" @@ -43,12 +51,8 @@ class SettingsUpdate(BaseModel): __root__: dict[str, object] -@admin_router.patch("/api/settings") +@admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)]) async def update_settings(request: Request, update: SettingsUpdate) -> dict: - admin_cookie = request.cookies.get("admin_password") - if not admin_cookie or admin_cookie != settings.admin_password: - raise HTTPException(status_code=403, detail="Unauthorized") - async with create_session() as session: new_settings = await SettingsService.update(update.__root__, session) data = new_settings.dict() @@ -495,26 +499,14 @@ async def dashboard(request: Request) -> str: @admin_router.get("/", response_class=HTMLResponse) async def admin(request: Request) -> str: - admin_cookie = request.cookies.get("admin_password") - try: - settings = SettingsService.get() - admin_pw: str = settings.admin_password - except Exception: - admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" - if admin_cookie and admin_cookie == admin_pw: + if is_admin_authenticated(request): return await dashboard(request) return admin_auth() @admin_router.get("/logs/{request_id}", response_class=HTMLResponse) async def view_logs(request: Request, request_id: str) -> str: - admin_cookie = request.cookies.get("admin_password") - try: - settings = SettingsService.get() - admin_pw: str = settings.admin_password - except Exception: - admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" - if not admin_cookie or admin_cookie != admin_pw: + if not is_admin_authenticated(request): return admin_auth() logger.info(f"Investigating logs for request_id: {request_id}") @@ -707,19 +699,10 @@ 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") - try: - settings = SettingsService.get() - admin_pw: str = settings.admin_password - except Exception: - admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" - if not admin_cookie or admin_cookie != admin_pw: - raise HTTPException(status_code=403, detail="Unauthorized") - # Get wallet and check balance from .settings import settings as global_settings From dcbaf7413a2e87928b4178f7ba5563b4533326e5 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 12:57:29 +0100 Subject: [PATCH 18/35] edit settings over admin dashboard --- routstr/core/admin.py | 97 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 8edd8707..e5be44ce 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -366,13 +366,93 @@ async def dashboard(request: Request) -> str: window.location.href = `/admin/logs/${{requestId}}`; }} + async function openSettingsModal() {{ + const modal = document.getElementById('settings-modal'); + const textarea = document.getElementById('settings-json'); + const errorBox = document.getElementById('settings-error'); + errorBox.style.display = 'none'; + errorBox.textContent = ''; + try {{ + const resp = await fetch('/admin/api/settings', {{ credentials: 'same-origin' }}); + if (!resp.ok) {{ + throw new Error('HTTP ' + resp.status); + }} + const data = await resp.json(); + textarea.value = JSON.stringify(data, null, 2); + }} catch (e) {{ + errorBox.style.display = 'block'; + errorBox.textContent = 'Failed to load settings: ' + e.message; + textarea.value = '{{}}'; + }} + modal.style.display = 'block'; + }} + + function closeSettingsModal() {{ + const modal = document.getElementById('settings-modal'); + modal.style.display = 'none'; + }} + + async function saveSettings() {{ + const textarea = document.getElementById('settings-json'); + const errorBox = document.getElementById('settings-error'); + errorBox.style.display = 'none'; + errorBox.style.color = '#e53e3e'; + let payload; + try {{ + payload = JSON.parse(textarea.value); + }} catch (e) {{ + errorBox.style.display = 'block'; + errorBox.textContent = 'Invalid JSON: ' + e.message; + return; + }} + + ['upstream_api_key', 'admin_password', 'nsec'].forEach(k => {{ + if (payload && payload[k] === '[REDACTED]') {{ delete payload[k]; }} + }}); + + try {{ + const resp = await fetch('/admin/api/settings', {{ + method: 'PATCH', + headers: {{ 'Content-Type': 'application/json' }}, + credentials: 'same-origin', + body: JSON.stringify(payload) + }}); + if (resp.ok) {{ + const data = await resp.json(); + textarea.value = JSON.stringify(data, null, 2); + errorBox.style.display = 'block'; + errorBox.style.color = '#22c55e'; + errorBox.textContent = 'Saved successfully'; + setTimeout(() => {{ errorBox.style.display = 'none'; }}, 2000); + }} else {{ + let errText = 'Failed to save settings'; + try {{ + const err = await resp.json(); + if (err && err.detail) {{ + errText = typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail); + }} + }} catch (_ignored) {{}} + errorBox.style.display = 'block'; + errorBox.style.color = '#e53e3e'; + errorBox.textContent = errText; + }} + }} catch (e) {{ + errorBox.style.display = 'block'; + errorBox.style.color = '#e53e3e'; + errorBox.textContent = 'Request failed: ' + e.message; + }} + }} + window.onclick = function(event) {{ const withdrawModal = document.getElementById('withdraw-modal'); const investigateModal = document.getElementById('investigate-modal'); + const settingsModal = document.getElementById('settings-modal'); if (event.target == withdrawModal) {{ closeWithdrawModal(); }} else if (event.target == investigateModal) {{ closeInvestigateModal(); + }} else if (event.target == settingsModal) {{ + closeSettingsModal(); }} }} @@ -433,6 +513,9 @@ async def dashboard(request: Request) -> str: + + +