Compare commits

...

104 Commits

Author SHA1 Message Date
Cursor Agent
46a1e8ebef CI: run pytest with junitxml and coverage artifacts
Generate pytest.xml and coverage output so artifact upload step stops warning.
2025-10-05 02:30:31 +00:00
Cursor Agent
9593437a81 Fix: mypy error for httpx proxies across versions to stabilize CI
CI was failing pytests; locally ensured tests pass and added a minor typing fix in discovery to avoid mypy breakage across httpx versions. Also validated ruff/mypy in workflow context.
2025-10-04 08:55:38 +00:00
redshift
801bc50fd7 Added url normalization for mint urls from .env
Otherwise even valid mint tokens are unnecessarily swapped to primary mint. Faced this issue myself.
2025-09-24 13:29:30 +00:00
redshift
ecbe3158c0 Merge pull request #179 from Routstr/503-mint-service-bug
Fixed the 503 mint service unavailable bug
2025-09-22 16:27:36 +00:00
redshift
bac50f2150 Fixed the 503 mint service unavailable bug
This flag fetch all keysets from the db into the wallet, which is wrong given that we're initiating it for a mint. And load_mint sets the first keyset from all the keysets from the db, irrespective of the unit and the mint. 

Fixes #175
2025-09-22 16:25:02 +00:00
shroominic
4bf02226dc Merge pull request #172 from Routstr/fix-max-tokens-string-int
fix max-tokens coming in as string
2025-09-16 13:05:39 +01:00
Shroominic
c8f4e7d3e5 fix max-tokens coming in as string 2025-09-16 13:00:38 +01:00
shroominic
3d4a8e0e14 Merge pull request #169 from Routstr/dev
## Routstr v0.1.3 — 2025-09-15

### Highlights

- **DB-backed settings with live updates**: Env vars seed on first run; DB is the source of truth (except `DATABASE_URL`). Manage via Admin UI or `PATCH /admin/api/settings`.
- **Models moved to database** with background sats-pricing computation and optional periodic refresh.
- **Smarter max-cost reservation**: Discount based on prompt tokens and `max_tokens` using `TOLERANCE_PERCENTAGE`, with a `MIN_REQUEST_MSAT` floor.
- **Faster startup and provider discovery**; `.onion` endpoints use Tor proxy by default.

### Breaking changes

- **Configuration is DB-first** after bootstrap. Env changes won’t auto-apply (besides `DATABASE_URL`).
- **Models endpoint**: Use `/v1/models` for canonical model info; `/v1/info` keeps an empty `models` field for back-compat.
- **Legacy pricing envs** are auto-mapped but deprecated:
  - `MODEL_BASED_PRICING` → `!FIXED_PRICING`
  - `COST_PER_REQUEST` → `FIXED_COST_PER_REQUEST`
  - `COST_PER_1K_*` → `FIXED_PER_1K_*`

### Added

- **SettingsService** with env→computed→DB merge; runtime edits; app metadata (name/description) applied to OpenAPI.
- **Automatic migrations** on startup; new tables: `settings`, `models`.
- **Per-model sats pricing** and per-request maximums computed in the background.
- **Pricing controls**: `FIXED_PRICING`, `FIXED_COST_PER_REQUEST`, optional per-1k token overrides, `MIN_REQUEST_MSAT`.
- **Azure chat support**: Optional `CHAT_COMPLETIONS_API_VERSION` adds `api-version` to `/chat/completions`.
- **Derive `NPUB` from `NSEC`** during bootstrap if not provided.

### Changed / Improvements

- **Proxy header hardening**: Strip sensitive headers; replace Authorization with upstream key when configured.
- **Discovery**: Read `RELAYS` from settings; Tor proxy defaults for `.onion` via `TOR_PROXY_URL`; faster announcement fetching.
- **Admin**: Improved auth/log UX; fixed log search; show mint balances in dashboard.
- **Performance**: Optimized startup; lazy DB info loading.

### Fixed

- **Refunds**: Correct msat→sat conversion; configurable refund cache TTL via `REFUND_CACHE_TTL_SECONDS`; better error mapping for mint outages.
- **Balances**: Reserved balance reporting and negative-reserved edge cases.
- **Tests**: Updated to patch settings and use fixed-pricing flags.

### Upgrade notes (from v0.1.2)

1. Ensure `DATABASE_URL`, `UPSTREAM_BASE_URL`, and `ADMIN_PASSWORD` are set. Optionally set `UPSTREAM_API_KEY`, `RELAYS`, `CASHU_MINTS`, `TOR_PROXY_URL`.
2. Start the service; automatic Alembic migrations will run (`settings`, `models` tables).
3. Configure settings via Admin UI (`/admin`) or `PATCH /admin/api/settings`. Env changes after first run won’t apply automatically.
4. Prefer new pricing envs or a `models.json` at `MODELS_PATH`. Legacy envs are mapped but will be removed in a future release.
5. Optional tuning: `TOLERANCE_PERCENTAGE`, `MIN_REQUEST_MSAT`, `PRICING_REFRESH_INTERVAL_SECONDS`, `MODELS_REFRESH_INTERVAL_SECONDS`, `ENABLE_*_REFRESH`.
6. For Azure, set `CHAT_COMPLETIONS_API_VERSION`.

### References

- Configuration: `docs/getting-started/configuration.md`
- API: `docs/api/endpoints.md`
- Custom pricing: `docs/advanced/custom-pricing.md`

### Stats and credits

- 42 files changed, +1819/−832 lines.
- Merges: #170 token-discount-max-cost, #171 move-models-to-db.
- Contributors: Shroominic.
2025-09-16 12:43:54 +01:00
Shroominic
8b9d53be3c fix deps issue 2025-09-15 18:04:35 +01:00
Shroominic
11a01dae89 fix pytests 2025-09-15 18:01:01 +01:00
Shroominic
9ab269dd8f ⬆️ v0.1.3 2025-09-15 17:55:20 +01:00
Shroominic
c263d38732 add reserved balance info 2025-09-15 17:38:51 +01:00
Shroominic
c4f84cd1e6 Merge remote-tracking branch 'refs/remotes/origin/dev' into dev 2025-09-11 13:40:36 +01:00
shroominic
0478b1c360 Merge pull request #171 from Routstr/move-models-to-db
Move models to db
2025-09-11 13:40:04 +01:00
Shroominic
633ae39622 optimize startup time and announcement fetching 2025-09-11 13:39:29 +01:00
Shroominic
84ff9f5dc3 update default relays 2025-09-11 13:38:04 +01:00
Shroominic
88ec3395c7 fix: missing mints balances in admin dashboard 2025-09-11 12:51:03 +01:00
Shroominic
49485435ad fix admin log search 2025-09-11 12:46:06 +01:00
Shroominic
d80df28b23 rm not needed setting 2025-09-11 12:42:23 +01:00
Shroominic
4cdbf23ec0 fixes 2025-09-10 13:28:48 +01:00
Shroominic
73d3613301 move MODELS to DB 2025-09-10 13:28:43 +01:00
shroominic
6993e6b156 Merge pull request #170 from Routstr/token-discount-max-cost
max cost discount
2025-09-09 18:30:14 +01:00
Shroominic
6d1525748d fix tests 2025-09-09 18:27:48 +01:00
Shroominic
57ac38e6dc fix test patches 2025-09-09 14:27:25 +01:00
Shroominic
2e42af61bb rm tolerance percentage args 2025-09-09 14:00:39 +01:00
Shroominic
7dfc2f4056 max cost discount 2025-09-09 13:58:52 +01:00
Shroominic
2b045c95c7 dev version indicator 2025-09-08 14:52:52 +01:00
Shroominic
665b3f9a16 lazy load db information 2025-09-08 13:43:17 +01:00
Shroominic
dcbaf7413a edit settings over admin dashboard 2025-09-08 12:57:29 +01:00
Shroominic
918a083a12 cleanup admin auth 2025-09-08 12:48:27 +01:00
Shroominic
7c9265b40d feat(settings): derive NPUB from NSEC during bootstrap (similar to ONION discovery) 2025-09-08 12:29:41 +01:00
Shroominic
44030ddbe5 docs: simplify discovery config (RELAYS only); drop OpenRouter BASE_URL mentions 2025-09-08 12:26:22 +01:00
Shroominic
be372c48ea refactor(settings): remove NIP-91 fields; keep relays under discovery; drop openrouter_base_url 2025-09-08 12:24:32 +01:00
Shroominic
939a991d6b chore: update version/readme; minor auth logging and settings usage 2025-09-08 10:53:41 +01:00
Shroominic
bb9991e7a5 docs: update configuration/pricing docs and compose with FIXED_* vars 2025-09-08 10:53:32 +01:00
Shroominic
496900baa6 test: update tests to patch settings and use fixed pricing flags 2025-09-08 10:53:23 +01:00
Shroominic
8f22826d63 fix(balance): correct sat refund conversion from msats and use settings for TTL 2025-09-08 10:53:11 +01:00
Shroominic
15e61d1760 refactor(proxy): use settings for upstream base; integrate new pricing flow and header prep 2025-09-08 10:53:02 +01:00
Shroominic
a4f28db887 refactor(wallet): use settings for cashu mints, primary mint, and payout address 2025-09-08 10:52:54 +01:00
Shroominic
db2c6eb98a refactor(payment): switch to settings-based pricing and upstream config; support fixed vs model pricing 2025-09-08 10:52:47 +01:00
Shroominic
8286f4f0cc refactor(discovery,nip91): read relays and config from settings; default Tor proxy; cleanup 2025-09-08 10:52:39 +01:00
Shroominic
7761d144f6 feat(core): initialize and use SettingsService; update admin settings API; hook app metadata from settings 2025-09-08 10:52:30 +01:00
Shroominic
14718843de feat(settings): add DB-backed Settings and SettingsService with env merge 2025-09-08 10:52:21 +01:00
Shroominic
a28277a0a8 rm 2025-09-06 14:57:12 +01:00
Shroominic
d2b8a4e78b update env example 2025-09-06 14:57:09 +01:00
Shroominic
ac24f10cb0 cleanup compose 2025-09-06 14:50:55 +01:00
shroominic
54fe4e27c0 ⬆️ v0.1.2 2025-09-04 21:50:05 +01:00
Shroominic
cc9e387d3a ⬆️ v0.1.2 2025-09-04 21:47:13 +01:00
shroominic
f499be5c15 Merge pull request #160 from Routstr/azure-openai-compatibility
Azure OpenAI compatibility
2025-09-04 21:41:05 +01:00
shroominic
12b88de226 Merge pull request #162 from Routstr/fix/reserved-balance-finalization
fix: always finalize reserved_balance; robust streaming finalization …
2025-09-04 21:40:44 +01:00
shroominic
a2ea4e60b0 Merge pull request #118 from Routstr/cursor/discover-and-announce-routstr-providers-3b9a
Discover and announce routstr providers
2025-09-04 21:40:24 +01:00
Shroominic
619f601cd4 mint_url -> mint_urls 2025-09-04 17:05:23 +01:00
Shroominic
f4dbea95f7 rm logs 2025-09-04 16:48:37 +01:00
Shroominic
b28aa20257 fix: always finalize reserved_balance; robust streaming finalization without usage or interruptions 2025-09-04 15:59:53 +01:00
Shroominic
331a3bebbd not change version here 2025-09-04 15:33:32 +01:00
Shroominic
9b70919d65 handle potential rate limiting 2025-09-04 15:29:28 +01:00
Shroominic
f09d1991f0 reduce timeout 2025-09-04 15:29:28 +01:00
Shroominic
e7995dc6b0 ƒormat tests 2025-09-04 15:29:28 +01:00
Shroominic
a62446dbd2 fix tests 2025-09-04 15:29:28 +01:00
Shroominic
1daa5a9a6b add nostr lib 2025-09-04 15:29:28 +01:00
Shroominic
983ab04e6d update id to d_tag 2025-09-04 15:29:28 +01:00
Shroominic
f94035a74f refactor to nostr library and fixes 2025-09-04 15:29:28 +01:00
Shroominic
c6b0f4e46d add http to onion url 2025-09-04 15:29:28 +01:00
Shroominic
3368eb6ef2 skipping localhost:8000 provider 2025-09-04 15:29:28 +01:00
Shroominic
fa026ee580 dev version indicator 2025-09-04 15:29:28 +01:00
Shroominic
951eb32c2b cached /refund 2025-09-04 15:29:28 +01:00
Shroominic
124fbedf2a update core config docs 2025-09-04 15:29:06 +01:00
Shroominic
fa4b78abec azure openai docs 2025-09-04 15:29:06 +01:00
Shroominic
207cafe285 azure openai compatibility 2025-09-04 15:29:06 +01:00
shroominic
ad068877ac Merge pull request #155 from Routstr/cached-/refund
cached /refund
2025-09-04 14:19:53 +01:00
Shroominic
c0d115b1ab update providers bg task 2025-09-03 16:52:13 +01:00
Shroominic
e28ff2cc68 Merge branch 'cursor/discover-and-announce-routstr-providers-3b9a' into dev 2025-09-03 09:11:40 +01:00
Shroominic
f5ef3770f6 add tor url as default 2025-09-02 21:47:04 +01:00
Shroominic
8e4a08e969 fix event replacement 2025-09-02 15:33:25 +01:00
Shroominic
4b40e9010d correct env var names 2025-09-01 21:42:25 +01:00
Shroominic
0fc4e3cb97 fix logging 2025-09-01 21:41:38 +01:00
Shroominic
b87a3fd88c rm readme section 2025-08-29 13:09:56 -03:00
Shroominic
8f9730d87f use partial pubkey for d tag 2025-08-29 13:09:07 -03:00
Shroominic
8f6f1d6d22 fix env vars + open timeout 2025-08-29 13:04:42 -03:00
Shroominic
3834720333 fix env vars 2025-08-29 13:03:44 -03:00
Shroominic
501b6e6292 fix provider discovery 2025-08-29 12:22:35 -03:00
Shroominic
828e7ced10 fix publishing 2025-08-29 12:22:11 -03:00
Shroominic
7e988f94e9 Merge branch 'main' into cursor/discover-and-announce-routstr-providers-3b9a 2025-08-29 11:40:26 -03:00
Shroominic
8462b64f79 cached /refund 2025-08-25 15:03:43 -03:00
shroominic
65084b1783 Merge pull request #154 from Routstr/cursor/document-entire-repository-for-users-and-contributors-43ef
mkdocs build fix
2025-08-25 02:15:32 -03:00
Shroominic
2311aa981e fix 2025-08-25 02:13:40 -03:00
shroominic
56485bffc3 Merge pull request #153 from Routstr/cursor/document-entire-repository-for-users-and-contributors-43ef
Document entire repository for users and contributors
2025-08-25 01:58:24 -03:00
Shroominic
f1d4dd6ff4 edits 2025-08-25 01:56:09 -03:00
Shroominic
11c8902dde edits 2025-08-25 01:48:46 -03:00
Shroominic
bedca877ce fmt 2025-08-25 01:10:10 -03:00
Shroominic
ea01122652 github pages 2025-08-25 01:09:44 -03:00
Shroominic
efa3e18bf9 routstr core docs 2025-08-25 01:09:39 -03:00
Shroominic
131ccdf5e4 coming soon 2025-08-25 01:09:27 -03:00
Shroominic
9622346cc2 todo 2025-08-25 01:09:20 -03:00
Shroominic
6595b30701 fix 2025-08-25 01:09:16 -03:00
Cursor Agent
d4c1389249 docs: Add comprehensive documentation for Routstr Core project
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-25 01:17:40 +00:00
Shroominic
9ae0e4a238 discovery wip 2025-08-18 12:45:57 -03:00
Shroominic
5e442d139c Merge remote-tracking branch 'origin/main' into cursor/discover-and-announce-routstr-providers-3b9a 2025-08-18 12:44:07 -03:00
Shroominic
78c0ee73b7 Merge branch 'main' into cursor/discover-and-announce-routstr-providers-3b9a 2025-08-18 11:21:05 -03:00
Cursor Agent
dedb519d9a Remove RIP-02 support, focus on NIP-91 provider discovery
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 19:06:01 +00:00
Cursor Agent
2a934dbda4 Support RIP-02 alongside NIP-91 for provider discovery protocol
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:50:54 +00:00
Cursor Agent
6b1f432c58 Remove RIP-02 support, migrate to NIP-91 provider discovery protocol
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:48:09 +00:00
Cursor Agent
8dc683e6ed Update provider discovery and test data to match RIP-02 event structure
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:18:32 +00:00
Cursor Agent
dff80b9e5d Add NIP-91 provider discovery and auto-announcement support
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:08:24 +00:00
64 changed files with 12316 additions and 1322 deletions

View File

@@ -1,40 +1,39 @@
# NAME = "Your Routstr Proxy Name"
# DESCRIPTION = "A short Description"
# Core Configuration
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=your-upstream-api-key
# Any openai-compatible api endpoint
UPSTREAM_BASE_URL="https://api.openai.com/v1"
UPSTREAM_API_KEY="sk-21212121212121212121212121212121"
# UPSTREAM_PROVIDER_FEE=1 # 1 = no fees, 1.05 = 5% fees
# ADMIN_PASSWORD=secure-admin-password
# Lightning address used to receive funds
# Database
# DATABASE_URL=sqlite+aiosqlite:///keys.db
# When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS.
# RECEIVE_LN_ADDRESS="user@minibits.cash"
#MINIMUM_PAYOUT = "100"
# Node Information
# 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=
# If set to true, pricing is loaded from the file specified by MODELS_PATH
# Defaults to "models.json" and falls back to "models.example.json" if missing
# MODEL_BASED_PRICING = "true"
# MODELS_PATH="models.json"
# 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
# Costs in Sats, if MODEL_BASED_PRICING is set to false
# COST_PER_REQUEST="10"
# COST_PER_1K_INPUT_TOKENS = "0"
# COST_PER_1K_OUTPUT_TOKENS = "0"
# EXCHANGE_FEE = "1.005" # 0.5 % currency exchange fee
# Network Configuration
# CORS_ORIGINS=*
# TOR_PROXY_URL=socks5://127.0.0.1:9050
# password used to log into admin interface
# ADMIN_PASSWORD=""
# Logging
# LOG_LEVEL=INFO
# ENABLE_CONSOLE_LOGGING=true
# Public Endpoint
# HTTP_URL="https://your.domain.com"
# Tor Endpoint (copy from docker logs)
# ONION_URL=".onion"
# RELAYS="wss://relay.routstr.com,wss://relay.nostr.band"
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
# Development
# DEBUG=TRUE
# LOG_LEVEL=TRACE
# Custom Model Management
# BASE_URL=https://openrouter.ai/api/v1
# MODELS_PATH=models.json
# SOURCE=

56
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Deploy MkDocs to GitHub Pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "mkdocs.yml"
- ".github/workflows/docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r docs/requirements.txt
- name: Build MkDocs site
run: mkdocs build --strict --verbose
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./site
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -35,12 +35,15 @@ jobs:
run: |
uv run mypy .
- name: Run tests with pytest
- name: Run tests with pytest (with coverage & junit)
env:
UPSTREAM_BASE_URL: "http://test"
UPSTREAM_API_KEY: "test"
run: |
uv run pytest --verbose --tb=short
uv run pytest \
--verbose --tb=short \
--junitxml=pytest.xml \
--cov=routstr --cov-report=term
- name: Upload test results
if: always()

View File

@@ -244,3 +244,20 @@ profile:
@echo "🔥 Running with profiling..."
$(PYTHON) -m cProfile -o profile.stats -m pytest tests/integration/test_performance_load.py::TestPerformanceBaseline -v
@echo "Profile saved to profile.stats. Use '$(PYTHON) -m pstats profile.stats' to analyze."
# Documentation
docs-build:
@echo "📚 Building documentation..."
mkdocs build
docs-serve:
@echo "📚 Serving documentation at http://localhost:8001..."
mkdocs serve -a localhost:8001
docs-deploy:
@echo "📚 Deploying documentation to GitHub Pages..."
mkdocs gh-deploy --force
docs-install:
@echo "📚 Installing documentation dependencies..."
pip install -r docs/requirements.txt

View File

@@ -33,7 +33,8 @@ sequenceDiagram
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays
- **Discovery** Fetch available providers from Nostr relays using NIP-91 protocol
- **NIP-91 Auto-Announcement** Automatically announce this provider to Nostr relays when NSEC is provided
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
## Getting Started
@@ -86,9 +87,11 @@ This builds the image and also starts a Tor container exposing the API as a hidd
The most common settings are shown below. See `.env.example` for the full list.
### Core Settings
- `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

28
build-docs.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Build script for MkDocs documentation
echo "Building Routstr Core documentation..."
# Check if mkdocs is installed
if ! command -v mkdocs &> /dev/null; then
echo "MkDocs not found. Installing dependencies..."
pip install -r docs/requirements.txt
fi
# Build the documentation
echo "Building docs..."
mkdocs build
# Serve locally for preview (optional)
if [ "$1" = "serve" ]; then
echo "Starting documentation server at http://localhost:8001"
mkdocs serve -a localhost:8001
elif [ "$1" = "deploy" ]; then
echo "Deploying to GitHub Pages..."
mkdocs gh-deploy --force
else
echo "Documentation built successfully in ./site/"
echo "Run './build-docs.sh serve' to preview locally"
echo "Run './build-docs.sh deploy' to deploy to GitHub Pages"
fi

View File

@@ -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"

View File

@@ -1,11 +1,10 @@
version: '3.8'
services:
routstr:
build: .
volumes:
- .:/app
- ./logs:/app/logs
- tor-data:/var/lib/tor:ro
env_file:
- .env
environment:
@@ -25,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:

View File

@@ -0,0 +1,567 @@
# Custom Pricing
This guide covers advanced pricing strategies and customization options for Routstr Core.
## Pricing Models Overview
Routstr supports three pricing models:
1. **Fixed Pricing** - Simple per-request fee
2. **Token-Based Pricing** - Charge per input/output token
3. **Model-Based Pricing** - Dynamic pricing from models.json
## Model-Based Pricing
### Configuration
Enable model-based pricing (default behavior):
```bash
# .env
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
```
### Custom Models File
Create a `models.json` with your pricing:
```json
{
"models": [
{
"id": "gpt-4",
"name": "GPT-4",
"description": "Advanced reasoning model",
"context_length": 8192,
"pricing": {
"prompt": "0.03", // USD per 1K tokens
"completion": "0.06", // USD per 1K tokens
"request": "0.0001", // Fixed per-request fee
"image": "0", // For multimodal models
"web_search": "0.005", // Additional features
"internal_reasoning": "0.01"
},
"supported_features": [
"function_calling",
"vision",
"json_mode"
],
"deprecation_date": null,
"replacement_model": null
},
{
"id": "custom-model",
"name": "Custom Fine-tuned Model",
"pricing": {
"prompt": "0.001",
"completion": "0.002",
"request": "0.00005"
},
"minimum_charge": "0.0001" // Minimum charge per request
}
],
"default_pricing": {
"prompt": "0.002",
"completion": "0.002",
"request": "0"
}
}
```
### Dynamic Price Updates
Automatically fetch prices from providers:
```python
# scripts/update_prices.py
import asyncio
import httpx
import json
async def fetch_openrouter_models():
"""Fetch current model pricing from OpenRouter."""
async with httpx.AsyncClient() as client:
response = await client.get(
"https://openrouter.ai/api/v1/models"
)
return response.json()
async def update_models_json():
"""Update local models.json with latest prices."""
data = await fetch_openrouter_models()
models = []
for model in data['data']:
models.append({
"id": model['id'],
"name": model['name'],
"pricing": {
"prompt": model['pricing']['prompt'],
"completion": model['pricing']['completion'],
"request": model['pricing'].get('request', '0')
},
"context_length": model.get('context_length', 4096)
})
with open('models.json', 'w') as f:
json.dump({"models": models}, f, indent=2)
# Run periodically
if __name__ == "__main__":
asyncio.run(update_models_json())
```
## Token-Based Pricing
### Configuration
Set up token-based pricing overrides:
```bash
# .env
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
Override default token counting:
```python
from tiktoken import encoding_for_model
class CustomTokenCounter:
def __init__(self):
self.encodings = {}
def count_tokens(
self,
text: str,
model: str
) -> int:
"""Custom token counting logic."""
# Cache encodings
if model not in self.encodings:
try:
self.encodings[model] = encoding_for_model(model)
except:
# Fallback encoding
self.encodings[model] = encoding_for_model("gpt-3.5-turbo")
encoding = self.encodings[model]
# Special handling for certain content
if text.startswith("```"):
# Code blocks might need special handling
tokens = encoding.encode(text)
return len(tokens) * 1.1 # 10% markup for code
return len(encoding.encode(text))
```
## Advanced Pricing Strategies
### Time-Based Pricing
Implement peak/off-peak pricing:
```python
from datetime import datetime
import pytz
class TimeBased PricingStrategy:
def __init__(self):
self.timezone = pytz.timezone('US/Eastern')
self.peak_hours = [(9, 17)] # 9 AM - 5 PM
self.peak_multiplier = 1.5
self.weekend_discount = 0.8
def get_price_multiplier(self) -> float:
"""Calculate price multiplier based on time."""
now = datetime.now(self.timezone)
# Weekend discount
if now.weekday() >= 5: # Saturday or Sunday
return self.weekend_discount
# Peak hours surcharge
hour = now.hour
for start, end in self.peak_hours:
if start <= hour < end:
return self.peak_multiplier
# Off-peak standard pricing
return 1.0
def apply_to_cost(self, base_cost: int) -> int:
"""Apply time-based pricing to cost."""
multiplier = self.get_price_multiplier()
return int(base_cost * multiplier)
```
### Model-Specific Surcharges
Add custom fees for specific models:
```python
class ModelSurchargeStrategy:
def __init__(self):
self.surcharges = {
"gpt-4-turbo": 1.1, # 10% premium
"claude-3-opus": 1.15, # 15% premium
"dall-e-3-hd": 1.25, # 25% premium for HD
}
self.discounts = {
"gpt-3.5-turbo": 0.95, # 5% discount
"deprecated-model": 0.8, # 20% discount
}
def get_model_multiplier(self, model: str) -> float:
"""Get price multiplier for model."""
if model in self.surcharges:
return self.surcharges[model]
elif model in self.discounts:
return self.discounts[model]
return 1.0
```
### Geographic Pricing
Adjust pricing based on client location:
```python
import geoip2.database
class GeographicPricingStrategy:
def __init__(self):
self.reader = geoip2.database.Reader('GeoLite2-Country.mmdb')
self.country_multipliers = {
'US': 1.0,
'GB': 1.0,
'DE': 1.0,
'IN': 0.7, # 30% discount
'BR': 0.8, # 20% discount
'NG': 0.6, # 40% discount
}
self.default_multiplier = 0.9
def get_country_multiplier(self, ip_address: str) -> float:
"""Get price multiplier based on country."""
try:
response = self.reader.country(ip_address)
country_code = response.country.iso_code
return self.country_multipliers.get(
country_code,
self.default_multiplier
)
except:
return 1.0 # Default pricing if lookup fails
```
## Cost Calculation Pipeline
### Implementing Custom Calculator
```python
from abc import ABC, abstractmethod
class CostCalculator(ABC):
@abstractmethod
async def calculate(
self,
request_data: dict,
usage_data: dict,
context: dict
) -> CostResult:
pass
class CompositeCostCalculator(CostCalculator):
"""Combine multiple pricing strategies."""
def __init__(self):
self.strategies = [
BaseCostCalculator(),
TimeBasedPricingStrategy(),
ModelSurchargeStrategy(),
GeographicPricingStrategy()
]
async def calculate(
self,
request_data: dict,
usage_data: dict,
context: dict
) -> CostResult:
# Start with base cost
base_cost = await self.strategies[0].calculate(
request_data, usage_data, context
)
# Apply each strategy
final_cost = base_cost.total_msats
breakdown = {"base": base_cost.total_msats}
for strategy in self.strategies[1:]:
multiplier = await strategy.get_multiplier(context)
adjustment = final_cost * (multiplier - 1)
final_cost += adjustment
breakdown[strategy.__class__.__name__] = adjustment
return CostResult(
total_msats=int(final_cost),
breakdown=breakdown
)
```
### Integration with Routstr
```python
# In routstr/payment/cost_calculation.py
async def calculate_request_cost(
model: str,
prompt_tokens: int,
completion_tokens: int,
request_type: str,
context: dict
) -> CostData:
"""Enhanced cost calculation with custom strategies."""
# Use custom calculator if configured
if os.getenv("USE_CUSTOM_PRICING", "false").lower() == "true":
calculator = CompositeCostCalculator()
result = await calculator.calculate(
request_data={
"model": model,
"type": request_type
},
usage_data={
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens
},
context=context
)
return result
# Fall back to standard calculation
return standard_calculate_cost(...)
```
## Monitoring Pricing
### Price Analytics
Track pricing effectiveness:
```python
class PricingAnalytics:
async def analyze_pricing(
self,
start_date: datetime,
end_date: datetime
):
"""Analyze pricing performance."""
# Average cost per request by model
model_costs = await self.get_average_costs_by_model(
start_date, end_date
)
# Revenue by pricing strategy
strategy_revenue = await self.get_revenue_by_strategy(
start_date, end_date
)
# Price elasticity
elasticity = await self.calculate_price_elasticity()
return {
"model_costs": model_costs,
"strategy_revenue": strategy_revenue,
"price_elasticity": elasticity,
"recommendations": self.generate_recommendations(
model_costs, elasticity
)
}
```
### A/B Testing Prices
Test different pricing strategies:
```python
class PricingExperiment:
def __init__(self):
self.experiments = {
"exp_001": {
"name": "10% discount test",
"group_a": {"multiplier": 1.0},
"group_b": {"multiplier": 0.9},
"allocation": 0.5 # 50/50 split
}
}
def assign_group(self, api_key_id: int) -> str:
"""Assign API key to experiment group."""
# Consistent assignment based on key ID
import hashlib
hash_value = int(hashlib.md5(
str(api_key_id).encode()
).hexdigest()[:8], 16)
return "group_b" if (hash_value % 100) < 50 else "group_a"
def get_experiment_multiplier(
self,
api_key_id: int,
experiment_id: str
) -> float:
"""Get price multiplier for experiment."""
experiment = self.experiments.get(experiment_id)
if not experiment:
return 1.0
group = self.assign_group(api_key_id)
return experiment[group]["multiplier"]
```
## Configuration Examples
### Enterprise Pricing
```json
{
"models": [
{
"id": "gpt-4-enterprise",
"name": "GPT-4 Enterprise",
"pricing": {
"prompt": "0.02",
"completion": "0.04"
},
"minimum_commitment": "1000", // $1000/month minimum
"sla": {
"uptime": "99.9%",
"support_response": "1 hour",
"dedicated_capacity": true
}
}
],
"enterprise_features": {
"priority_queue": true,
"custom_models": true,
"audit_logs": true,
"sso": true
}
}
```
### Budget-Friendly Options
```json
{
"models": [
{
"id": "gpt-3.5-turbo-budget",
"name": "GPT-3.5 Turbo Budget",
"pricing": {
"prompt": "0.0005",
"completion": "0.001"
},
"restrictions": {
"max_tokens_per_request": 1000,
"requests_per_minute": 10,
"peak_hours_blocked": true
}
}
],
"prepaid_packages": [
{
"name": "Starter Pack",
"price_usd": 10,
"tokens_included": 10000000,
"expires_days": 30
}
]
}
```
## Troubleshooting
### Price Calculation Issues
```python
# Debug pricing
async def debug_price_calculation(
model: str,
tokens: dict,
api_key_id: int
):
"""Debug price calculation step by step."""
print(f"Model: {model}")
print(f"Tokens: {tokens}")
# Base price
base_price = get_model_price(model)
print(f"Base price: {base_price}")
# Token cost
token_cost = calculate_token_cost(base_price, tokens)
print(f"Token cost: {token_cost}")
# Strategies
strategies = get_active_strategies()
for strategy in strategies:
multiplier = await strategy.get_multiplier(api_key_id)
print(f"{strategy.name}: {multiplier}x")
# Final cost
final_cost = apply_all_strategies(token_cost, api_key_id)
print(f"Final cost: {final_cost} msats")
return final_cost
```
### Common Issues
1. **Prices Not Updating**
- Check `MODELS_PATH` is correct
- Verify file permissions
- Check background task logs
2. **Wrong Currency Conversion**
- Verify BTC/USD rate source
- Check `EXCHANGE_FEE` setting
- Monitor rate update frequency
3. **Discounts Not Applied**
- Verify strategy configuration
- Check API key metadata
- Review transaction history
## Best Practices
1. **Transparent Pricing**
- Publish pricing clearly
- Show cost breakdowns
- Notify of price changes
2. **Fair Pricing**
- Regular competitive analysis
- Consider user feedback
- Offer budget options
3. **Performance**
- Cache price calculations
- Optimize database queries
- Monitor calculation time
## Next Steps
- [Migrations](migrations.md) - Database migration guide
- [API Endpoints](../api/endpoints.md) - Pricing endpoints
- [Monitoring](../user-guide/admin-dashboard.md) - Track pricing metrics

646
docs/advanced/migrations.md Normal file
View File

@@ -0,0 +1,646 @@
# Database Migrations
This guide covers database schema management using Alembic migrations in Routstr Core.
## Overview
Routstr uses Alembic for database migrations with these features:
- **Automatic migrations** on startup
- **Version control** for schema changes
- **Rollback capability** for safety
- **Support for multiple databases** (SQLite, PostgreSQL)
## Automatic Migrations
### Startup Behavior
Migrations run automatically when Routstr starts:
```python
# In routstr/core/main.py
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Running database migrations")
run_migrations() # Automatic migration
await init_db() # Initialize connection pool
# ... rest of startup
```
This ensures:
- ✅ Database is always up-to-date
- ✅ No manual migration steps in production
- ✅ Zero-downtime deployments
- ✅ Backwards compatibility
### Migration Safety
Migrations are designed to be safe:
- Idempotent (can run multiple times)
- Non-destructive by default
- Tested before release
- Reversible when possible
## Creating Migrations
### Auto-generating from Models
After modifying SQLModel classes:
```bash
# Generate migration from model changes
make db-migrate
# You'll be prompted for a description
Enter migration message: Add user preferences table
# Review generated file
cat migrations/versions/xxxx_add_user_preferences_table.py
```
### Manual Migrations
For complex changes, create manually:
```bash
# Create empty migration
alembic revision -m "Complex data transformation"
# Edit the generated file
vim migrations/versions/xxxx_complex_data_transformation.py
```
### Migration Template
```python
"""Add user preferences table
Revision ID: a1b2c3d4e5f6
Revises: f6e5d4c3b2a1
Create Date: 2024-01-15 10:30:00.123456
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers
revision = 'a1b2c3d4e5f6'
down_revision = 'f6e5d4c3b2a1'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Apply migration."""
# Create new table
op.create_table(
'userpreferences',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('api_key_id', sa.Integer(), nullable=False),
sa.Column('theme', sa.String(), nullable=True),
sa.Column('notifications_enabled', sa.Boolean(), default=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['api_key_id'], ['apikey.id'], )
)
# Create index
op.create_index(
'ix_userpreferences_api_key_id',
'userpreferences',
['api_key_id']
)
def downgrade() -> None:
"""Revert migration."""
op.drop_index('ix_userpreferences_api_key_id', table_name='userpreferences')
op.drop_table('userpreferences')
```
## Common Migration Patterns
### Adding Columns
Add column with default value:
```python
def upgrade():
# Add nullable column first
op.add_column(
'apikey',
sa.Column('last_rotation', sa.DateTime(), nullable=True)
)
# Populate existing rows
connection = op.get_bind()
connection.execute(
"UPDATE apikey SET last_rotation = created_at WHERE last_rotation IS NULL"
)
# Make non-nullable if needed
op.alter_column('apikey', 'last_rotation', nullable=False)
```
### Renaming Columns
Safe column rename:
```python
def upgrade():
# SQLite doesn't support ALTER COLUMN, so we need a workaround
with op.batch_alter_table('apikey') as batch_op:
batch_op.alter_column('old_name', new_column_name='new_name')
```
### Adding Indexes
Performance-improving indexes:
```python
def upgrade():
# Single column index
op.create_index(
'ix_transaction_timestamp',
'transaction',
['timestamp']
)
# Composite index
op.create_index(
'ix_transaction_key_time',
'transaction',
['api_key_id', 'timestamp']
)
# Partial index (PostgreSQL only)
op.create_index(
'ix_apikey_active',
'apikey',
['balance'],
postgresql_where='balance > 0'
)
```
### Data Migrations
Transform existing data:
```python
def upgrade():
# Add new column
op.add_column(
'apikey',
sa.Column('key_type', sa.String(), nullable=True)
)
# Migrate data
connection = op.get_bind()
result = connection.execute('SELECT id, metadata FROM apikey')
for row in result:
key_type = 'premium' if row.metadata.get('premium') else 'standard'
connection.execute(
f"UPDATE apikey SET key_type = '{key_type}' WHERE id = {row.id}"
)
# Make column non-nullable
op.alter_column('apikey', 'key_type', nullable=False)
```
### Enum Types
Add enum column:
```python
from enum import Enum
class KeyStatus(str, Enum):
ACTIVE = "active"
SUSPENDED = "suspended"
EXPIRED = "expired"
def upgrade():
# Create enum type (PostgreSQL)
key_status_enum = sa.Enum(KeyStatus, name='keystatus')
key_status_enum.create(op.get_bind(), checkfirst=True)
# Add column
op.add_column(
'apikey',
sa.Column(
'status',
key_status_enum,
nullable=False,
server_default='active'
)
)
```
## Database-Specific Considerations
### SQLite Limitations
SQLite has limitations requiring workarounds:
```python
def upgrade():
# SQLite doesn't support ALTER COLUMN directly
# Use batch_alter_table for compatibility
with op.batch_alter_table('apikey') as batch_op:
batch_op.alter_column(
'balance',
type_=sa.BigInteger(), # Change from Integer
existing_type=sa.Integer()
)
```
### PostgreSQL Features
Leverage PostgreSQL-specific features:
```python
def upgrade():
# Use JSONB for better performance
op.add_column(
'apikey',
sa.Column('metadata', sa.JSON().with_variant(
sa.dialects.postgresql.JSONB(), 'postgresql'
))
)
# Add GIN index for JSONB queries
op.create_index(
'ix_apikey_metadata',
'apikey',
['metadata'],
postgresql_using='gin'
)
# Add check constraint
op.create_check_constraint(
'ck_apikey_balance_positive',
'apikey',
'balance >= 0'
)
```
## Migration Commands
### Running Migrations
```bash
# Apply all pending migrations
make db-upgrade
# Upgrade to specific revision
alembic upgrade a1b2c3d4e5f6
# Upgrade one revision
alembic upgrade +1
```
### Checking Status
```bash
# Show current revision
make db-current
# Output: a1b2c3d4e5f6 (head)
# Show migration history
make db-history
# Output:
# a1b2c3d4e5f6 -> b2c3d4e5f6a7 (head), Add user preferences
# f6e5d4c3b2a1 -> a1b2c3d4e5f6, Add indexes
# e5d4c3b2a1f6 -> f6e5d4c3b2a1, Initial schema
```
### Rolling Back
```bash
# Rollback one migration
make db-downgrade
# Rollback to specific revision
alembic downgrade f6e5d4c3b2a1
# Rollback all (dangerous!)
alembic downgrade base
```
## Testing Migrations
### Unit Testing
Test migrations in isolation:
```python
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
def test_migration_add_user_preferences():
"""Test user preferences migration."""
# Create test database
engine = create_engine("sqlite:///:memory:")
# Run migrations up to previous version
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
command.upgrade(alembic_cfg, "f6e5d4c3b2a1")
# Verify state before migration
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "userpreferences" not in tables
# Run target migration
command.upgrade(alembic_cfg, "a1b2c3d4e5f6")
# Verify state after migration
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "userpreferences" in tables
# Check columns
columns = {col['name'] for col in inspector.get_columns('userpreferences')}
assert columns == {'id', 'api_key_id', 'theme', 'notifications_enabled', 'created_at'}
# Test downgrade
command.downgrade(alembic_cfg, "f6e5d4c3b2a1")
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "userpreferences" not in tables
```
### Integration Testing
Test with real data:
```python
async def test_migration_with_data():
"""Test migration preserves existing data."""
# Setup test database with data
async with test_engine.begin() as conn:
# Insert test data
await conn.execute(
"INSERT INTO apikey (key_hash, balance) VALUES ('test', 1000)"
)
# Run migration
run_migrations()
# Verify data integrity
async with test_engine.connect() as conn:
result = await conn.execute("SELECT * FROM apikey WHERE key_hash = 'test'")
row = result.first()
assert row.balance == 1000
assert row.key_type == 'standard' # New column with default
```
## Production Deployment
### Zero-Downtime Migrations
Strategy for seamless updates:
1. **Make migrations backwards compatible**
```python
# Good: Add nullable column
op.add_column('apikey', sa.Column('new_field', sa.String(), nullable=True))
# Bad: Drop column immediately
# op.drop_column('apikey', 'old_field')
```
2. **Deploy in phases**
```bash
# Phase 1: Deploy code that works with both schemas
# Phase 2: Run migration
# Phase 3: Deploy code that requires new schema
# Phase 4: Clean up deprecated columns
```
3. **Use feature flags**
```python
if feature_enabled('use_new_schema'):
# Use new column
query = select(APIKey.new_field)
else:
# Use old column
query = select(APIKey.old_field)
```
### Migration Monitoring
Track migration execution:
```python
# Add to migration
def upgrade():
start_time = time.time()
logger.info(f"Starting migration {revision}")
try:
# Migration logic here
op.create_table(...)
duration = time.time() - start_time
logger.info(f"Migration {revision} completed in {duration:.2f}s")
except Exception as e:
logger.error(f"Migration {revision} failed: {e}")
raise
```
### Backup Before Migration
Always backup before major changes:
```bash
#!/bin/bash
# backup_before_migration.sh
# Backup database
if [[ "$DATABASE_URL" == *"sqlite"* ]]; then
cp database.db "backup_$(date +%Y%m%d_%H%M%S).db"
else
pg_dump $DATABASE_URL > "backup_$(date +%Y%m%d_%H%M%S).sql"
fi
# Run migration
alembic upgrade head
# Verify
alembic current
```
## Troubleshooting
### Common Issues
**Migration Conflicts**
```bash
# Multiple heads detected
alembic heads
# a1b2c3d4e5f6 (head)
# b2c3d4e5f6a7 (head)
# Merge heads
alembic merge -m "Merge migrations" a1b2c3 b2c3d4
```
**Failed Migration**
```python
# Add rollback logic
def upgrade():
try:
op.create_table(...)
except Exception as e:
# Clean up partial changes
op.drop_table('partial_table', checkfirst=True)
raise
def downgrade():
# Ensure clean rollback
op.drop_table('new_table', checkfirst=True)
```
**Lock Timeout**
```python
# Add timeout handling
def upgrade():
connection = op.get_bind()
# Set timeout (PostgreSQL)
connection.execute("SET lock_timeout = '10s'")
try:
op.add_column(...)
except OperationalError as e:
if 'lock timeout' in str(e):
logger.error("Migration failed due to lock timeout")
raise
```
### Recovery Procedures
If migration fails in production:
1. **Check current state**
```bash
alembic current
alembic history
```
2. **Manual rollback if needed**
```sql
-- Check migration table
SELECT * FROM alembic_version;
-- Force version if necessary
UPDATE alembic_version SET version_num = 'previous_version';
```
3. **Fix and retry**
```bash
# Fix migration file
vim migrations/versions/problematic_migration.py
# Retry
alembic upgrade head
```
## Best Practices
### Migration Guidelines
1. **Keep migrations small and focused**
- One logical change per migration
- Easier to review and rollback
2. **Test migrations thoroughly**
- Test upgrade and downgrade
- Test with production-like data
- Test database-specific features
3. **Document breaking changes**
```python
"""BREAKING: Change balance column type
This migration requires application update.
Deploy order:
1. Update application to handle both int and bigint
2. Run this migration
3. Update application to use only bigint
"""
```
4. **Make migrations idempotent**
```python
def upgrade():
# Check if column exists
inspector = inspect(op.get_bind())
columns = [col['name'] for col in inspector.get_columns('apikey')]
if 'new_column' not in columns:
op.add_column(
'apikey',
sa.Column('new_column', sa.String())
)
```
### Performance Considerations
1. **Add indexes concurrently (PostgreSQL)**
```python
def upgrade():
# Create index without locking table
op.create_index(
'ix_large_table_column',
'large_table',
['column'],
postgresql_concurrently=True
)
```
2. **Batch large updates**
```python
def upgrade():
connection = op.get_bind()
# Process in batches
batch_size = 1000
offset = 0
while True:
result = connection.execute(
f"UPDATE apikey SET processed = true "
f"WHERE id IN (SELECT id FROM apikey WHERE processed = false LIMIT {batch_size})"
)
if result.rowcount == 0:
break
offset += batch_size
time.sleep(0.1) # Prevent overload
```
## Next Steps
- [Testing Guide](../contributing/testing.md) - Testing migrations
- [Deployment](../getting-started/docker.md) - Production deployment

598
docs/advanced/nostr.md Normal file
View File

@@ -0,0 +1,598 @@
# Nostr Discovery
Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays) for decentralized provider discovery. This enables users to find Routstr nodes without relying on centralized directories.
## Overview
Nostr integration provides:
- **Decentralized Discovery**: Find providers through relay network
- **Cryptographic Identity**: Providers identified by public keys
- **Real-time Updates**: Live provider status and pricing
- **Censorship Resistance**: No central point of control
## How It Works
```mermaid
graph LR
A[Routstr Node] --> B[Nostr Relay]
B --> C[Nostr Relay]
B --> D[Nostr Relay]
E[User Client] --> B
E --> C
E --> D
B --> F[Provider List]
C --> F
D --> F
```
Providers announce themselves by publishing signed events to Nostr relays. Clients can query these relays to discover available providers.
## Provider Configuration
### Setting Up Nostr Identity
1. **Generate Nostr Keys**
```bash
# Using nostril or similar tool
nostril --generate-keypair
# Output:
# Private key (nsec): nsec1abc...
# Public key (npub): npub1xyz...
```
2. **Configure Environment**
```bash
# .env
NPUB=npub1xyz... # Your public key
NSEC=nsec1abc... # Your private key (keep secret!)
NAME=Lightning AI Gateway
DESCRIPTION=Fast and reliable AI API with Bitcoin payments
HTTP_URL=https://api.lightning-ai.com
ONION_URL=http://lightningai.onion
```
### Publishing to Nostr
Routstr automatically publishes provider information to configured relays:
```python
# Published event structure (NIP-89)
{
"kind": 31990, # Application handler event
"pubkey": "your_public_key",
"content": {
"name": "Lightning AI Gateway",
"description": "Fast and reliable AI API",
"endpoints": {
"http": "https://api.lightning-ai.com",
"onion": "http://lightningai.onion"
},
"models": ["gpt-3.5-turbo", "gpt-4", "claude-3"],
"pricing": {
"gpt-3.5-turbo": {
"prompt_sats_per_1k": 3,
"completion_sats_per_1k": 4
}
},
"cashu_mints": [
"https://mint.minibits.cash/Bitcoin"
]
},
"tags": [
["d", "routstr"],
["t", "ai-api"],
["t", "bitcoin"],
["p", "payment-proxy"]
]
}
```
### Relay Configuration
Configure which relays to publish to:
```python
# Default relays
DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://relay.routstr.com",
"wss://nos.lol"
]
# Custom relay configuration
RELAYS=wss://relay1.com,wss://relay2.com
```
## Client Discovery
### Using the Discovery Endpoint
Find providers through the API:
```bash
GET /v1/providers
Response:
{
"providers": [
{
"name": "Lightning AI Gateway",
"npub": "npub1xyz...",
"description": "Fast and reliable AI API",
"endpoints": {
"http": "https://api.lightning-ai.com",
"onion": "http://lightningai.onion"
},
"models": ["gpt-3.5-turbo", "gpt-4"],
"pricing": {
"gpt-3.5-turbo": {
"prompt_sats_per_1k": 3,
"completion_sats_per_1k": 4
}
},
"last_seen": "2024-01-01T12:00:00Z",
"reliability_score": 0.99
}
]
}
```
### Direct Nostr Queries
Query Nostr relays directly:
```python
import json
import websocket
def discover_providers(relay_url: str):
"""Discover Routstr providers from Nostr relay."""
ws = websocket.create_connection(relay_url)
# Subscribe to provider events
subscription = {
"kinds": [31990],
"tags": {
"d": ["routstr"]
}
}
ws.send(json.dumps(["REQ", "sub1", subscription]))
providers = []
while True:
response = json.loads(ws.recv())
if response[0] == "EVENT":
event = response[2]
providers.append(parse_provider_event(event))
elif response[0] == "EOSE": # End of stored events
break
ws.close()
return providers
```
### JavaScript/TypeScript
```typescript
import { SimplePool } from 'nostr-tools';
async function discoverProviders(): Promise<Provider[]> {
const pool = new SimplePool();
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band'
];
const filter = {
kinds: [31990],
'#d': ['routstr']
};
const events = await pool.list(relays, [filter]);
return events.map(event => ({
name: event.content.name,
npub: nip19.npubEncode(event.pubkey),
url: event.content.endpoints.http,
models: event.content.models,
pricing: event.content.pricing
}));
}
```
## Provider Ranking
### Reliability Scoring
Providers are ranked based on:
```python
class ProviderScore:
def calculate(self, provider: Provider) -> float:
score = 1.0
# Uptime (based on recent checks)
uptime_ratio = provider.successful_pings / provider.total_pings
score *= uptime_ratio
# Response time
if provider.avg_response_time < 500: # ms
score *= 1.0
elif provider.avg_response_time < 1000:
score *= 0.9
else:
score *= 0.7
# Model availability
model_score = len(provider.models) / 10 # Max 10 models
score *= min(1.0, 0.5 + model_score * 0.5)
# Price competitiveness
if provider.is_cheapest_for_any_model():
score *= 1.1
return min(1.0, score)
```
### Provider Selection
Choose optimal provider:
```python
def select_provider(
providers: list[Provider],
model: str,
requirements: dict
) -> Provider:
"""Select best provider for requirements."""
# Filter by model availability
candidates = [p for p in providers if model in p.models]
# Filter by requirements
if requirements.get('tor_required'):
candidates = [p for p in candidates if p.onion_url]
if requirements.get('max_price_per_1k'):
max_price = requirements['max_price_per_1k']
candidates = [
p for p in candidates
if p.pricing[model]['prompt_sats_per_1k'] <= max_price
]
# Sort by score
candidates.sort(key=lambda p: p.reliability_score, reverse=True)
return candidates[0] if candidates else None
```
## Publishing Updates
### Automatic Updates
Routstr publishes updates when:
- Node starts up
- Configuration changes
- Models are added/removed
- Pricing updates
### Manual Publishing
Force publish current state:
```python
async def publish_provider_info():
"""Manually publish provider information."""
event = create_provider_event(
name=os.getenv("NAME"),
description=os.getenv("DESCRIPTION"),
models=get_available_models(),
pricing=get_current_pricing()
)
await publish_to_relays(event, RELAYS)
```
### Event Lifecycle
```python
# Publish every 6 hours
@periodic_task(hours=6)
async def update_nostr_presence():
"""Keep provider information fresh."""
try:
await publish_provider_info()
logger.info("Updated Nostr presence")
except Exception as e:
logger.error(f"Failed to update Nostr: {e}")
# Delete on shutdown
async def remove_nostr_presence():
"""Remove provider from discovery."""
deletion_event = create_deletion_event()
await publish_to_relays(deletion_event, RELAYS)
```
## Security Considerations
### Key Management
1. **Secure Storage**
```python
# Never log private keys
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
def sanitize_env(env_dict: dict) -> dict:
return {
k: '***' if k in SENSITIVE_VARS else v
for k, v in env_dict.items()
}
```
2. **Key Rotation**
```bash
# Generate new keys
nostril --generate-keypair
# Update configuration
# Publish transition event
# Update all references
```
### Event Validation
Verify provider events:
```python
def validate_provider_event(event: dict) -> bool:
"""Validate provider announcement."""
# Check signature
if not verify_signature(event):
return False
# Check required fields
required = ['name', 'endpoints', 'models', 'pricing']
content = json.loads(event['content'])
if not all(field in content for field in required):
return False
# Verify endpoints are reachable
if not await check_endpoints(content['endpoints']):
return False
return True
```
### Relay Security
Choose relays carefully:
```python
TRUSTED_RELAYS = {
'wss://relay.damus.io': {
'operator': 'Damus',
'reputation': 'high',
'filters_spam': True
},
'wss://relay.nostr.band': {
'operator': 'Nostr.Band',
'reputation': 'high',
'paid_tier': True
}
}
```
## Advanced Features
### Multi-Relay Broadcasting
Ensure wide distribution:
```python
async def broadcast_to_relays(event: dict, relays: list[str]):
"""Broadcast event to multiple relays."""
tasks = []
for relay in relays:
task = asyncio.create_task(
publish_to_relay(event, relay)
)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
successful = sum(1 for r in results if not isinstance(r, Exception))
logger.info(f"Published to {successful}/{len(relays)} relays")
```
### Provider Metadata
Extended metadata in events:
```json
{
"kind": 31990,
"content": {
"name": "Lightning AI",
"description": "Enterprise AI API",
"metadata": {
"established": "2024-01-01",
"total_requests": 1000000,
"average_response_ms": 250,
"supported_features": [
"streaming",
"function_calling",
"vision",
"embeddings"
],
"certifications": ["SOC2", "GDPR"],
"contact": {
"nostr": "npub1contact...",
"email": "support@lightning-ai.com"
}
}
}
}
```
### Discovery Filters
Advanced filtering options:
```python
# Find providers with specific features
GET /v1/providers?features=streaming,vision&max_price=5&min_reliability=0.95
# Response includes filtered results
{
"providers": [...],
"filters_applied": {
"features": ["streaming", "vision"],
"max_price_sats_per_1k": 5,
"min_reliability": 0.95
},
"total_providers": 50,
"matching_providers": 12
}
```
## Monitoring
### Discovery Metrics
Track discovery performance:
```python
class DiscoveryMetrics:
def __init__(self):
self.relay_health = {}
self.provider_count = 0
self.query_latency = []
async def check_relay_health(self, relay_url: str):
"""Monitor relay connectivity."""
start = time.time()
try:
await connect_to_relay(relay_url)
latency = time.time() - start
self.relay_health[relay_url] = {
'status': 'healthy',
'latency_ms': latency * 1000
}
except Exception as e:
self.relay_health[relay_url] = {
'status': 'unhealthy',
'error': str(e)
}
```
### Provider Monitoring
```python
@periodic_task(minutes=5)
async def monitor_providers():
"""Check provider health."""
providers = await discover_providers()
for provider in providers:
try:
# Test endpoint
response = await test_provider_endpoint(provider.http_url)
# Update metrics
await update_provider_metrics(
provider.npub,
success=response.status_code == 200,
response_time=response.elapsed
)
except Exception as e:
logger.warning(f"Provider {provider.name} check failed: {e}")
```
## Troubleshooting
### No Providers Found
```python
# Debug discovery issues
async def debug_discovery():
"""Diagnose discovery problems."""
issues = []
# Check relay connectivity
for relay in RELAYS:
if not await can_connect_to_relay(relay):
issues.append(f"Cannot connect to {relay}")
# Check event publishing
if not await verify_own_events_visible():
issues.append("Own events not visible on relays")
# Check filters
if len(await get_all_provider_events()) == 0:
issues.append("No provider events on any relay")
return issues
```
### Relay Connection Issues
```bash
# Test relay connection
wscat -c wss://relay.damus.io
# Send subscription
["REQ","test",{"kinds":[31990],"#d":["routstr"]}]
```
## Best Practices
### For Providers
1. **Consistent Identity**
- Use same npub across services
- Maintain profile metadata
- Verify identity on multiple platforms
2. **Regular Updates**
- Publish status every few hours
- Update pricing promptly
- Remove stale information
3. **Relay Diversity**
- Publish to 5+ relays
- Include regional relays
- Monitor relay health
### For Clients
1. **Verify Providers**
- Check multiple relays
- Verify endpoints work
- Monitor reliability over time
2. **Cache Discovery**
- Cache provider list
- Refresh periodically
- Handle stale data gracefully
3. **Fallback Options**
- Keep backup providers
- Handle discovery failures
- Support manual configuration
## Next Steps
- [Tor Support](tor.md) - Anonymous provider access
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
- [API Reference](../api/endpoints.md) - Discovery API details

530
docs/advanced/tor.md Normal file
View File

@@ -0,0 +1,530 @@
# Tor Support
Routstr Core includes built-in support for Tor hidden services, enabling anonymous access to your API and enhanced privacy for users.
## Overview
Tor support provides:
- **Anonymous Access**: Hidden service (.onion) address
- **Enhanced Privacy**: No IP address logging
- **Censorship Resistance**: Accessible from restricted networks
- **Optional Usage**: Regular HTTP/HTTPS access remains available
## Docker Setup
### Using Docker Compose
The included `compose.yml` automatically sets up Tor:
```yaml
version: '3.8'
services:
routstr:
build: .
environment:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- 8000:8000
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
volumes:
- tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
depends_on:
- routstr
volumes:
tor-data:
```
Start with:
```bash
docker compose up -d
```
### Getting Your Onion Address
After starting, retrieve your hidden service address:
```bash
# View Tor logs
docker compose logs tor
# Or directly from the hostname file
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
Your onion address will look like:
```
roustrjfsdgfiueghsklchg.onion
```
## Manual Tor Setup
### Install Tor
```bash
# Ubuntu/Debian
sudo apt-get install tor
# macOS
brew install tor
# Start Tor
sudo systemctl start tor
```
### Configure Hidden Service
Edit `/etc/tor/torrc`:
```bash
# Hidden service configuration
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
# Optional: Restrict to v3 addresses
HiddenServiceVersion 3
```
Restart Tor:
```bash
sudo systemctl restart tor
```
Get onion address:
```bash
sudo cat /var/lib/tor/routstr/hostname
```
## Client Configuration
### Using Tor with Python
```python
import httpx
from openai import OpenAI
# Configure SOCKS proxy
proxies = {
"http://": "socks5://127.0.0.1:9050",
"https://": "socks5://127.0.0.1:9050"
}
# Create client with Tor
http_client = httpx.Client(proxies=proxies)
client = OpenAI(
api_key="sk-...",
base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
http_client=http_client
)
# Use normally
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello via Tor!"}]
)
```
### Using Tor with cURL
```bash
# Install torify
sudo apt-get install torsocks
# Make request through Tor
torify curl http://roustrjfsdgfiueghsklchg.onion/v1/models
# Or with explicit proxy
curl --socks5 127.0.0.1:9050 http://roustrjfsdgfiueghsklchg.onion/v1/models
```
### JavaScript/Node.js
```javascript
import { SocksProxyAgent } from 'socks-proxy-agent';
import OpenAI from 'openai';
// Create SOCKS agent
const agent = new SocksProxyAgent('socks5://127.0.0.1:9050');
// Configure OpenAI client
const openai = new OpenAI({
apiKey: 'sk-...',
baseURL: 'http://roustrjfsdgfiueghsklchg.onion/v1',
httpAgent: agent
});
// Use normally
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'Hello via Tor!' }]
});
```
## Configuration
### Environment Variables
Configure Tor proxy for outgoing connections:
```bash
# .env
TOR_PROXY_URL=socks5://tor:9050 # Docker
# or
TOR_PROXY_URL=socks5://127.0.0.1:9050 # Local
```
### Publishing Onion Address
Make your onion address discoverable:
```bash
# .env
ONION_URL=http://roustrjfsdgfiueghsklchg.onion
```
This will be included in:
- `/v1/info` endpoint
- Nostr announcements
- Admin dashboard
## Security Considerations
### Hidden Service Security
1. **Keep Private Key Secure**
```bash
# Backup hidden service keys
sudo tar -czf tor-keys-backup.tar.gz /var/lib/tor/routstr/
# Restore to maintain same address
sudo tar -xzf tor-keys-backup.tar.gz -C /
```
2. **Access Control**
```bash
# Restrict to authenticated clients
HiddenServiceAuthorizeClient stealth client1,client2
```
3. **Rate Limiting**
```bash
# In torrc
HiddenServiceMaxStreams 100
HiddenServiceMaxStreamsCloseCircuit 1
```
### Operational Security
1. **Separate Tor Instance**
```yaml
# Use dedicated Tor container
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
restart: always
networks:
- tor_network
```
2. **Monitor Tor Health**
```python
async def check_tor_connection():
"""Verify Tor connectivity."""
try:
async with httpx.AsyncClient(
proxies={"all://": TOR_PROXY_URL}
) as client:
response = await client.get(
"https://check.torproject.org/api/ip"
)
data = response.json()
return data.get("IsTor", False)
except Exception:
return False
```
3. **Logging Considerations**
```python
# Don't log .onion addresses with IPs
def sanitize_logs(message: str) -> str:
# Remove IP addresses when .onion is present
if ".onion" in message:
message = re.sub(r'\d+\.\d+\.\d+\.\d+', '[IP]', message)
return message
```
## Performance Optimization
### Connection Pooling
```python
# Reuse Tor circuits
class TorConnectionPool:
def __init__(self, proxy_url: str):
self.proxy_url = proxy_url
self._clients = []
async def get_client(self) -> httpx.AsyncClient:
if not self._clients:
client = httpx.AsyncClient(
proxies={"all://": self.proxy_url},
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(
max_keepalive_connections=5,
max_connections=10
)
)
self._clients.append(client)
return self._clients[0]
```
### Circuit Management
```python
# Rotate Tor circuits periodically
async def rotate_tor_circuit():
"""Signal Tor to create new circuit."""
async with httpx.AsyncClient() as client:
# Tor control port (requires configuration)
response = await client.post(
"http://localhost:9051",
data="AUTHENTICATE\r\nSIGNAL NEWNYM\r\n"
)
```
### Caching Strategies
```python
# Cache responses for Tor users
@lru_cache(maxsize=1000)
def get_cached_response(
endpoint: str,
params_hash: str
) -> Optional[dict]:
"""Cache frequently accessed data."""
# Longer cache for Tor users due to latency
return cache.get(f"tor:{endpoint}:{params_hash}")
```
## Monitoring
### Tor Metrics
Track Tor-specific metrics:
```python
class TorMetrics:
def __init__(self):
self.tor_requests = 0
self.tor_errors = 0
self.circuit_builds = 0
self.average_latency = 0
async def record_request(
self,
duration: float,
success: bool
):
self.tor_requests += 1
if not success:
self.tor_errors += 1
# Update average latency
self.average_latency = (
(self.average_latency * (self.tor_requests - 1) + duration)
/ self.tor_requests
)
```
### Health Checks
```python
@router.get("/health/tor")
async def tor_health():
"""Check Tor service health."""
checks = {
"tor_proxy": await check_tor_proxy(),
"hidden_service": await check_hidden_service(),
"circuit_established": await check_circuit()
}
status = "healthy" if all(checks.values()) else "unhealthy"
return {
"status": status,
"checks": checks,
"metrics": {
"tor_requests_total": metrics.tor_requests,
"tor_error_rate": metrics.tor_errors / max(metrics.tor_requests, 1),
"average_latency_ms": metrics.average_latency * 1000
}
}
```
## Troubleshooting
### Common Issues
**Hidden Service Not Accessible**
```bash
# Check Tor logs
docker compose logs tor
# or
sudo journalctl -u tor
# Verify service is running
sudo systemctl status tor
# Test locally
curl --socks5 127.0.0.1:9050 http://your-onion.onion/v1/info
```
**Slow Connection**
- Tor adds 3+ hops of latency
- Use connection pooling
- Implement aggressive caching
- Consider increasing timeouts
**Connection Errors**
```python
# Implement Tor-specific retry logic
async def tor_retry(func, max_retries=5):
for attempt in range(max_retries):
try:
return await func()
except httpx.ProxyError:
if attempt < max_retries - 1:
# Exponential backoff for circuit building
await asyncio.sleep(2 ** attempt)
else:
raise
```
### Debugging
Enable Tor debug logging:
```bash
# In torrc
Log debug file /var/log/tor/debug.log
# Monitor in real-time
tail -f /var/log/tor/debug.log
```
## Best Practices
### For Operators
1. **Backup Hidden Service Keys**
- Store securely offline
- Enables service recovery
- Maintains same .onion address
2. **Monitor Tor Health**
- Check circuit establishment
- Track request latency
- Alert on failures
3. **Separate Concerns**
- Run Tor in separate container
- Isolate from main application
- Use internal networks
### For Users
1. **Verify Onion Addresses**
- Check against multiple sources
- Bookmark verified addresses
- Watch for phishing
2. **Handle Higher Latency**
- Increase client timeouts
- Implement retries
- Use connection pooling
3. **Enhance Privacy**
- Use Tor Browser for web access
- Avoid mixing Tor/clearnet
- Don't include identifying info
## Advanced Configuration
### Multi-Hop Onion Services
For extra security, chain multiple Tor instances:
```yaml
# compose.yml
services:
tor-entry:
image: tor:latest
command: tor -f /etc/tor/torrc.entry
tor-middle:
image: tor:latest
command: tor -f /etc/tor/torrc.middle
tor-exit:
image: tor:latest
command: tor -f /etc/tor/torrc.exit
```
### Onion Service Authentication
Require client authorization:
```bash
# Generate client auth
openssl rand -base64 32 > client_auth_key
# In torrc
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
HiddenServiceAuthorizeClient stealth payments
```
### Load Balancing
Distribute load across multiple instances:
```nginx
# Onion service nginx config
upstream routstr_backends {
server routstr1:8000;
server routstr2:8000;
server routstr3:8000;
}
server {
listen 80;
location / {
proxy_pass http://routstr_backends;
}
}
```
## Next Steps
- [Nostr Discovery](nostr.md) - Announce your onion service
- [Docker Setup](../getting-started/docker.md) - Container configuration

428
docs/api/authentication.md Normal file
View File

@@ -0,0 +1,428 @@
# Authentication
Routstr uses API key authentication for all protected endpoints. This guide covers how to create, use, and manage API keys.
## API Key Creation
### From eCash Token
Create an API key by depositing an eCash token:
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API credentials in the Authorization header. The token is hashed on the server, and the hash acts as an API key with the token's balance.**
```bash
POST /v1/wallet/create
Content-Type: application/json
{
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
}
```
**Request Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cashu_token` | string | Yes | Base64-encoded Cashu token |
**Response:**
```json
{
"api_key": "sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p",
"balance": 10000,
"created_at": "2024-01-01T00:00:00Z",
"key_id": "key_123456"
}
```
### From Lightning Invoice (Coming Soon)
```bash
POST /v1/wallet/create/lightning
Content-Type: application/json
{
"amount_sats": 10000,
"name": "Lightning Key"
}
```
Response includes Lightning invoice for payment.
## Using API Keys
### Header Authentication
Include the API key in the Authorization header:
```bash
curl https://your-node.com/v1/chat/completions \
-H "Authorization: Bearer sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
```
### Query Parameter (Not Recommended)
For tools that don't support headers:
```bash
GET /v1/models?api_key=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
```
⚠️ **Warning**: Query parameters may be logged. Use headers when possible.
## Key Management
### Check Balance
Get current balance and usage statistics:
```bash
GET /v1/wallet/balance
Authorization: Bearer sk-...
Response:
{
"balance": 8546,
"total_deposited": 10000,
"total_spent": 1454,
"last_used": "2024-01-01T12:34:56Z",
"created_at": "2024-01-01T00:00:00Z",
"expires_at": null,
"key_info": {
"name": "Production Key",
"key_id": "key_123456"
}
}
```
### Top Up Balance
Add funds to existing key:
```bash
POST /v1/wallet/topup
Authorization: Bearer sk-...
Content-Type: application/json
{
"cashu_token": "cashuAeyJ0b2tlbiI6W3..."
}
Response:
{
"old_balance": 8546,
"added_amount": 5000,
"new_balance": 13546,
"transaction_id": "txn_789"
}
```
### List Transactions
View transaction history:
```bash
GET /v1/wallet/transactions?limit=10
Authorization: Bearer sk-...
Response:
{
"transactions": [
{
"id": "txn_123",
"type": "usage",
"amount": -154,
"balance_after": 8546,
"description": "gpt-3.5-turbo: 50 prompt + 150 completion tokens",
"timestamp": "2024-01-01T12:34:56Z"
},
{
"id": "txn_122",
"type": "deposit",
"amount": 10000,
"balance_after": 10000,
"description": "Initial deposit",
"timestamp": "2024-01-01T00:00:00Z"
}
],
"has_more": false,
"total": 2
}
```
## Security Best Practices
### API Key Storage
**Do:**
- Store keys in environment variables
- Use secret management systems
- Encrypt keys at rest
- Implement key rotation
**Don't:**
- Commit keys to version control
- Share keys between environments
- Log keys in plain text
- Expose keys in client-side code
### Environment Variables
```bash
# .env file
ROUTSTR_API_KEY=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
ROUTSTR_BASE_URL=https://your-node.com/v1
# Usage in code
import os
api_key = os.getenv("ROUTSTR_API_KEY")
```
### Key Rotation
Regularly rotate API keys:
```python
# 1. Create new key
new_key = create_api_key(balance=old_key_balance)
# 2. Update applications
update_environment_variable("ROUTSTR_API_KEY", new_key)
# 3. Test new key
test_api_connection(new_key)
# 4. Withdraw old key balance
withdraw_balance(old_key)
```
## Authentication Errors
### Invalid API Key
```json
{
"error": {
"type": "authentication_failed",
"message": "Invalid API key",
"code": "invalid_api_key"
}
}
```
**Status Code:** 401
**Common Causes:**
- Typo in API key
- Key doesn't exist
- Key has been deleted
### Expired API Key
```json
{
"error": {
"type": "authentication_failed",
"message": "API key has expired",
"code": "key_expired",
"details": {
"expired_at": "2024-01-01T00:00:00Z"
}
}
}
```
**Status Code:** 401
**Resolution:**
- Create a new API key
- Contact admin if refund address was set
### Insufficient Balance
```json
{
"error": {
"type": "insufficient_balance",
"message": "Insufficient balance for request",
"code": "payment_required",
"details": {
"balance": 100,
"required": 154,
"shortfall": 54
}
}
}
```
**Status Code:** 402
**Resolution:**
- Top up the API key balance
- Use a more economical model
- Optimize request parameters
## Advanced Authentication
### Per-Request Tokens (Coming Soon)
Pay per request without maintaining a balance:
```bash
curl https://your-node.com/v1/chat/completions \
-H "X-Cashu: cashuAeyJ0b2tlbiI6W3..." \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[...]}'
```
Response includes change:
```
X-Cashu: cashuAeyJjaGFuZ2UiOlt7...
```
### Multi-Key Authentication
Use multiple keys for different purposes:
```python
# Production key for main app
PROD_KEY = os.getenv("ROUTSTR_PROD_KEY")
# Development key for testing
DEV_KEY = os.getenv("ROUTSTR_DEV_KEY")
# Analytics key with restricted permissions
ANALYTICS_KEY = os.getenv("ROUTSTR_ANALYTICS_KEY")
# Choose key based on environment
api_key = PROD_KEY if is_production() else DEV_KEY
```
### Delegated Authentication
Create sub-keys with limited permissions:
```bash
POST /v1/wallet/create/subkey
Authorization: Bearer sk-parent-key
Content-Type: application/json
{
"name": "Limited Subkey",
"balance_limit": 1000,
"allowed_models": ["gpt-3.5-turbo"],
"expires_in_hours": 24
}
```
## Rate Limiting
Rate limits are applied per API key:
### Default Limits
| Metric | Limit | Window |
|--------|-------|--------|
| Requests | 1000 | 1 minute |
| Tokens | 1,000,000 | 1 hour |
| Concurrent | 10 | - |
### Rate Limit Headers
```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1640995200
X-RateLimit-Type: requests_per_minute
```
### Handling Rate Limits
```python
import time
from typing import Optional
def make_request_with_retry(
client,
max_retries: int = 3
) -> Optional[Response]:
for attempt in range(max_retries):
try:
response = client.chat.completions.create(...)
return response
except RateLimitError as e:
if attempt < max_retries - 1:
# Extract retry-after from error
retry_after = e.retry_after or 60
print(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
else:
raise
```
## IP Whitelisting
Restrict API key usage by IP:
```bash
POST /v1/wallet/update
Authorization: Bearer sk-...
Content-Type: application/json
{
"allowed_ips": [
"192.168.1.100",
"10.0.0.0/24"
]
}
```
## Monitoring
### Usage Alerts
Set up usage notifications:
```bash
POST /v1/wallet/alerts
Authorization: Bearer sk-...
Content-Type: application/json
{
"low_balance_threshold": 1000,
"daily_spend_limit": 5000,
"webhook_url": "https://your-app.com/webhook"
}
```
### Audit Logging
All API key usage is logged:
```json
{
"timestamp": "2024-01-01T12:34:56Z",
"api_key_id": "key_123456",
"endpoint": "/v1/chat/completions",
"method": "POST",
"ip_address": "192.168.1.100",
"user_agent": "OpenAI-Python/1.0",
"cost_sats": 154,
"response_status": 200
}
```
## Next Steps
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Using the API](../user-guide/using-api.md) - Integration examples

530
docs/api/endpoints.md Normal file
View File

@@ -0,0 +1,530 @@
# API Endpoints
Complete reference for all Routstr API endpoints.
## Overview
Routstr provides OpenAI-compatible endpoints with Bitcoin/eCash payment integration.
### Base URL
All endpoints use the base URL:
```text
https://api.routstr.com/v1
```
### Authentication
All endpoints require authentication via:
- **Bearer Token**: `Authorization: Bearer sk-...` or `Authorization: Bearer cashuAeyJ0...`
- **X-Cashu Header**: `X-Cashu: cashuAeyJ0...` (for direct eCash payments)
See [Authentication](authentication.md) for details.
## Chat
### Create Chat Completion
Send messages to generate model responses.
```http
POST /v1/chat/completions
```
**Request Body:**
```json
{
"model": "gpt-4",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Hello!"
}
],
"temperature": 0.7,
"stream": false
}
```
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `model` | string | Yes | - | Model ID to use |
| `messages` | array | Yes | - | Array of message objects |
| `temperature` | number | No | 1.0 | Sampling temperature (0-2) |
| `max_tokens` | integer | No | Model default | Maximum tokens to generate |
| `stream` | boolean | No | false | Stream partial responses |
| `top_p` | number | No | 1.0 | Nucleus sampling |
| `n` | integer | No | 1 | Number of completions |
| `stop` | string/array | No | null | Stop sequences |
| `presence_penalty` | number | No | 0 | Presence penalty (-2 to 2) |
| `frequency_penalty` | number | No | 0 | Frequency penalty (-2 to 2) |
**Response:**
```json
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you today?"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 13,
"completion_tokens": 9,
"total_tokens": 22
}
}
```
### Streaming Response
When `stream: true`:
```text
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
```
## Completions (Coming Soon)
### Create Completion
**Note: This endpoint is coming soon and not yet available.**
Generate text completion (legacy endpoint).
```http
POST /v1/completions
```
**Request Body:**
```json
{
"model": "gpt-3.5-turbo-instruct",
"prompt": "Once upon a time",
"max_tokens": 50,
"temperature": 0.7
}
```
**Response:**
```json
{
"id": "cmpl-123",
"object": "text_completion",
"created": 1677652288,
"model": "gpt-3.5-turbo-instruct",
"choices": [{
"text": " in a faraway land, there lived a brave knight...",
"index": 0,
"logprobs": null,
"finish_reason": "length"
}],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 50,
"total_tokens": 54
}
}
```
## Embeddings
### Create Embeddings (Coming Soon)
**Note: This endpoint is coming soon and not yet available.**
Generate vector representations of text.
```http
POST /v1/embeddings
```
**Request Body:**
```json
{
"model": "text-embedding-3-small",
"input": "The quick brown fox jumps over the lazy dog",
"encoding_format": "float"
}
```
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `model` | string | Yes | - | Embedding model ID |
| `input` | string/array | Yes | - | Text(s) to embed |
| `encoding_format` | string | No | "float" | Format: "float" or "base64" |
| `dimensions` | integer | No | Model default | Output dimensions |
**Response:**
```json
{
"object": "list",
"data": [{
"object": "embedding",
"index": 0,
"embedding": [0.0023064255, -0.009327292, ...]
}],
"model": "text-embedding-3-small",
"usage": {
"prompt_tokens": 9,
"total_tokens": 9
}
}
```
## Images (Coming Soon)
### Create Image
**Note: This endpoint is coming soon and not yet available.**
Generate images from text prompts.
```http
POST /v1/images/generations
```
**Request Body:**
```json
{
"model": "dall-e-3",
"prompt": "A white siamese cat wearing a space helmet",
"n": 1,
"size": "1024x1024",
"quality": "standard"
}
```
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `model` | string | Yes | - | Model: dall-e-2, dall-e-3 |
| `prompt` | string | Yes | - | Text description |
| `n` | integer | No | 1 | Number of images |
| `size` | string | No | "1024x1024" | Image dimensions |
| `quality` | string | No | "standard" | Quality: standard, hd |
| `style` | string | No | "vivid" | Style: vivid, natural |
| `response_format` | string | No | "url" | Format: url, b64_json |
**Response:**
```json
{
"created": 1677652288,
"data": [{
"url": "https://generated-image-url.com/image.png",
"revised_prompt": "A white Siamese cat wearing a detailed space helmet..."
}]
}
```
## Audio (Coming Soon)
### Create Transcription
**Note: This endpoint is coming soon and not yet available.**
Convert audio to text.
```http
POST /v1/audio/transcriptions
Content-Type: multipart/form-data
```
**Form Data:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file` | file | Yes | Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm) |
| `model` | string | Yes | Model ID (whisper-1) |
| `language` | string | No | Language code (ISO-639-1) |
| `prompt` | string | No | Context prompt |
| `response_format` | string | No | Format: json, text, srt, verbose_json, vtt |
| `temperature` | number | No | Sampling temperature |
**Response:**
```json
{
"text": "Hello, this is the transcribed audio content."
}
```
### Create Translation
**Note: This endpoint is coming soon and not yet available.**
Translate audio to English.
```http
POST /v1/audio/translations
Content-Type: multipart/form-data
```
Same parameters as transcription, but always translates to English.
## Models
### List Models
Get available models and pricing.
```http
GET /v1/models
```
**Response:**
```json
{
"object": "list",
"data": [
{
"id": "gpt-3.5-turbo",
"object": "model",
"created": 1677610602,
"owned_by": "openai",
"permission": [...],
"root": "gpt-3.5-turbo",
"parent": null,
"pricing": {
"prompt": 0.001,
"completion": 0.002,
"unit": "1k tokens"
}
}
]
}
```
## Wallet Management
### Create Wallet (Coming Soon)
**Note: This endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys.**
Create a new wallet with eCash deposit.
```http
POST /v1/wallet/create
```
**Request Body:**
```json
{
"cashu_token": "cashuAeyJ0...",
"admin_key": "optional-admin-key"
}
```
**Response:**
```json
{
"api_key": "sk-1234567890abcdef",
"admin_key": "radmin_fedcba0987654321",
"balance": 10000,
"mint": "https://mint.example.com",
"unit": "sat"
}
```
### Check Balance
Get current wallet balance.
```http
GET /v1/wallet/balance
Authorization: Bearer sk-...
```
**Response:**
```json
{
"balance": 8500,
"currency": "sat",
"reserved": 0
}
```
### Top Up Wallet
Add funds to existing wallet.
```http
POST /v1/wallet/topup
Authorization: Bearer sk-...
```
**Request Body:**
```json
{
"cashu_token": "cashuAeyJ0..."
}
```
**Response:**
```json
{
"balance": 18500,
"amount_added": 10000,
"currency": "sat"
}
```
### Withdraw Funds
Withdraw balance as eCash.
```http
POST /v1/wallet/withdraw
Authorization: Bearer sk-...
```
**Request Body:**
```json
{
"amount": 5000,
"mint": "https://mint.example.com"
}
```
**Response:**
```json
{
"cashu_token": "cashuAeyJ0...",
"amount": 5000,
"mint": "https://mint.example.com"
}
```
## 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.
```http
GET /v1/providers
```
**Response:**
```json
{
"providers": [
{
"name": "openai",
"models": ["gpt-4", "gpt-3.5-turbo"],
"endpoints": ["chat/completions", "completions"],
"status": "active"
}
]
}
```
### Provider Info
Get specific provider details.
```http
GET /v1/providers/{provider_name}
```
**Response:**
```json
{
"name": "openai",
"display_name": "OpenAI",
"description": "Official OpenAI API",
"models": [
{
"id": "gpt-4",
"name": "GPT-4",
"context_window": 8192,
"pricing": {
"prompt": 0.03,
"completion": 0.06,
"unit": "1k tokens"
}
}
],
"endpoints": ["chat/completions", "completions", "embeddings"],
"features": ["streaming", "function_calling"],
"status": "active"
}
```
## Rate Limiting
All endpoints are subject to rate limiting:
- **Per minute**: 60 requests
- **Per hour**: 1000 requests
- **Per day**: 10000 requests
Rate limit information is included in response headers.
## Next Steps
- [Errors](errors.md) - Error handling reference
- [Authentication](authentication.md) - Auth details
- [Examples](../user-guide/using-api.md) - Code examples

592
docs/api/errors.md Normal file
View File

@@ -0,0 +1,592 @@
# Error Handling
This guide covers error responses, codes, and handling strategies for the Routstr API.
## Error Response Format
All errors follow a consistent JSON structure:
```json
{
"error": {
"type": "error_type",
"message": "Human-readable error message",
"code": "error_code",
"details": {
"additional": "context-specific information"
}
}
}
```
## HTTP Status Codes
| Status | Meaning | Common Causes |
|--------|---------|---------------|
| 400 | Bad Request | Invalid parameters, malformed JSON |
| 401 | Unauthorized | Invalid or missing API key |
| 402 | Payment Required | Insufficient balance |
| 403 | Forbidden | Access denied to resource |
| 404 | Not Found | Endpoint or resource doesn't exist |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
| 502 | Bad Gateway | Upstream API error |
| 503 | Service Unavailable | Temporary outage |
## Error Types
### Authentication Errors
#### Invalid API Key
```json
{
"error": {
"type": "authentication_failed",
"message": "Invalid API key provided",
"code": "invalid_api_key"
}
}
```
**Status:** 401
**Resolution:** Check API key format and validity
#### Expired API Key
```json
{
"error": {
"type": "authentication_failed",
"message": "API key has expired",
"code": "key_expired",
"details": {
"expired_at": "2024-01-01T00:00:00Z",
"refund_available": true
}
}
}
```
**Status:** 401
**Resolution:** Create new API key or contact admin for refund
#### Missing Authorization
```json
{
"error": {
"type": "authentication_failed",
"message": "Authorization header required",
"code": "missing_auth"
}
}
```
**Status:** 401
**Resolution:** Include `Authorization: Bearer {api_key}` header
### Payment Errors
#### Insufficient Balance
```json
{
"error": {
"type": "insufficient_balance",
"message": "Insufficient balance for request",
"code": "payment_required",
"details": {
"balance": 100,
"required": 154,
"shortfall": 54,
"estimated_tokens": {
"prompt": 50,
"completion": 150
}
}
}
}
```
**Status:** 402
**Resolution:** Top up API key balance
#### Invalid Token
```json
{
"error": {
"type": "payment_error",
"message": "Invalid Cashu token",
"code": "invalid_token",
"details": {
"reason": "Token already spent"
}
}
}
```
**Status:** 400
**Resolution:** Use a valid, unspent token
#### Mint Unavailable
```json
{
"error": {
"type": "payment_error",
"message": "Cannot connect to Cashu mint",
"code": "mint_unavailable",
"details": {
"mint_url": "https://mint.example.com",
"retry_after": 60
}
}
}
```
**Status:** 503
**Resolution:** Try again later or use different mint
### Validation Errors
#### Invalid Parameters
```json
{
"error": {
"type": "invalid_request",
"message": "Invalid request parameters",
"code": "validation_error",
"details": {
"errors": [
{
"field": "temperature",
"message": "Must be between 0 and 2",
"value": 3.5
},
{
"field": "model",
"message": "Model 'gpt-5' not found",
"value": "gpt-5"
}
]
}
}
}
```
**Status:** 422
**Resolution:** Fix parameter values
#### Missing Required Fields
```json
{
"error": {
"type": "invalid_request",
"message": "Missing required fields",
"code": "missing_fields",
"details": {
"missing": ["model", "messages"]
}
}
}
```
**Status:** 400
**Resolution:** Include all required fields
### Rate Limiting
#### Rate Limit Exceeded
```json
{
"error": {
"type": "rate_limit_exceeded",
"message": "Too many requests",
"code": "rate_limit",
"details": {
"limit": 100,
"window": "1 minute",
"retry_after": 45
}
}
}
```
**Status:** 429
**Headers:**
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
Retry-After: 45
```
**Resolution:** Wait for retry_after seconds
### Upstream Errors
#### Model Overloaded
```json
{
"error": {
"type": "upstream_error",
"message": "Model is currently overloaded",
"code": "model_overloaded",
"details": {
"model": "gpt-4",
"retry_after": 5
}
}
}
```
**Status:** 503
**Resolution:** Retry request after delay
#### Upstream Timeout
```json
{
"error": {
"type": "upstream_error",
"message": "Request to upstream API timed out",
"code": "upstream_timeout",
"details": {
"timeout": 30,
"endpoint": "chat/completions"
}
}
}
```
**Status:** 504
**Resolution:** Retry with shorter prompt or max_tokens
### Content Policy
#### Content Filtered
```json
{
"error": {
"type": "content_policy_violation",
"message": "Content filtered due to policy violation",
"code": "content_filtered",
"details": {
"reason": "harmful_content",
"categories": ["violence", "hate"]
}
}
}
```
**Status:** 400
**Resolution:** Modify prompt to comply with policies
## Error Handling Best Practices
### Retry Logic
Implement exponential backoff with jitter:
```python
import time
import random
from typing import Optional, Callable
def retry_with_backoff(
func: Callable,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0
) -> Optional[Any]:
"""Retry function with exponential backoff."""
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
# Check if error is retryable
if hasattr(e, 'status_code'):
if e.status_code in [429, 502, 503, 504]:
# Calculate delay with jitter
delay = min(
base_delay * (2 ** attempt) + random.uniform(0, 1),
max_delay
)
# Use retry_after if provided
if hasattr(e, 'retry_after'):
delay = e.retry_after
time.sleep(delay)
else:
# Non-retryable error
raise
```
### Error Categories
Group errors for handling:
```python
class ErrorHandler:
# Errors that should be retried
RETRYABLE_ERRORS = {
'rate_limit',
'upstream_timeout',
'model_overloaded',
'mint_unavailable'
}
# Errors requiring user action
USER_ACTION_ERRORS = {
'insufficient_balance',
'invalid_api_key',
'key_expired'
}
# Errors requiring code changes
CLIENT_ERRORS = {
'validation_error',
'missing_fields',
'invalid_request'
}
@classmethod
def handle_error(cls, error_response: dict) -> None:
error_code = error_response['error']['code']
if error_code in cls.RETRYABLE_ERRORS:
# Implement retry logic
pass
elif error_code in cls.USER_ACTION_ERRORS:
# Alert user
pass
elif error_code in cls.CLIENT_ERRORS:
# Log for debugging
pass
```
### Graceful Degradation
Handle errors without breaking application flow:
```python
async def get_ai_response(prompt: str) -> str:
"""Get AI response with fallback handling."""
try:
# Try primary model
response = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
except InsufficientBalanceError:
# Fall back to cheaper model
try:
response = await client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}],
max_tokens=100 # Limit tokens
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"Fallback failed: {e}")
return "Service temporarily unavailable"
except Exception as e:
logger.error(f"Unexpected error: {e}")
return "An error occurred processing your request"
```
### Logging Errors
Structure error logs for debugging:
```python
import logging
import json
def log_api_error(error_response: dict, context: dict) -> None:
"""Log API errors with context."""
logger = logging.getLogger(__name__)
error_data = {
'timestamp': datetime.utcnow().isoformat(),
'error': error_response['error'],
'context': {
'endpoint': context.get('endpoint'),
'api_key_id': context.get('api_key_id'),
'request_id': context.get('request_id'),
'model': context.get('model')
}
}
logger.error(
"API Error",
extra={'structured_data': json.dumps(error_data)}
)
```
### User-Friendly Messages
Map technical errors to user messages:
```python
ERROR_MESSAGES = {
'insufficient_balance': "Your account balance is too low. Please add funds to continue.",
'invalid_api_key': "Invalid API key. Please check your configuration.",
'rate_limit': "Too many requests. Please wait a moment and try again.",
'model_overloaded': "The AI service is busy. Please try again in a few seconds.",
'validation_error': "Invalid request. Please check your input and try again."
}
def get_user_message(error_code: str) -> str:
"""Get user-friendly error message."""
return ERROR_MESSAGES.get(
error_code,
"An unexpected error occurred. Please try again later."
)
```
## Common Scenarios
### Handling Balance Errors
```python
async def make_request_with_balance_check():
try:
# Check balance first
balance_info = await client.get("/v1/wallet/balance")
# Estimate cost
estimated_cost = calculate_cost(model, prompt_length)
if balance_info['balance'] < estimated_cost * 1.1: # 10% buffer
# Proactively top up
await top_up_balance()
# Make request
return await client.chat.completions.create(...)
except InsufficientBalanceError as e:
# Handle insufficient balance
shortfall = e.details['shortfall']
await top_up_balance(amount=shortfall * 2)
# Retry request
```
### Handling Rate Limits
```python
from datetime import datetime, timedelta
class RateLimitTracker:
def __init__(self):
self.reset_times = {}
def is_limited(self, endpoint: str) -> bool:
reset_time = self.reset_times.get(endpoint)
if reset_time and datetime.now() < reset_time:
return True
return False
def set_limit(self, endpoint: str, reset_timestamp: int):
self.reset_times[endpoint] = datetime.fromtimestamp(reset_timestamp)
def wait_time(self, endpoint: str) -> float:
reset_time = self.reset_times.get(endpoint)
if reset_time:
return max(0, (reset_time - datetime.now()).total_seconds())
return 0
```
## Testing Error Handling
### Unit Tests
```python
import pytest
from unittest.mock import Mock
async def test_insufficient_balance_handling():
# Mock API client
mock_client = Mock()
mock_client.chat.completions.create.side_effect = InsufficientBalanceError(
required=100,
available=50
)
# Test error handling
handler = ErrorHandler(mock_client)
result = await handler.safe_request(
model="gpt-4",
messages=[{"role": "user", "content": "test"}]
)
# Verify fallback behavior
assert result.fallback_used is True
assert result.model == "gpt-3.5-turbo"
```
### Integration Tests
```python
async def test_real_error_scenarios():
# Test with invalid API key
invalid_client = OpenAI(
api_key="sk-invalid",
base_url=test_url
)
with pytest.raises(AuthenticationError) as exc_info:
await invalid_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "test"}]
)
assert exc_info.value.status_code == 401
assert "invalid_api_key" in str(exc_info.value)
```
## Monitoring Errors
Track error rates and patterns:
```python
class ErrorMetrics:
def __init__(self):
self.error_counts = defaultdict(int)
self.error_timestamps = defaultdict(list)
def record_error(self, error_code: str):
self.error_counts[error_code] += 1
self.error_timestamps[error_code].append(datetime.now())
def get_error_rate(self, error_code: str, window_minutes: int = 60) -> float:
cutoff = datetime.now() - timedelta(minutes=window_minutes)
recent_errors = [
ts for ts in self.error_timestamps[error_code]
if ts > cutoff
]
return len(recent_errors) / window_minutes
```
## Next Steps
- [Authentication](authentication.md) - Auth error details
- [Endpoints](endpoints.md) - Endpoint-specific errors
- [Examples](../user-guide/using-api.md) - Error handling examples

365
docs/api/overview.md Normal file
View File

@@ -0,0 +1,365 @@
# API Reference Overview
Routstr Core provides a complete OpenAI-compatible API with additional endpoints for payment management. This reference covers all available endpoints, authentication methods, and response formats.
## Base URL
```
https://api.routstr.com/v1
```
All API endpoints are prefixed with `/v1` for versioning.
## Authentication
Routstr uses API keys for authentication. Include your key in the Authorization header:
```bash
Authorization: Bearer sk-...
```
### API Key Format
- Prefix: `sk-`
- Length: 32 characters
- Example: `sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p`
### Cashu Tokens as Authentication
You can also use a Cashu eCash token directly in the `Authorization` header. The server hashes the token internally; this hash represents your API key identity and carries the token's balance.
```bash
Authorization: Bearer cashuAeyJ0b2tlbiI6W3...
```
## Content Types
### Request
- **Required**: `Content-Type: application/json`
- **Encoding**: UTF-8
- **Maximum Size**: 10MB (configurable)
### Response
- **Type**: `application/json` or `text/event-stream` (for streaming)
- **Encoding**: UTF-8
- **Compression**: gzip (if accepted)
## Rate Limiting
Rate limits are applied per API key:
- **Requests**: 1000 per minute
- **Tokens**: 1,000,000 per hour
- **Concurrent**: 10 simultaneous requests
Rate limit headers:
```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1640995200
```
## Error Responses
All errors follow a consistent format:
```json
{
"error": {
"type": "insufficient_balance",
"message": "Insufficient balance for request",
"code": "payment_required",
"details": {
"required": 154,
"available": 100
}
}
}
```
### Error Types
| Type | Status Code | Description |
|------|-------------|-------------|
| `invalid_request` | 400 | Malformed request |
| `authentication_failed` | 401 | Invalid or missing API key |
| `insufficient_balance` | 402 | Not enough balance |
| `forbidden` | 403 | Access denied |
| `not_found` | 404 | Resource not found |
| `rate_limit_exceeded` | 429 | Too many requests |
| `internal_error` | 500 | Server error |
| `upstream_error` | 502 | Upstream API error |
## Endpoint Categories
### AI/ML Endpoints
Standard OpenAI-compatible endpoints:
- **Chat Completions**: `/v1/chat/completions`
- **Completions**: `/v1/completions` *(Coming soon)*
- **Embeddings**: `/v1/embeddings` *(Coming soon)*
- **Images**: `/v1/images/generations` *(Coming soon)*
- **Audio**: `/v1/audio/transcriptions` *(Coming soon)*
- **Models**: `/v1/models`
### Payment Endpoints
Routstr-specific payment management:
- **Wallet**: `/v1/wallet/*`
- **Balance**: `/v1/balance`
- **Node Info**: `/v1/info`
### Admin Endpoints
Protected administrative functions:
- **Dashboard**: `/admin/`
- **API Management**: `/admin/api/*`
## Request Headers
### Standard Headers
| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes | Bearer token with API key |
| `Content-Type` | Yes | Must be `application/json` |
| `Accept` | No | Response format preference |
| `Accept-Encoding` | No | Compression support |
| `X-Request-ID` | No | Client-provided request ID |
### Custom Headers
| Header | Description |
|--------|-------------|
| `X-Routstr-Version` | API version override |
| `X-Cashu` | eCash token for per-request payment |
| `X-Max-Cost` | Maximum acceptable cost in sats |
## Response Headers
### Standard Headers
| Header | Description |
|--------|-------------|
| `Content-Type` | Response format |
| `Content-Length` | Response size |
| `X-Routstr-Request-ID` | Unique request identifier |
| `X-Routstr-Version` | API version used |
### Cost Headers
| Header | Description |
|--------|-------------|
| `X-Routstr-Cost` | Request cost in sats |
| `X-Routstr-Balance` | Remaining balance |
| `X-Cashu` | Change token (if applicable) |
## Streaming Responses
For endpoints supporting streaming, responses use Server-Sent Events:
```
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
data: [DONE]
```
## OpenAPI Specification
The complete OpenAPI 3.0 specification is available at:
```
GET /openapi.json
```
Interactive documentation:
```
GET /docs # Swagger UI
GET /redoc # ReDoc
```
## SDK Support
Routstr is compatible with official OpenAI SDKs:
### Python
```python
from openai import OpenAI
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1"
)
```
### JavaScript/TypeScript
```javascript
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: 'sk-...',
baseURL: 'https://your-node.com/v1'
});
```
### cURL
```bash
curl https://your-node.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
```
## Webhook Support
Configure webhooks for events:
```json
POST /v1/webhooks
{
"url": "https://your-app.com/webhook",
"events": ["balance.low", "key.expired"],
"secret": "whsec_your_secret"
}
```
Events are sent with signature verification:
```
X-Webhook-Signature: sha256=...
```
## API Versioning
- Current version: `v1`
- Version in URL path: `/v1/endpoint`
- Override with header: `X-Routstr-Version: v2`
- Deprecation notices: 6 months
## Status Codes
| Code | Meaning |
|------|---------|
| 200 | Success |
| 201 | Created |
| 204 | No content |
| 400 | Bad request |
| 401 | Unauthorized |
| 402 | Payment required |
| 403 | Forbidden |
| 404 | Not found |
| 429 | Rate limited |
| 500 | Server error |
| 502 | Upstream error |
| 503 | Service unavailable |
## CORS Support
CORS is enabled with configurable origins:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
```
## Compression
Responses are compressed with gzip when:
- Client sends `Accept-Encoding: gzip`
- Response is larger than 1KB
- Content type is compressible
## Pagination
List endpoints support pagination:
```
GET /v1/transactions?limit=50&offset=100
```
Response includes pagination metadata:
```json
{
"data": [...],
"has_more": true,
"total": 500,
"limit": 50,
"offset": 100
}
```
## Field Filtering
Select specific fields in responses:
```
GET /v1/models?fields=id,name,pricing
```
## Batch Requests
Process multiple operations in one request:
```json
POST /v1/batch
{
"requests": [
{"method": "POST", "endpoint": "/chat/completions", "body": {...}},
{"method": "GET", "endpoint": "/models"},
{"method": "GET", "endpoint": "/balance"}
]
}
```
## Idempotency
Prevent duplicate operations:
```
Idempotency-Key: unique-request-id
```
Keys are stored for 24 hours.
## Health Check
Monitor service status:
```
GET /health
Response:
{
"status": "healthy",
"version": "0.1.3",
"timestamp": "2024-01-01T00:00:00Z",
"checks": {
"database": "ok",
"upstream": "ok",
"mint": "ok"
}
}
```
## Next Steps
- [Authentication](authentication.md) - Detailed auth guide
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Examples](../user-guide/using-api.md) - Code examples

View File

@@ -0,0 +1,381 @@
# Architecture Overview
This document describes the high-level architecture of Routstr Core, helping contributors understand how the system works.
## System Overview
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs.
```mermaid
graph TB
subgraph "External Services"
Client[API Client]
Mint[Cashu Mint]
Provider[AI Provider]
Nostr[Nostr Relays]
end
subgraph "Routstr Core"
API[FastAPI Server]
Auth[Auth Module]
Payment[Payment Module]
Proxy[Proxy Module]
DB[(SQLite DB)]
API --> Auth
Auth --> Payment
Auth --> DB
Payment --> Proxy
Proxy --> Provider
Payment --> Mint
API --> Nostr
end
Client --> API
```
## Core Components
### FastAPI Application
The main application is initialized in `routstr/core/main.py`:
- **Lifespan Management**: Handles startup/shutdown tasks
- **Middleware**: CORS, logging, error handling
- **Routers**: Modular endpoint organization
- **Background Tasks**: Price updates, automatic payouts
### Authentication System
Located in `routstr/auth.py`, handles:
- **API Key Validation**: Hashed key storage and lookup
- **Balance Checking**: Ensures sufficient funds
- **Rate Limiting**: Optional request throttling
- **Token Redemption**: Converts eCash to balance
### Payment Processing
The `routstr/payment/` module manages:
- **Cost Calculation**: Token-based or fixed pricing
- **Model Pricing**: Dynamic pricing from models.json
- **Currency Conversion**: BTC/USD rate management
- **Fee Application**: Exchange and provider fees
### Request Proxying
`routstr/proxy.py` handles:
- **Request Forwarding**: Preserves headers and body
- **Response Streaming**: Efficient memory usage
- **Usage Tracking**: Counts tokens and costs
- **Error Handling**: Graceful upstream failures
### Database Layer
Using SQLModel in `routstr/core/db.py`:
```python
# Core models
APIKey:
- id: Primary key
- key_hash: Hashed API key
- balance: Current balance (msats)
- created_at: Timestamp
- metadata: JSON field
Transaction:
- id: Primary key
- api_key_id: Foreign key
- amount: Transaction amount
- type: deposit/usage/withdrawal
- timestamp: When occurred
```
## Request Flow
### Standard API Request
```mermaid
sequenceDiagram
participant C as Client
participant R as Routstr
participant D as Database
participant P as AI Provider
C->>R: API Request + Key
R->>D: Validate Key
D-->>R: Key Info + Balance
R->>R: Check Balance
R->>P: Forward Request
P-->>R: AI Response
R->>D: Deduct Cost
R-->>C: Return Response
```
### Payment Flow
```mermaid
sequenceDiagram
participant C as Client
participant R as Routstr
participant W as Wallet Module
participant M as Cashu Mint
participant D as Database
C->>R: Create Key Request + Token
R->>W: Validate Token
W->>M: Verify with Mint
M-->>W: Token Valid
W-->>R: Token Amount
R->>D: Create Key + Balance
R-->>C: Return API Key
```
## Key Design Decisions
### 1. Async Architecture
Everything is async for maximum performance:
```python
async def handle_request(request: Request) -> Response:
# Non-blocking database queries
api_key = await get_api_key(request.headers["Authorization"])
# Concurrent operations
balance_check, rate_limit = await asyncio.gather(
check_balance(api_key),
check_rate_limit(api_key)
)
# Stream response without blocking
async for chunk in proxy_request(request):
yield chunk
```
### 2. Modular Design
Components are loosely coupled:
- **Routers**: Separate files for different endpoints
- **Dependencies**: Injected via FastAPI's DI system
- **Models**: Shared data structures
- **Services**: Business logic separated from routes
### 3. Error Handling
Graceful degradation and clear error messages:
```python
class RoustrError(Exception):
"""Base exception with structured error response"""
status_code: int = 500
error_type: str = "internal_error"
class InsufficientBalanceError(RoustrError):
status_code = 402
error_type = "insufficient_balance"
```
### 4. Database Migrations
Using Alembic for schema management:
- Auto-migrations on startup
- Version control for schema changes
- Rollback capability
- Zero-downtime updates
## Security Architecture
### API Key Security
- **Storage**: SHA-256 hashed keys
- **Generation**: Cryptographically secure random
- **Validation**: Constant-time comparison
- **Rotation**: Support for key expiry
### Payment Security
- **Token Validation**: Cryptographic verification
- **Double-Spend Prevention**: Mint verification
- **Balance Protection**: Atomic transactions
- **Audit Trail**: All transactions logged
### Network Security
- **HTTPS**: Enforced in production
- **CORS**: Configurable origins
- **Rate Limiting**: Per-key limits
- **Input Validation**: Pydantic models
## Performance Considerations
### Caching Strategy
```python
# Model pricing cache
@lru_cache(maxsize=100)
def get_model_price(model_id: str) -> ModelPrice:
return MODELS.get(model_id)
# Balance cache with TTL
balance_cache = TTLCache(maxsize=1000, ttl=60)
```
### Database Optimization
- **Connection Pooling**: Reuse connections
- **Indexed Queries**: Key lookups are O(1)
- **Batch Operations**: Group updates
- **Async I/O**: Non-blocking queries
### Streaming Responses
Efficient memory usage for large responses:
```python
async def stream_response(upstream_response):
async for chunk in upstream_response.aiter_bytes():
# Process chunk without loading full response
yield process_chunk(chunk)
```
## Extension Points
### Adding New Endpoints
1. Create router module
2. Define Pydantic models
3. Implement business logic
4. Register with main app
5. Add tests
### Custom Pricing Models
1. Extend `ModelPrice` class
2. Implement calculation logic
3. Add to pricing registry
4. Update configuration
### Payment Methods
1. Create payment handler
2. Implement validation
3. Add to payment router
4. Update balance logic
## Testing Strategy
### Unit Tests
- Mock external dependencies
- Test business logic in isolation
- Fast execution (< 1 second per test)
- High coverage target (> 80%)
### Integration Tests
- Test component interactions
- Use test database
- Mock external services
- Verify end-to-end flows
### Performance Tests
- Load testing with locust
- Memory profiling
- Database query optimization
- Response time benchmarks
## Monitoring and Observability
### Structured Logging
```python
logger.info("api_request", extra={
"request_id": request_id,
"api_key": api_key_id,
"endpoint": endpoint,
"model": model,
"tokens": token_count,
"cost_sats": cost,
"duration_ms": duration
})
```
### Metrics Collection
Key metrics tracked:
- Request rate by endpoint
- Token usage by model
- Balance changes
- Error rates
- Response times
### Health Checks
```python
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"version": __version__,
"database": await check_db(),
"upstream": await check_upstream(),
"mints": await check_mints()
}
```
## Deployment Architecture
### Container Structure
```dockerfile
# Multi-stage build
FROM python:3.11-slim AS builder
# Install dependencies
FROM python:3.11-slim
# Copy only runtime needs
# Run as non-root user
```
### Environment Configuration
- **Development**: Local SQLite, debug logging
- **Testing**: In-memory database, mock services
- **Production**: Persistent storage, structured logs
### Scaling Considerations
- **Horizontal**: Multiple instances behind load balancer
- **Vertical**: Async handles high concurrency
- **Database**: Consider PostgreSQL for scale
- **Caching**: Redis for distributed cache
## Future Architecture
### Planned Improvements
1. **WebSocket Support**: Real-time balance updates
2. **Plugin System**: Extensible pricing/auth
3. **Multi-Region**: Geographic distribution
4. **Event Sourcing**: Complete audit trail
### Technical Debt
Areas for improvement:
- Database query optimization
- Response caching layer
- Metric aggregation
- API versioning strategy
## Next Steps
- Review [Code Structure](code-structure.md) for detailed organization
- See [Testing Guide](testing.md) for test architecture

View File

@@ -0,0 +1,493 @@
# Code Structure
This guide provides a detailed overview of Routstr Core's codebase organization and key modules.
## Directory Layout
```
routstr-core/
├── routstr/ # Main application package
│ ├── __init__.py # Package initialization, loads .env
│ ├── auth.py # Authentication and authorization
│ ├── balance.py # Balance management endpoints
│ ├── discovery.py # Nostr relay discovery
│ ├── proxy.py # Request proxying logic
│ ├── wallet.py # Cashu wallet operations
│ │
│ ├── core/ # Core infrastructure
│ │ ├── __init__.py
│ │ ├── admin.py # Admin dashboard and API
│ │ ├── db.py # Database models and connection
│ │ ├── exceptions.py # Custom exception classes
│ │ ├── logging.py # Structured logging setup
│ │ ├── main.py # FastAPI app initialization
│ │ └── middleware.py # HTTP middleware components
│ │
│ └── payment/ # Payment processing
│ ├── __init__.py
│ ├── cost_calculation.py # Usage cost calculation
│ ├── helpers.py # Payment utilities
│ ├── lnurl.py # Lightning URL support
│ ├── models.py # Model pricing management
│ ├── price.py # BTC/USD price handling
│ └── x_cashu.py # Cashu header protocol
├── tests/ # Test suite
│ ├── __init__.py
│ ├── conftest.py # Pytest configuration
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── migrations/ # Alembic database migrations
│ ├── alembic.ini
│ ├── env.py
│ ├── script.py.mako
│ └── versions/ # Migration files
├── scripts/ # Utility scripts
│ └── models_meta.py # Fetch model pricing
├── docs/ # Documentation
├── logs/ # Application logs (git ignored)
├── .github/ # GitHub Actions workflows
├── .env.example # Environment variable template
├── .gitignore # Git ignore rules
├── .dockerignore # Docker ignore rules
├── Dockerfile # Container definition
├── Makefile # Development commands
├── README.md # Project overview
├── alembic.ini # Migration configuration
├── compose.yml # Docker Compose setup
├── compose.testing.yml # Testing environment
├── pyproject.toml # Project configuration
└── uv.lock # Locked dependencies
```
## Key Modules
### Application Entry Point
#### `routstr/__init__.py`
```python
# Loads environment variables
import dotenv
dotenv.load_dotenv()
# Exports FastAPI app
from .core.main import app as fastapi_app
```
#### `routstr/core/main.py`
```python
# FastAPI application setup
app = FastAPI(
title="Routstr Node",
lifespan=lifespan, # Manages startup/shutdown
)
# Middleware registration
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(LoggingMiddleware)
# Router inclusion
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(proxy_router)
```
### Authentication Module
#### `routstr/auth.py`
Handles API key validation and authorization:
```python
class APIKeyAuth:
"""FastAPI dependency for API key authentication"""
async def __call__(self, request: Request) -> APIKey:
# Extract and validate API key
# Check balance
# Return authenticated key object
# Usage in routes:
@router.get("/protected")
async def protected_route(api_key: APIKey = Depends(APIKeyAuth())):
pass
```
Key functions:
- `create_api_key()` - Generate new API keys
- `validate_api_key()` - Verify and retrieve key
- `check_balance()` - Ensure sufficient funds
- `update_last_used()` - Track usage
### Payment Processing
#### `routstr/payment/cost_calculation.py`
Calculates request costs:
```python
def calculate_request_cost(
model: str,
prompt_tokens: int,
completion_tokens: int,
**kwargs
) -> CostData:
"""Calculate cost in millisatoshis"""
# Model-based or fixed pricing
# Token counting
# Fee application
# Currency conversion
```
#### `routstr/payment/models.py`
Manages model pricing data:
```python
class ModelPrice:
id: str
name: str
pricing: dict[str, float] # USD prices
context_length: int
# Global model registry
MODELS: dict[str, ModelPrice] = load_models()
# Dynamic price updates
async def update_sats_pricing():
"""Background task to update BTC prices"""
```
#### `routstr/payment/x_cashu.py`
Implements Cashu payment protocol:
```python
class XCashuHandler:
"""Handle x-cashu header payments"""
async def process_request_payment(
self,
token: str,
estimated_cost: int
) -> PaymentResult:
# Validate token
# Check minimum amount
# Process payment
# Generate change
```
### Request Proxying
#### `routstr/proxy.py`
Core proxy functionality:
```python
@router.api_route("/{path:path}", methods=ALL_METHODS)
async def proxy_request(
request: Request,
path: str,
api_key: APIKey = Depends(APIKeyAuth())
) -> Response:
"""Forward requests to upstream provider"""
# Build upstream request
# Stream response
# Track usage
# Deduct costs
```
Key features:
- Streaming support
- Header preservation
- Error handling
- Usage tracking
### Database Layer
#### `routstr/core/db.py`
SQLModel definitions:
```python
class APIKey(SQLModel, table=True):
id: int | None = Field(primary_key=True)
key_hash: str = Field(index=True, unique=True)
balance: int # millisatoshis
total_deposited: int = 0
total_spent: int = 0
created_at: datetime
expires_at: datetime | None = None
metadata: dict = Field(default_factory=dict, sa_column=Column(JSON))
class Transaction(SQLModel, table=True):
id: int | None = Field(primary_key=True)
api_key_id: int = Field(foreign_key="apikey.id")
amount: int # can be negative
balance_after: int
type: TransactionType
description: str
timestamp: datetime
```
### Admin Interface
#### `routstr/core/admin.py`
Web dashboard and admin API:
```python
@admin_router.get("/admin/")
async def admin_dashboard(request: Request):
"""Render admin HTML interface"""
# Authentication check
# Load statistics
# Render template
@admin_router.post("/admin/api/withdraw")
async def withdraw_balance(
api_key: str,
amount: int | None = None
) -> WithdrawalResponse:
"""Generate eCash token for withdrawal"""
```
Features:
- HTML dashboard
- API key management
- Balance withdrawals
- Usage statistics
### Wallet Integration
#### `routstr/wallet.py`
Cashu wallet operations:
```python
class WalletManager:
"""Manage Cashu wallet instances"""
async def redeem_token(
self,
token: str,
mint_url: str | None = None
) -> int:
"""Redeem eCash token and return value"""
async def create_token(
self,
amount: int,
mint_url: str
) -> str:
"""Create eCash token for withdrawal"""
```
### Utility Modules
#### `routstr/core/logging.py`
Structured logging configuration:
```python
def setup_logging():
"""Configure JSON structured logging"""
# Set log level
# Configure formatters
# Add handlers
class RequestIdMiddleware:
"""Add request ID to all logs"""
```
#### `routstr/core/middleware.py`
HTTP middleware components:
```python
class LoggingMiddleware:
"""Log all HTTP requests/responses"""
class ErrorHandlingMiddleware:
"""Consistent error responses"""
```
#### `routstr/core/exceptions.py`
Custom exception hierarchy:
```python
class RoustrError(Exception):
"""Base exception with error details"""
status_code: int
error_type: str
detail: str
class PaymentError(RoustrError):
"""Payment-related errors"""
class UpstreamError(RoustrError):
"""Upstream API errors"""
```
## Configuration Files
### `pyproject.toml`
Project metadata and dependencies:
```toml
[project]
name = "routstr"
version = "0.1.3"
dependencies = [
"fastapi[standard]>=0.115",
"sqlmodel>=0.0.24",
"cashu",
# ...
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff.lint]
select = ["E", "F", "I"]
```
### `alembic.ini`
Database migration configuration:
```ini
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
```
### `Makefile`
Development commands:
```makefile
# Setup commands
setup:
uv sync
uv pip install -e .
# Development server
dev:
fastapi dev routstr --host 0.0.0.0
# Testing
test:
uv run pytest
# Code quality
lint:
uv run ruff check .
```
## Code Patterns
### Dependency Injection
Using FastAPI's DI system:
```python
# Define dependency
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session
# Use in routes
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Item))
return result.scalars().all()
```
### Async Context Managers
For resource management:
```python
async with httpx.AsyncClient() as client:
response = await client.get(url)
async with database.transaction():
# Atomic operations
```
### Type Safety
Leveraging Python 3.11+ features:
```python
# Union types with |
def process(value: str | int) -> dict[str, Any]:
pass
# Type aliases
Balance = int # millisatoshis
TokenList = list[dict[str, str]]
```
### Error Handling
Consistent error responses:
```python
try:
result = await risky_operation()
except SpecificError as e:
logger.error("Operation failed", exc_info=True)
raise HTTPException(
status_code=400,
detail={
"error": "specific_error",
"message": str(e)
}
)
```
## Best Practices
### Module Organization
1. **Single Responsibility**: Each module has one clear purpose
2. **Minimal Imports**: Import only what's needed
3. **Circular Dependencies**: Avoid by using dependency injection
4. **Public API**: Expose through `__init__.py`
### Function Design
1. **Type Hints**: Always include complete type annotations
2. **Async First**: Use async/await for I/O operations
3. **Error Handling**: Raise specific exceptions
4. **Documentation**: Docstrings for public functions
### Testing Structure
1. **Mirror Source**: Test structure matches source
2. **Fixtures**: Reusable test data in conftest.py
3. **Mocking**: Mock external dependencies
4. **Coverage**: Aim for >80% coverage
## Next Steps
- Review [Testing Guide](testing.md) for test structure
- Read [Architecture](architecture.md) for system design

389
docs/contributing/setup.md Normal file
View File

@@ -0,0 +1,389 @@
# Development Setup
This guide will help you set up a development environment for contributing to Routstr Core.
## Prerequisites
Before you begin, ensure you have:
- **Python 3.11+** - Required for type hints and modern features
- **Git** - For version control
- **Docker** (optional) - For running integration tests
- **Make** - For running development commands
## Quick Start
### 1. Fork and Clone
First, fork the repository on GitHub, then clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/routstr-core.git
cd routstr-core
```
### 2. Install uv
We use [uv](https://github.com/astral-sh/uv) for fast, reliable Python package management:
```bash
# Using the installer script
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or with pip
pip install uv
# Or with Homebrew (macOS)
brew install uv
```
### 3. Set Up Environment
Run the setup command:
```bash
make setup
```
This will:
- ✅ Install uv if not present
- ✅ Create a virtual environment
- ✅ Install all dependencies
- ✅ Install dev tools (mypy, ruff, pytest)
- ✅ Install project in editable mode
### 4. Configure Environment
Create your environment file:
```bash
cp .env.example .env
```
Edit `.env` with your configuration:
```bash
# Minimum required for development
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=your-api-key # Optional for mock testing
ADMIN_PASSWORD=development-password
DATABASE_URL=sqlite+aiosqlite:///dev.db
```
### 5. Verify Installation
Run these commands to verify your setup:
```bash
# Check dependencies
make check-deps
# Run unit tests
make test-unit
# Start development server
make dev
```
## Development Workflow
### Running the Server
For development with auto-reload:
```bash
make dev
# Server starts at http://localhost:8000
# Auto-reloads on code changes
```
For production-like environment:
```bash
make run
```
### Code Quality
Before committing, always run:
```bash
# Format code
make format
# Check linting
make lint
# Type checking
make type-check
# All checks at once
make check
```
### Testing
Run different test suites:
```bash
# Unit tests only (fast)
make test-unit
# Integration tests with mocks
make test-integration
# All tests
make test
# With coverage report
make test-coverage
# Run specific test
uv run pytest tests/unit/test_auth.py::test_token_validation -v
```
### Database Management
Work with database migrations:
```bash
# Create new migration
make db-migrate
# Apply migrations
make db-upgrade
# Rollback one migration
make db-downgrade
# View current revision
make db-current
```
## Project Structure
Understanding the codebase:
```
routstr-core/
├── routstr/ # Main package
│ ├── __init__.py # Package initialization
│ ├── auth.py # Authentication logic
│ ├── balance.py # Balance management
│ ├── discovery.py # Nostr discovery
│ ├── proxy.py # Request proxying
│ ├── wallet.py # Cashu wallet integration
│ │
│ ├── core/ # Core modules
│ │ ├── admin.py # Admin dashboard
│ │ ├── db.py # Database models
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── logging.py # Logging setup
│ │ ├── main.py # FastAPI app
│ │ └── middleware.py # HTTP middleware
│ │
│ └── payment/ # Payment processing
│ ├── cost_calculation.py # Cost logic
│ ├── helpers.py # Utilities
│ ├── lnurl.py # Lightning URLs
│ ├── models.py # Model pricing
│ ├── price.py # BTC pricing
│ └── x_cashu.py # Cashu headers
├── tests/ # Test suite
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── migrations/ # Database migrations
├── scripts/ # Utility scripts
├── docs/ # Documentation
├── Makefile # Dev commands
├── pyproject.toml # Project config
└── compose.yml # Docker setup
```
## Common Tasks
### Adding a New Endpoint
1. Create route in appropriate module
2. Add request/response models
3. Write unit tests
4. Update API documentation
5. Add integration tests
Example:
```python
# In routstr/core/main.py or appropriate router
@app.get("/v1/stats")
async def get_stats(
user: User = Depends(get_current_user)
) -> StatsResponse:
"""Get usage statistics for the current user."""
# Implementation
pass
```
### Adding a Database Model
1. Define model in `routstr/core/db.py`
2. Create migration: `make db-migrate`
3. Review generated migration
4. Apply: `make db-upgrade`
Example:
```python
class Transaction(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
api_key_id: int = Field(foreign_key="apikey.id")
amount: int # millisatoshis
timestamp: datetime = Field(default_factory=datetime.utcnow)
description: str
```
### Writing Tests
Follow the AAA pattern:
```python
async def test_balance_deduction():
# Arrange
api_key = await create_test_api_key(balance=1000)
# Act
result = await deduct_balance(api_key.key, amount=100)
# Assert
assert result.success
assert result.new_balance == 900
assert result.deducted == 100
```
## Development Tools
### Makefile Commands
Key commands for development:
```bash
make help # Show all commands
make setup # Initial setup
make dev # Run dev server
make test # Run all tests
make lint # Check code style
make format # Fix code style
make type-check # Check types
make clean # Clean temp files
make docker-build # Build Docker image
```
### IDE Setup
#### VS Code
Recommended extensions:
- Python
- Pylance
- Ruff
- GitLens
Settings (`.vscode/settings.json`):
```json
{
"python.linting.enabled": true,
"python.linting.ruffEnabled": true,
"python.formatting.provider": "ruff",
"python.analysis.typeCheckingMode": "strict",
"editor.formatOnSave": true
}
```
#### PyCharm
1. Set Python interpreter to uv venv
2. Enable type checking
3. Configure Ruff as external tool
4. Set up file watchers for formatting
### Debugging
#### Debug Server
```bash
# Run with debug logging
LOG_LEVEL=DEBUG make dev
# Or with debugger
uv run python -m debugpy --listen 5678 --wait-for-client \
-m uvicorn routstr:fastapi_app --reload
```
#### Debug Tests
```bash
# Run specific test with output
uv run pytest tests/unit/test_auth.py -v -s
# With debugger
uv run pytest tests/unit/test_auth.py --pdb
```
## Troubleshooting
### Common Issues
**Import Errors**
```bash
# Ensure project is installed in editable mode
uv sync
uv pip install -e .
```
**Database Errors**
```bash
# Reset database
rm dev.db
make db-upgrade
```
**Type Checking Fails**
```bash
# Clear mypy cache
make clean
make type-check
```
**Tests Fail Locally**
```bash
# Ensure test dependencies are installed
uv sync --dev
# Check for leftover test data
rm -rf test_*.db
```
### Getting Help
- Check existing [GitHub Issues](https://github.com/routstr/routstr-core/issues)
- Ask in [GitHub Discussions](https://github.com/routstr/routstr-core/discussions)
- Read the [Architecture Guide](architecture.md)
## Next Steps
Now that you're set up:
1. Read the [Architecture Overview](architecture.md)
3. Check [open issues](https://github.com/routstr/routstr-core/issues)
4. Start with a small contribution
Happy coding! 🚀

View File

@@ -0,0 +1,581 @@
# Testing Guide
This guide covers testing practices, patterns, and tools used in Routstr Core development.
## Testing Philosophy
We follow these principles:
- **Test Behavior, Not Implementation** - Tests should survive refactoring
- **Fast Feedback** - Unit tests run in milliseconds
- **Reliable Tests** - No flaky tests allowed
- **Clear Failures** - Tests should clearly indicate what broke
## Test Structure
```
tests/
├── __init__.py
├── conftest.py # Shared fixtures and configuration
├── unit/ # Fast, isolated unit tests
│ ├── test_auth.py
│ ├── test_balance.py
│ ├── test_cost_calculation.py
│ ├── test_models.py
│ └── test_wallet.py
└── integration/ # Component integration tests
├── test_api_endpoints.py
├── test_payment_flow.py
├── test_proxy_streaming.py
└── test_real_mint.py
```
## Running Tests
### Quick Test Commands
```bash
# Run all tests
make test
# Run unit tests only (fast)
make test-unit
# Run integration tests
make test-integration
# Run with coverage
make test-coverage
# Run specific test file
uv run pytest tests/unit/test_auth.py -v
# Run specific test
uv run pytest tests/unit/test_auth.py::test_create_api_key -v
# Run tests matching pattern
uv run pytest -k "balance" -v
```
### Test Markers
Use markers to categorize tests:
```python
@pytest.mark.slow
async def test_heavy_computation():
pass
@pytest.mark.requires_docker
async def test_real_services():
pass
# Run without slow tests
pytest -m "not slow"
# Run only integration tests
pytest -m "integration"
```
## Writing Tests
### Unit Test Example
```python
# tests/unit/test_auth.py
import pytest
from routstr.auth import create_api_key, validate_api_key
class TestAPIKeyAuth:
"""Test API key authentication functionality"""
async def test_create_api_key(self, test_db):
"""Test creating a new API key"""
# Arrange
initial_balance = 10000
# Act
api_key = await create_api_key(
balance=initial_balance,
name="Test Key"
)
# Assert
assert api_key.key.startswith("sk-")
assert len(api_key.key) == 32
assert api_key.balance == initial_balance
async def test_validate_invalid_key(self, test_db):
"""Test validation fails for invalid key"""
# Act & Assert
with pytest.raises(InvalidAPIKeyError):
await validate_api_key("invalid_key")
```
### Integration Test Example
```python
# tests/integration/test_payment_flow.py
import pytest
from httpx import AsyncClient
class TestPaymentFlow:
"""Test end-to-end payment flows"""
@pytest.mark.asyncio
async def test_token_redemption_flow(
self,
app_client: AsyncClient,
mock_cashu_wallet
):
"""Test complete token redemption and API usage"""
# Arrange
token = create_test_token(amount=5000)
mock_cashu_wallet.redeem.return_value = 5000
# Act - Create API key
response = await app_client.post(
"/v1/wallet/create",
json={"cashu_token": token}
)
# Assert - Key created
assert response.status_code == 200
api_key = response.json()["api_key"]
assert response.json()["balance"] == 5000
# Act - Use API key
response = await app_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hi"}]
}
)
# Assert - Request successful
assert response.status_code == 200
assert "choices" in response.json()
```
## Test Fixtures
### Common Fixtures
Located in `tests/conftest.py`:
```python
@pytest.fixture
async def test_db():
"""Provide a clean test database"""
# Create in-memory SQLite database
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession)
async with async_session() as session:
yield session
await engine.dispose()
@pytest.fixture
async def app_client(test_db):
"""Provide test client with test database"""
app.dependency_overrides[get_db] = lambda: test_db
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture
def mock_cashu_wallet(mocker):
"""Mock Cashu wallet for testing"""
mock = mocker.patch("routstr.wallet.Wallet")
mock.return_value.redeem.return_value = 1000
return mock
```
### Using Fixtures
```python
async def test_with_fixtures(
test_db, # Get test database
app_client, # Get test HTTP client
mock_cashu_wallet # Get mocked wallet
):
# Use fixtures in test
pass
```
## Mocking Strategies
### Mocking External Services
```python
# Mock upstream API
@pytest.fixture
def mock_openai(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {
"choices": [{
"message": {"content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30
}
}
mocker.patch(
"httpx.AsyncClient.post",
return_value=mock_response
)
# Mock Cashu mint
@pytest.fixture
def mock_mint(mocker):
mint = mocker.patch("routstr.wallet.Mint")
mint.return_value.check_proof_state.return_value = True
return mint
```
### Mocking Time
```python
from freezegun import freeze_time
@freeze_time("2024-01-01 12:00:00")
async def test_time_dependent():
# Time is frozen during test
key = await create_api_key(expires_in_days=30)
assert key.expires_at == datetime(2024, 1, 31, 12, 0, 0)
```
## Test Patterns
### Testing Async Code
```python
# Always mark async tests
@pytest.mark.asyncio
async def test_async_function():
result = await async_operation()
assert result == expected
# Test async context managers
async def test_async_context():
async with create_resource() as resource:
assert resource.is_active
```
### Testing Exceptions
```python
# Test specific exception
async def test_raises_specific_error():
with pytest.raises(InsufficientBalanceError) as exc_info:
await deduct_balance(api_key, amount=999999)
assert "Insufficient balance" in str(exc_info.value)
assert exc_info.value.status_code == 402
# Test exception details
async def test_exception_details():
with pytest.raises(RoustrError) as exc_info:
await risky_operation()
error = exc_info.value
assert error.error_type == "validation_error"
assert error.detail == "Invalid input"
```
### Testing Streaming Responses
```python
async def test_streaming_response():
"""Test streaming chat completion"""
chunks = []
async with app_client.stream(
"POST",
"/v1/chat/completions",
json={"model": "gpt-3.5-turbo", "stream": True}
) as response:
async for line in response.aiter_lines():
if line.startswith("data: "):
chunks.append(json.loads(line[6:]))
# Verify chunks
assert len(chunks) > 0
assert chunks[-1] == "[DONE]"
```
### Database Testing
```python
async def test_database_transaction(test_db):
"""Test atomic transactions"""
async with test_db.begin():
# Create test data
api_key = APIKey(key_hash="test", balance=1000)
test_db.add(api_key)
await test_db.flush()
# Test rollback
try:
async with test_db.begin_nested():
api_key.balance = -100 # Invalid
await test_db.flush()
raise ValueError("Rollback")
except ValueError:
pass
# Verify rollback worked
await test_db.refresh(api_key)
assert api_key.balance == 1000
```
## Performance Testing
### Benchmark Tests
```python
@pytest.mark.benchmark
def test_performance(benchmark):
"""Benchmark critical functions"""
result = benchmark(expensive_function, arg1, arg2)
assert result == expected
# Run benchmarks
pytest --benchmark-only
```
### Load Testing
```python
# tests/integration/test_load.py
async def test_concurrent_requests(app_client):
"""Test handling multiple concurrent requests"""
async def make_request(i):
response = await app_client.get(f"/test/{i}")
return response.status_code
# Make 100 concurrent requests
tasks = [make_request(i) for i in range(100)]
results = await asyncio.gather(*tasks)
# All should succeed
assert all(status == 200 for status in results)
```
## Test Data
### Factories
```python
# tests/factories.py
from datetime import datetime, timedelta
def create_test_api_key(**kwargs):
"""Factory for test API keys"""
defaults = {
"key": f"sk-test-{uuid4().hex[:8]}",
"balance": 10000,
"created_at": datetime.utcnow(),
"expires_at": datetime.utcnow() + timedelta(days=30)
}
defaults.update(kwargs)
return APIKey(**defaults)
def create_test_token(amount: int = 1000, mint: str = None):
"""Factory for test Cashu tokens"""
# Create valid test token structure
return base64.encode(...)
```
### Test Constants
```python
# tests/constants.py
TEST_MODELS = {
"gpt-3.5-turbo": {
"prompt": 0.0015,
"completion": 0.002
},
"gpt-4": {
"prompt": 0.03,
"completion": 0.06
}
}
TEST_API_KEY = "sk-test-1234567890"
TEST_MINT_URL = "https://testmint.example.com"
```
## Coverage
### Running Coverage
```bash
# Generate coverage report
make test-coverage
# View HTML report
open htmlcov/index.html
# Coverage with specific tests
pytest --cov=routstr --cov-report=html tests/unit/
```
### Coverage Configuration
In `pyproject.toml`:
```toml
[tool.coverage.run]
source = ["routstr"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if TYPE_CHECKING:"
]
```
## Debugging Tests
### Print Debugging
```python
# Use -s flag to see print output
pytest -s tests/unit/test_auth.py
# In test
async def test_debug():
print(f"Value: {value}") # Will show with -s
assert value == expected
```
### Interactive Debugging
```python
# Drop into debugger on failure
pytest --pdb
# Set breakpoint in test
async def test_debug():
import pdb; pdb.set_trace()
# Execution stops here
```
### Logging in Tests
```python
# Enable debug logging in tests
import logging
logging.basicConfig(level=logging.DEBUG)
# Or use caplog fixture
async def test_logging(caplog):
with caplog.at_level(logging.INFO):
await function_that_logs()
assert "Expected message" in caplog.text
```
## CI/CD Integration
### GitHub Actions
Tests run automatically on:
- Pull requests
- Pushes to main
- Nightly schedules
See `.github/workflows/test.yml` for configuration.
### Pre-commit Hooks
Install pre-commit hooks:
```bash
pre-commit install
# Run manually
pre-commit run --all-files
```
## Best Practices
### Do's
1. ✅ Write tests first (TDD)
2. ✅ Keep tests simple and focused
3. ✅ Use descriptive test names
4. ✅ Test edge cases
5. ✅ Mock external dependencies
6. ✅ Use fixtures for setup
7. ✅ Assert specific values
### Don'ts
1. ❌ Don't test implementation details
2. ❌ Don't use production services
3. ❌ Don't rely on test order
4. ❌ Don't ignore flaky tests
5. ❌ Don't skip error cases
6. ❌ Don't use hard-coded waits
7. ❌ Don't commit commented tests
## Troubleshooting
### Common Issues
**Async Test Errors**
```python
# Wrong
def test_async(): # Missing async
await function()
# Right
async def test_async():
await function()
```
**Database State**
```python
# Ensure clean state
@pytest.fixture(autouse=True)
async def cleanup(test_db):
yield
# Cleanup after each test
await test_db.execute("DELETE FROM apikey")
await test_db.commit()
```
**Mock Not Working**
```python
# Check import path
mocker.patch("routstr.wallet.Wallet") # Full path
# Not just "Wallet"
```
## Next Steps
- See [Architecture](architecture.md) for system design
- Read [Setup Guide](setup.md) for environment setup

View File

@@ -0,0 +1,260 @@
# Configuration
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
### Core Settings
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `UPSTREAM_BASE_URL` | Base URL of the OpenAI-compatible API to proxy | - | ✅ |
| `UPSTREAM_API_KEY` | API key for the upstream service | - | ❌ |
| `ADMIN_PASSWORD` | Password for admin dashboard access | - | ⚠️ |
### Node Information
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `NAME` | Public name of your Routstr node | `ARoutstrNode` | ❌ |
| `DESCRIPTION` | Description of your node | `A Routstr Node` | ❌ |
| `NPUB` | Nostr public key for node identity | - | ❌ |
| `HTTP_URL` | Public HTTP URL of your node | - | ❌ |
| `ONION_URL` | Tor hidden service URL | - | ❌ |
### Cashu Configuration
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `CASHU_MINTS` | Comma-separated list of trusted Cashu mint URLs | `https://mint.minibits.cash/Bitcoin` | ❌ |
| `RECEIVE_LN_ADDRESS` | Lightning address for automatic payouts | - | ❌ |
### Pricing Configuration
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `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 & 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
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | `INFO` | ❌ |
| `ENABLE_CONSOLE_LOGGING` | Enable console log output | `true` | ❌ |
### Other
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `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
### Basic OpenAI Proxy
```bash
# .env
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-...
ADMIN_PASSWORD=my-secure-password
```
### Custom AI Provider
```bash
# .env
UPSTREAM_BASE_URL=https://api.anthropic.com/v1
UPSTREAM_API_KEY=your-anthropic-key
MODELS_PATH=/app/config/anthropic-models.json
```
### Azure OpenAI (optional)
```bash
# .env
UPSTREAM_BASE_URL=https://<resource>.openai.azure.com/openai/deployments/<deployment>
UPSTREAM_API_KEY=<azure_api_key>
CHAT_COMPLETIONS_API_VERSION=2024-05-01-preview
```
### High-Security Setup
```bash
# .env
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-...
ADMIN_PASSWORD=very-long-secure-password-here
CORS_ORIGINS=https://myapp.com,https://app.myapp.com
TOR_PROXY_URL=socks5://tor:9050
LOG_LEVEL=WARNING
```
### Public Node Configuration
```bash
# .env
NAME=Lightning AI Gateway
DESCRIPTION=Fast and reliable AI API access with Bitcoin payments
NPUB=npub1abcd...
HTTP_URL=https://api.lightning-ai.com
ONION_URL=http://lightningai.onion
CASHU_MINTS=https://mint1.com,https://mint2.com
```
## 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_*`.
Example fixed pricing:
```bash
FIXED_PRICING=true
FIXED_COST_PER_REQUEST=10
```
## Custom Models Configuration
Create a `models.json` file:
```json
{
"models": [
{
"id": "gpt-4",
"name": "GPT-4",
"pricing": {
"prompt": "0.00003",
"completion": "0.00006",
"request": "0"
}
},
{
"id": "gpt-3.5-turbo",
"name": "GPT-3.5 Turbo",
"pricing": {
"prompt": "0.0000015",
"completion": "0.000002",
"request": "0"
}
}
]
}
```
## Security Best Practices
### Admin Password
Generate a strong password:
```bash
openssl rand -base64 32
```
### API Keys
- Rotate upstream API keys regularly
- Use read-only keys when possible
- Monitor key usage
### Network Security
- Restrict CORS origins in production
- Use HTTPS for public endpoints
- Enable Tor for anonymity
### Database Security
- Regular backups
- Encrypted storage volumes
- Restricted file permissions
## Troubleshooting
### Check Current Configuration
```bash
# View all environment variables
docker exec routstr env | sort
# Test configuration
curl http://localhost:8000/v1/info
```
### Common Issues
**Missing Upstream URL**
```
ERROR: UPSTREAM_BASE_URL not set
Solution: Set UPSTREAM_BASE_URL in .env
```
**Invalid Cashu Mint**
```
ERROR: Failed to connect to mint
Solution: Verify CASHU_MINTS URLs are accessible
```
**Database Errors**
```
ERROR: Database connection failed
Solution: Check DATABASE_URL and file permissions
```
## Advanced Configuration
### Multiple Mints
Configure fallback mints:
```bash
CASHU_MINTS=https://primary.mint,https://backup1.mint,https://backup2.mint
```
### Custom Database
Use PostgreSQL instead of SQLite:
```bash
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/routstr
```
### Proxy Settings
For corporate environments:
```bash
HTTP_PROXY=http://proxy.company.com:8080
HTTPS_PROXY=http://proxy.company.com:8080
```
## Next Steps
- [User Guide](../user-guide/introduction.md) - Start using Routstr
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced pricing strategies

View File

@@ -0,0 +1,337 @@
# Docker Setup
This guide covers deploying Routstr Core using Docker for production environments.
## Docker Images
Official images are available on GitHub Container Registry:
```bash
ghcr.io/routstr/proxy:latest
```
## Basic Docker Run
### Minimal Setup
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=sk-... \
-e ADMIN_PASSWORD=secure-password \
ghcr.io/routstr/proxy:latest
```
### With Persistent Storage
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-v routstr-data:/app/data \
-v routstr-logs:/app/logs \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=sk-... \
-e DATABASE_URL=sqlite+aiosqlite:///data/keys.db \
ghcr.io/routstr/proxy:latest
```
## Docker Compose Setup
### Basic Configuration
Create `compose.yml`:
```yaml
version: '3.8'
services:
routstr:
image: ghcr.io/routstr/proxy:latest
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
env_file:
- .env
restart: unless-stopped
```
### With Tor Hidden Service
The included `compose.yml` provides Tor support:
```yaml
version: '3.8'
services:
routstr:
build: . # Or use image: ghcr.io/routstr/proxy:latest
volumes:
- .:/app
- ./logs:/app/logs
env_file:
- .env
environment:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- 8000:8000
extra_hosts:
- "host.docker.internal:host-gateway"
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
volumes:
- tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
depends_on:
- routstr
volumes:
tor-data:
```
### Environment File
Create `.env` file:
```bash
# Required
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=your-api-key
ADMIN_PASSWORD=secure-admin-password
# Cashu Configuration
CASHU_MINTS=https://mint.minibits.cash/Bitcoin
# Optional
NAME=My Routstr Node
DESCRIPTION=Pay-per-use AI API proxy
NPUB=npub1...
HTTP_URL=https://api.mynode.com
ONION_URL=http://mynode.onion
# Pricing (optional)
FIXED_PRICING=false
EXCHANGE_FEE=1.005
UPSTREAM_PROVIDER_FEE=1.05
```
## Building Custom Image
### Dockerfile Overview
The provided Dockerfile:
- Uses Alpine Linux for small size
- Installs required dependencies for secp256k1
- Runs as non-root user
- Exposes port 8000
### Build Locally
```bash
# Clone repository
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
# Build image
docker build -t my-routstr:latest .
# Run custom image
docker run -d \
--name routstr \
-p 8000:8000 \
--env-file .env \
my-routstr:latest
```
## Deployment Considerations
### Resource Requirements
- **CPU**: 1-2 cores recommended
- **Memory**: 512MB-1GB
- **Storage**: 1GB + database growth
- **Network**: Low latency to upstream provider
### Health Checks
Add health check to compose.yml:
```yaml
services:
routstr:
# ... other config ...
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/v1/info"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
### Reverse Proxy Setup
#### Nginx Example
```nginx
server {
listen 443 ssl http2;
server_name api.yournode.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# For streaming responses
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
}
```
#### Caddy Example
```caddy
api.yournode.com {
reverse_proxy localhost:8000 {
flush_interval -1
}
}
```
## Monitoring
### Log Management
View logs:
```bash
# Docker
docker logs -f routstr
# Docker Compose
docker compose logs -f routstr
# Log files
tail -f ./logs/routstr.log
```
### Metrics
Monitor key metrics:
- Request count and latency
- Token validation success rate
- Upstream API errors
- Database size growth
## Backup and Recovery
### Database Backup
```bash
# Backup SQLite database
docker exec routstr sqlite3 /app/data/keys.db ".backup /app/data/backup.db"
# Copy backup locally
docker cp routstr:/app/data/backup.db ./backup-$(date +%Y%m%d).db
```
### Restore from Backup
```bash
# Stop service
docker compose down
# Restore database
docker cp ./backup.db routstr:/app/data/keys.db
# Restart service
docker compose up -d
```
## Security Considerations
### Environment Variables
- Never commit `.env` files
- Use Docker secrets for sensitive data
- Rotate API keys regularly
- Use strong admin passwords
### Network Security
- Use HTTPS/TLS termination
- Restrict admin interface access
- Enable firewall rules
- Monitor for suspicious activity
### Container Security
- Run as non-root user
- Use read-only filesystem where possible
- Limit container capabilities
- Keep base image updated
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker logs routstr
# Verify environment
docker exec routstr env | grep -E "(UPSTREAM|CASHU|ADMIN)"
# Test database connection
docker exec routstr sqlite3 /app/data/keys.db ".tables"
```
### Permission Issues
```bash
# Fix volume permissions
sudo chown -R 1000:1000 ./data ./logs
```
### Network Issues
```bash
# Test upstream connectivity
docker exec routstr curl -I https://api.openai.com
# Check DNS resolution
docker exec routstr nslookup api.openai.com
```
## Production Checklist
- [ ] Set strong `ADMIN_PASSWORD`
- [ ] Configure proper `UPSTREAM_BASE_URL` and `UPSTREAM_API_KEY`
- [ ] Set up persistent volumes for data and logs
- [ ] Configure reverse proxy with TLS
- [ ] Set up monitoring and alerting
- [ ] Implement backup strategy
- [ ] Test disaster recovery
- [ ] Document deployment process
## Next Steps
- [Configuration Guide](configuration.md) - All environment variables
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node

View File

@@ -0,0 +1,156 @@
# Overview
Routstr Core is a powerful payment proxy that brings Bitcoin micropayments to AI APIs. This overview will help you understand the core concepts and architecture.
## Core Concepts
### Payment Proxy
Routstr acts as a transparent proxy between your application and OpenAI-compatible APIs. It:
- Intercepts API requests
- Validates payment tokens
- Forwards requests to the upstream provider
- Tracks usage and deducts costs
- Returns responses to the client
### Cashu eCash Protocol
[Cashu](https://cashu.space) is a Bitcoin eCash protocol that enables:
- **Privacy**: Payments are unlinkable and untraceable
- **Instant Settlement**: No waiting for blockchain confirmations
- **Micropayments**: Send fractions of a satoshi
- **Offline Capability**: Tokens can be transferred without internet
### Lightning Network Integration
Routstr connects to the Lightning Network through Cashu mints, enabling:
- Fast Bitcoin deposits and withdrawals
- Global payment reach
- Low transaction fees
- No minimum payment amounts
## Architecture
### System Components
```mermaid
graph TB
subgraph "Client Side"
A[AI Application]
B[OpenAI SDK]
C[eCash Wallet]
end
subgraph "Routstr Core"
D[FastAPI Server]
E[Auth Module]
F[Payment Module]
G[Proxy Module]
H[SQLite Database]
end
subgraph "External Services"
I[Upstream AI Provider]
J[Cashu Mint]
K[Bitcoin/Lightning]
end
A --> B
B --> D
C --> D
D --> E
E --> F
F --> J
D --> G
G --> I
D --> H
J --> K
```
### Key Modules
1. **Authentication** (`auth.py`)
- API key validation
- Balance checking
- Request authorization
2. **Payment Processing** (`payment/`)
- Token validation
- Cost calculation
- Balance updates
- Pricing models
3. **Proxy Handler** (`proxy.py`)
- Request forwarding
- Response streaming
- Usage tracking
- Error handling
4. **Wallet Management** (`wallet.py`)
- Cashu wallet integration
- Token redemption
- Balance management
- Automatic payouts
5. **Admin Interface** (`core/admin.py`)
- Web dashboard
- Balance viewing
- Key management
- Withdrawal interface
## Payment Flow
### Standard Flow (API Key)
1. User deposits eCash tokens to create an API key
2. Client sends requests with the API key
3. Routstr checks balance and forwards request
4. Cost is deducted based on actual usage
5. Response is returned to client
### Per-Request Flow (Coming Soon)
1. Client includes eCash token in request header
2. Routstr validates token meets minimum amount
3. Request is processed
4. Change is returned in response header
5. No account or balance needed
## Supported Features
### API Compatibility
- ✅ Chat completions (streaming and non-streaming)
- ✅ Text completions
- ✅ Embeddings
- ✅ Image generation
- ✅ Audio transcription/translation
- ✅ Model listing
- ✅ Custom endpoints
### Payment Features
- ✅ Multiple Cashu mint support
- ✅ Automatic balance tracking
- ✅ Model-based pricing
- ✅ USD to BTC conversion
- ✅ Configurable fees
- ✅ Balance withdrawals
### Operational Features
- ✅ Docker deployment
- ✅ Tor hidden service support
- ✅ Nostr relay discovery
- ✅ Database migrations
- ✅ Comprehensive logging
- ✅ Admin dashboard
## Next Steps
- [Quick Start](quickstart.md) - Get running in minutes
- [Docker Setup](docker.md) - Deploy with containers
- [Configuration](configuration.md) - Customize your instance

View File

@@ -0,0 +1,226 @@
# Quick Start
Get Routstr Core up and running in minutes with Docker or local development setup.
## Prerequisites
- Docker and Docker Compose (for production)
- Python 3.11+ (for development)
- A Cashu-compatible wallet (optional for testing)
## Option 1: Docker (Recommended)
### Quick Run
The fastest way to start Routstr Core:
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=your-openai-api-key \
ghcr.io/routstr/proxy:latest
```
### Docker Compose
For a full setup with Tor support:
1. Clone the repository:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
```
2. Create environment file:
```bash
cp .env.example .env
# Edit .env with your settings
```
3. Start the services:
```bash
docker compose up -d
```
This will start:
- Routstr proxy on port 8000
- Tor hidden service (optional)
- Automatic database migrations
### Verify Installation
Check that Routstr is running:
```bash
curl http://localhost:8000/v1/info
```
You should see:
```json
{
"name": "ARoutstrNode",
"description": "A Routstr Node",
"version": "0.1.3",
"npub": "",
"mints": ["https://mint.minibits.cash/Bitcoin"],
"models": {...}
}
```
## Option 2: Local Development
### Install Dependencies
1. Install [uv](https://github.com/astral-sh/uv) package manager:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
2. Clone and setup:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
uv sync
```
3. Configure environment:
```bash
cp .env.example .env
# Edit .env with your settings
```
### Run the Server
```bash
fastapi run routstr --host 0.0.0.0 --port 8000
```
## First API Call
### 1. Get an eCash Token
You'll need a Cashu token to pay for API calls. Options:
- Use a [Cashu wallet](https://cashu.space) to create tokens
- Get test tokens from a testnet mint
- Use the example token (for testing only)
### 2. Create an API Key
Send your eCash token to create an API key:
```bash
curl -X POST http://localhost:8000/v1/wallet/create \
-H "Content-Type: application/json" \
-d '{
"cashu_token": "cashuAeyJ0b2..."
}'
```
Response:
```json
{
"api_key": "rUvK7...",
"balance": 10000
}
```
### 3. Make an API Call
Use your API key like a normal OpenAI key:
```python
import openai
client = openai.OpenAI(
api_key="rUvK7...", # Your Routstr API key
base_url="http://localhost:8000/v1"
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
```
## Example Client
Run the included example:
```bash
CASHU_TOKEN="your-token" python example.py
```
This demonstrates:
- Creating an API key from a token
- Making streaming chat requests
- Automatic balance deduction
## Testing the Setup
### Check Available Models
```bash
curl http://localhost:8000/v1/models
```
### View Admin Dashboard
Open <http://localhost:8000/admin/> in your browser.
Default password is set in `ADMIN_PASSWORD` environment variable.
### Monitor Logs
Docker:
```bash
docker compose logs -f routstr
```
Local:
```bash
# Logs are in ./logs/ directory
tail -f logs/routstr.log
```
## Common Issues
### Connection Refused
- Ensure the service is running: `docker ps`
- Check firewall settings
- Verify port 8000 is not in use
### Invalid API Key
- Ensure you've created an API key with sufficient balance
- Check the token was valid and had value
- Verify the mint URL is accessible
### Upstream Errors
- Check `UPSTREAM_BASE_URL` is correct
- Verify `UPSTREAM_API_KEY` if required
- Test upstream service directly
## Next Steps
- [Configuration Guide](configuration.md) - Customize settings
- [Docker Setup](docker.md) - Production deployment
- [User Guide](../user-guide/introduction.md) - Detailed usage

83
docs/index.md Normal file
View File

@@ -0,0 +1,83 @@
# Routstr Core Documentation
Welcome to the official documentation for **Routstr Core** - a FastAPI-based reverse proxy that enables Bitcoin micropayments for OpenAI-compatible APIs using the Cashu eCash protocol.
## What is Routstr Core?
Routstr Core is a payment proxy that sits between API clients and OpenAI-compatible services. It enables:
- **Pay-per-request billing** using Bitcoin eCash tokens
- **Seamless integration** with existing OpenAI clients
- **Privacy-preserving payments** through the Cashu protocol
- **Flexible pricing models** with per-token or per-request billing
- **Multi-provider support** for various AI model providers
### Key Features
- 🪙 **Cashu Wallet Integration** - Accept Lightning payments and redeem eCash tokens
- 🔑 **API Key Management** - Secure key storage with balance tracking
- 💰 **Dynamic Pricing** - Model-based pricing with live BTC/USD conversion
- 🎛️ **Admin Dashboard** - Web interface for balance and key management
- 🌐 **Nostr Discovery** - Find providers through decentralized relay network
- 🐋 **Docker Support** - Easy deployment with optional Tor hidden service
-**Lightning Fast** - Minimal latency overhead for API requests
## How It Works
```mermaid
sequenceDiagram
participant Client
participant Routstr as Routstr Proxy
participant DB as Database
participant Upstream as AI Provider
participant Wallet as Cashu Wallet
Client->>Routstr: API Request + eCash Token
Routstr->>Wallet: Validate & Redeem Token
Wallet-->>Routstr: Token Value (sats)
Routstr->>DB: Store/Update Balance
Routstr->>Upstream: Forward API Request
Upstream-->>Routstr: API Response + Usage Data
Routstr->>DB: Deduct Actual Cost
Routstr-->>Client: API Response
```
## Quick Links
<div class="grid cards" markdown>
- :rocket: **[Quick Start](getting-started/quickstart.md)**
Get up and running with Docker in minutes
- :gear: **[Configuration](getting-started/configuration.md)**
Learn about environment variables and settings
- :book: **[User Guide](user-guide/introduction.md)**
Comprehensive guide for using Routstr
- :hammer: **[Contributing](contributing/setup.md)**
Help improve Routstr Core
</div>
## Use Cases
- **AI Application Developers** - Add Bitcoin payments to your AI apps without managing infrastructure
- **API Resellers** - Resell API access with custom pricing and profit margins
- **Privacy-Focused Users** - Access AI models without revealing personal information
- **Micropayment Experiments** - Test new business models with instant, small payments
## Getting Help
- 📖 Browse the [User Guide](user-guide/introduction.md) for detailed usage instructions
- 🐛 Report issues on [GitHub](https://github.com/routstr/routstr-core/issues)
- 💬 Join the community discussions
- 🔧 Check the [API Reference](api/overview.md) for technical details
## License
Routstr Core is open source software licensed under the GPLv3. See the [LICENSE](https://github.com/routstr/routstr-core/blob/main/LICENSE) file for details.

4
docs/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
mkdocs>=1.5.3
mkdocs-material>=9.5.0
mkdocs-mermaid2-plugin>=1.1.1
pymdown-extensions>=10.5

View File

@@ -0,0 +1,329 @@
# Admin Dashboard
The Routstr admin dashboard provides a web interface for managing your node, viewing balances, and handling withdrawals.
## Accessing the Dashboard
### URL Format
The admin dashboard is available at:
```
https://api.routstr.com/admin/
```
> **Important**: Always include the trailing slash (`/`) in the URL.
### Authentication
The dashboard is protected by a password set in the `ADMIN_PASSWORD` environment variable.
1. Navigate to `/admin/`
2. Enter the admin password
3. Click "Login"
The password is stored as a secure cookie for the session.
## Dashboard Overview
### Main Interface
The dashboard displays:
- **Node Information**
- Node name and description
- Version number
- Public URLs (HTTP and Onion)
- Supported Cashu mints
- **Statistics**
- Total API keys
- Active keys
- Total balance across all keys
- Recent activity
- **API Key List**
- All keys with balances
- Usage statistics
- Management options
## Features
### Viewing API Keys
The main table shows all API keys with:
| Column | Description |
|--------|-------------|
| API Key | Masked key (first/last 4 chars) |
| Balance | Current balance in sats |
| Created | Creation timestamp |
| Last Used | Most recent API call |
| Total Spent | Lifetime usage |
| Status | Active/Expired/Disabled |
### Searching and Filtering
- **Search**: Find keys by partial match
- **Sort**: Click column headers to sort
- **Filter**: Show only active/expired keys
- **Export**: Download data as CSV
### Key Details
Click on any key to view:
- Full API key (masked by default)
- Complete transaction history
- Usage graphs
- Metadata (name, expiry, refund address)
## Balance Management
### Viewing Balances
Balances are displayed in multiple units:
- **Sats**: Standard satoshi units
- **mSats**: Millisatoshis (internal precision)
- **BTC**: Bitcoin decimal format
- **USD**: Approximate USD value
### Balance History
View balance changes over time:
```
Time | Type | Amount | Balance | Description
-------------|-----------|---------|---------|-------------
12:34:56 | Deposit | +10,000 | 10,000 | Token redemption
12:35:12 | Usage | -154 | 9,846 | gpt-3.5-turbo call
12:36:45 | Usage | -210 | 9,636 | gpt-4 call
```
## Withdrawals
### Manual Withdrawal
To withdraw funds from an API key:
1. Click "Withdraw" next to the key
2. Optionally specify amount (default: full balance)
3. Select target Cashu mint
4. Click "Generate Token"
5. Copy the eCash token
6. Redeem in your Cashu wallet
### Bulk Operations
For multiple withdrawals:
1. Select keys using checkboxes
2. Click "Bulk Actions" → "Withdraw"
3. Tokens are generated for each key
4. Download all tokens as text file
### Automatic Withdrawals
If configured with `RECEIVE_LN_ADDRESS`:
- Balances above threshold auto-convert to Lightning
- Sent to configured Lightning address
- View payout history in dashboard
## Node Configuration
### Viewing Settings
Current node configuration is displayed:
- Upstream provider URL
- Enabled features
- Pricing model
- Fee structure
### Models and Pricing
View supported models and their pricing:
| Model | Input $/1K | Output $/1K | Sats/1K |
|-------|------------|-------------|---------|
| gpt-3.5-turbo | $0.0015 | $0.002 | 3/4 |
| gpt-4 | $0.03 | $0.06 | 60/120 |
| dall-e-3 | - | - | 1000/image |
### Updating Configuration
> **Note**: Configuration changes require node restart.
To update settings:
1. Modify environment variables
2. Restart the node
3. Verify changes in dashboard
## Analytics
### Usage Statistics
View comprehensive usage data:
- **Requests per Day**: Line graph
- **Token Usage**: Stacked bar chart
- **Model Distribution**: Pie chart
- **Cost Analysis**: Breakdown by model
### Performance Metrics
Monitor node performance:
- Average response time
- Request success rate
- Upstream API latency
- Cache hit ratio
### Export Data
Export analytics data:
1. Select date range
2. Choose metrics
3. Click "Export"
4. Download as CSV/JSON
## Security Features
### Access Control
- Password protection
- Session timeout (configurable)
- IP allowlisting (optional)
- Audit logging
### Security Log
View security events:
```
2024-01-15 12:34:56 | Login Success | IP: 192.168.1.1
2024-01-15 12:35:12 | Withdrawal | Key: sk-****abcd | Amount: 5000
2024-01-15 12:40:00 | Session Timeout | IP: 192.168.1.1
```
### Best Practices
1. **Strong Password**: Use a long, random password
2. **HTTPS Only**: Always access via HTTPS
3. **Regular Monitoring**: Check logs frequently
4. **Limited Access**: Restrict dashboard access
## Troubleshooting
### Cannot Access Dashboard
**Issue**: 404 Not Found
- Ensure trailing slash: `/admin/`
- Check if admin routes are enabled
**Issue**: Unauthorized
- Verify `ADMIN_PASSWORD` is set
- Clear browser cookies
- Try incognito/private mode
### Display Issues
**Issue**: Broken Layout
- Clear browser cache
- Disable ad blockers
- Try different browser
**Issue**: Missing Data
- Check database connectivity
- Verify node is running
- Review error logs
### Withdrawal Problems
**Issue**: Token Generation Fails
- Check mint connectivity
- Verify sufficient balance
- Try different mint
**Issue**: Invalid Token
- Ensure complete token copy
- Check token hasn't expired
- Verify mint compatibility
## Advanced Features
### Custom Branding
Customize dashboard appearance:
```bash
# Environment variables
ADMIN_LOGO_URL=https://example.com/logo.png
ADMIN_THEME_COLOR=#FF6B00
ADMIN_CUSTOM_CSS=/path/to/custom.css
```
### API Access
Access admin functions programmatically:
```bash
# Get node stats
curl -X GET https://your-node.com/admin/api/stats \
-H "X-Admin-Password: your-password"
# Export key data
curl -X GET https://your-node.com/admin/api/keys \
-H "X-Admin-Password: your-password" \
-H "Accept: application/json"
```
### Webhooks
Configure notifications:
```bash
ADMIN_WEBHOOK_URL=https://example.com/webhook
ADMIN_WEBHOOK_EVENTS=withdrawal,low_balance,error
```
## Dashboard Shortcuts
### Keyboard Navigation
- `Ctrl+K`: Quick search
- `Ctrl+R`: Refresh data
- `Ctrl+E`: Export current view
- `Escape`: Close modals
### Quick Actions
- Double-click to copy API key
- Right-click for context menu
- Drag to reorder columns
- Shift-click to select multiple
## Mobile Access
The dashboard is mobile-responsive:
- Touch-optimized controls
- Swipe navigation
- Compact view mode
- Offline capability
## Next Steps
- [Models & Pricing](models-pricing.md) - Configure pricing
- [API Reference](../api/overview.md) - Admin API endpoints
- [Advanced Configuration](../advanced/custom-pricing.md) - Advanced settings

View File

@@ -0,0 +1,245 @@
# User Guide Introduction
Welcome to the Routstr Core User Guide. This guide will help you understand how to use Routstr to access AI APIs with Bitcoin micropayments.
## What You'll Learn
- How the payment system works
- Creating and managing API keys
- Making API calls through Routstr
- Using the admin dashboard
- Managing your balance
## Prerequisites
Before starting, you'll need:
1. **A Running Routstr Instance**
- Either your own deployment or access to a public node
- The base URL (e.g., `https://api.routstr.com/v1`)
2. **A Cashu Wallet** (optional but recommended)
- [Nutstash](https://nutstash.app) - Web wallet
- [Minibits](https://www.minibits.cash) - Mobile wallet
- [Cashu.me](https://cashu.me) - Simple web wallet
3. **An API Client**
- OpenAI Python/JavaScript SDK
- Any HTTP client (curl, Postman, etc.)
- Your application code
## How Routstr Works
### Traditional API Access
```mermaid
graph LR
A[Your App] --> B[OpenAI API]
B --> A
```
- Direct connection to provider
- Monthly billing
- Credit card required
- Usage limits
### With Routstr
```mermaid
graph LR
A[Your App] --> B[Routstr Proxy]
B --> C[OpenAI API]
C --> B
B --> A
D[Bitcoin/eCash] --> B
```
- Pay per request with Bitcoin
- No credit card needed
- Anonymous payments
- Instant settlement
## Key Concepts
### eCash Tokens
- Digital bearer tokens backed by Bitcoin
- Can be sent like cash - whoever has the token owns it
- Redeemable at Cashu mints for Bitcoin
- Perfect for micropayments
### API Keys
- Created by depositing eCash tokens
- Track your balance and usage
- Can be topped up anytime
- Optional expiry and refund address
### Balance Management
- Measured in millisatoshis (msats)
- 1 Bitcoin = 100,000,000 sats = 100,000,000,000 msats
- Deducted based on actual usage
- Withdrawable as eCash tokens
## Typical Workflow
### 1. Get Bitcoin/eCash
Options:
- Buy Bitcoin and deposit to a Cashu mint
- Receive eCash tokens from someone else
- Use a testnet mint for testing
### 2. Use Your eCash
You have two options for using your eCash tokens with Routstr:
#### Option A: Create a Persistent Wallet
Create a wallet with an API key for multiple requests:
```bash
POST /v1/wallet/create
{
"cashu_token": "cashuAeyJ0..."
}
```
This returns an API key (`sk-...`) and your balance. The wallet persists between requests.
#### Option B: Direct Token Usage
Use your Cashu token directly as the API key:
```python
client = OpenAI(
api_key="cashuAeyJ0...", # Your Cashu token directly
base_url="https://api.routstr.com/v1"
)
```
Routstr automatically converts the token to access the associated wallet. Each request consumes from the token's balance.
### 3. Make API Calls
With either method:
```python
# Using persistent wallet API key
client = OpenAI(
api_key="sk-...",
base_url="https://api.routstr.com/v1"
)
# Or using Cashu token directly
client = OpenAI(
api_key="cashuAeyJ0...",
base_url="https://api.routstr.com/v1"
)
```
### 4. Monitor Usage
- Check balance: `GET /v1/wallet/balance`
- View admin dashboard
- Track costs per request
### 5. Withdraw Funds
When done, withdraw remaining balance as eCash through the admin interface.
## Supported Endpoints
Routstr supports all standard OpenAI endpoints:
-`/v1/chat/completions` - Chat models
- 🚧 `/v1/completions` - Text completion (Coming soon)
- 🚧 `/v1/embeddings` - Text embeddings (Coming soon)
- 🚧 `/v1/images/generations` - Image generation (Coming soon)
- 🚧 `/v1/audio/transcriptions` - Audio to text (Coming soon)
- 🚧 `/v1/audio/translations` - Audio translation (Coming soon)
-`/v1/models` - List available models
- ✅ Custom provider endpoints
## Cost Structure
### Pricing Models
1. **Fixed Cost Per Request**
- Simple flat fee per API call
- Good for uniform usage
2. **Token-Based Pricing**
- Pay per input/output token
- More accurate for varied usage
3. **Model-Based Pricing**
- Different rates per model
- Reflects actual provider costs
### Cost Calculation
```
Total Cost = Base Fee + (Input Tokens * Input Rate) + (Output Tokens * Output Rate)
```
Fees may include:
- Exchange rate markup (BTC/USD conversion)
- Provider margin
- Node operator fee
## Getting Support
### Documentation
- This user guide for general usage
- [API Reference](../api/overview.md) for technical details
- [Contributing Guide](../contributing/setup.md) for developers
### Community
- GitHub Issues for bugs and features
- Nostr for decentralized discussion
- Node operator contact info
### Troubleshooting
Common issues and solutions:
- [Payment Flow](payment-flow.md) - Understanding the payment process
- [Using the API](using-api.md) - API integration guide
- [Admin Dashboard](admin-dashboard.md) - Managing your node
## Security Considerations
### API Key Security
- Treat API keys like passwords
- Never share or commit them
- Rotate keys regularly
- Use environment variables
### Payment Security
- eCash tokens are bearer instruments
- Verify mint trustworthiness
- Keep backups of tokens
- Use small amounts for testing
### Network Security
- Always use HTTPS connections
- Verify SSL certificates
- Consider using Tor for privacy
- Monitor for unusual activity
## Next Steps
Ready to start? Continue with:
1. [Payment Flow](payment-flow.md) - Detailed payment process
2. [Using the API](using-api.md) - Making your first calls
3. [Admin Dashboard](admin-dashboard.md) - Managing your account

View File

@@ -0,0 +1,466 @@
# Models & Pricing
Understanding how Routstr calculates costs is essential for managing your API usage efficiently. This guide explains the pricing models and how to configure them.
## Pricing Models
Routstr supports three pricing models:
### 1. Fixed Pricing
Simple per-request charging:
```bash
FIXED_PRICING=true
FIXED_COST_PER_REQUEST=10 # 10 sats per request
```
**Best for:**
- Uniform API usage
- Simple applications
- Predictable costs
### 2. Token-Based Pricing
Charge based on actual token usage:
```bash
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:**
- Varied request sizes
- Fair usage billing
- Cost optimization
### 3. Model-Based Pricing
Dynamic pricing based on model costs:
```bash
FIXED_PRICING=false
EXCHANGE_FEE=1.005 # 0.5% exchange fee
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee
```
**Best for:**
- Multiple models
- Market-based pricing
- Automatic updates
## Model Configuration
### Default Models
Routstr includes pricing for popular models:
| Model | Input ($/1K) | Output ($/1K) | Context | Notes |
|-------|--------------|---------------|---------|-------|
| gpt-3.5-turbo | $0.0015 | $0.002 | 16K | Fast, economical |
| gpt-4 | $0.03 | $0.06 | 8K | Advanced reasoning |
| gpt-4-turbo | $0.01 | $0.03 | 128K | Large context |
| claude-3-opus | $0.015 | $0.075 | 200K | Best quality |
| claude-3-sonnet | $0.003 | $0.015 | 200K | Balanced |
| llama-2-70b | $0.0007 | $0.0009 | 4K | Open source |
### Custom Models File
Create `models.json` to override defaults:
```json
{
"models": [
{
"id": "gpt-4-vision",
"name": "GPT-4 Vision",
"pricing": {
"prompt": "0.00003",
"completion": "0.00006",
"request": "0",
"image": "0.00255"
},
"context_length": 128000,
"supports_vision": true
},
{
"id": "custom-model",
"name": "My Custom Model",
"pricing": {
"prompt": "0.001",
"completion": "0.002",
"request": "0.0001"
},
"context_length": 8192
}
]
}
```
### Auto-updating Models
Fetch latest models from OpenRouter:
```bash
# Update models from API
python scripts/models_meta.py
# Or manually
curl https://openrouter.ai/api/v1/models > models.json
```
## Cost Calculation
### Understanding the Formula
```
Base Cost = (Input Tokens × Input Rate) + (Output Tokens × Output Rate) + Request Fee
Bitcoin Price = Current BTC/USD rate (e.g., $50,000)
Sats Cost = (Base Cost / Bitcoin Price) × 100,000,000
Final Cost = Sats Cost × Exchange Fee × Provider Fee
```
### Example Calculations
**Example 1: Simple Chat (gpt-3.5-turbo)**
```
Input: 50 tokens
Output: 150 tokens
Model rates: $0.0015/1K input, $0.002/1K output
USD Cost = (50/1000 × 0.0015) + (150/1000 × 0.002)
= $0.000075 + $0.0003
= $0.000375
At $50,000/BTC: 0.75 sats
With 5.5% total fees: 0.79 sats
```
**Example 2: Large Context (gpt-4)**
```
Input: 2,000 tokens
Output: 500 tokens
Model rates: $0.03/1K input, $0.06/1K output
USD Cost = (2000/1000 × 0.03) + (500/1000 × 0.06)
= $0.06 + $0.03
= $0.09
At $50,000/BTC: 180 sats
With 5.5% total fees: 190 sats
```
**Example 3: Image Generation (dall-e-3)**
```
Model: dall-e-3
Size: 1024x1024
Quality: standard
Cost: $0.04 per image
At $50,000/BTC: 80 sats
With 5.5% fees: 84 sats
```
## Fee Structure
### Exchange Fee
Covers Bitcoin/USD conversion costs:
```bash
EXCHANGE_FEE=1.005 # 0.5% default
```
Factors:
- Exchange rate volatility
- Conversion costs
- Price update frequency
### Provider Fee
Node operator's margin:
```bash
UPSTREAM_PROVIDER_FEE=1.05 # 5% default
```
Covers:
- Infrastructure costs
- Maintenance
- Support
- Profit margin
### Calculating Total Fees
```
Total Multiplier = EXCHANGE_FEE × UPSTREAM_PROVIDER_FEE
Example: 1.005 × 1.05 = 1.05525 (5.525% total)
```
## Special Pricing
### Image Models
Image generation uses per-image pricing:
| Model | Size | Quality | Price |
|-------|------|---------|-------|
| dall-e-2 | 256x256 | - | $0.016 |
| dall-e-2 | 512x512 | - | $0.018 |
| dall-e-2 | 1024x1024 | - | $0.02 |
| dall-e-3 | 1024x1024 | standard | $0.04 |
| dall-e-3 | 1024x1024 | hd | $0.08 |
| dall-e-3 | 1024x1792 | standard | $0.08 |
| dall-e-3 | 1024x1792 | hd | $0.12 |
### Audio Models
Audio pricing by duration:
| Model | Type | Price |
|-------|------|-------|
| whisper-1 | Transcription | $0.006/minute |
| whisper-1 | Translation | $0.006/minute |
| tts-1 | Text-to-speech | $0.015/1K chars |
| tts-1-hd | HD speech | $0.03/1K chars |
### Embedding Models
Lower costs for embeddings:
| Model | Price/1K tokens |
|-------|-----------------|
| text-embedding-3-small | $0.00002 |
| text-embedding-3-large | $0.00013 |
| text-embedding-ada-002 | $0.0001 |
## Monitoring Costs
### Per-Request Tracking
Each API response includes usage data:
```json
{
"usage": {
"prompt_tokens": 50,
"completion_tokens": 150,
"total_tokens": 200
},
"x-routstr-cost": {
"sats": 79,
"usd": 0.000375,
"breakdown": {
"prompt_cost": 15,
"completion_cost": 60,
"fees": 4
}
}
}
```
### Daily Summaries
View in admin dashboard:
- Total requests
- Token usage by model
- Cost distribution
- Trending patterns
### Cost Alerts
Set up notifications:
```python
# Example monitoring script
def check_daily_spend(api_key):
balance_start = get_balance(api_key, "00:00")
balance_now = get_balance(api_key)
spent = balance_start - balance_now
if spent > DAILY_LIMIT:
send_alert(f"Daily spend exceeded: {spent} sats")
```
## Optimization Strategies
### Model Selection
Choose the right model for each task:
| Task | Recommended Model | Why |
|------|-------------------|-----|
| Simple Q&A | gpt-3.5-turbo | Fast, cheap, sufficient |
| Code generation | gpt-4 | Better reasoning |
| Summarization | claude-3-haiku | Good balance |
| Creative writing | claude-3-opus | Best quality |
| Embeddings | text-embedding-3-small | Optimized for vectors |
### Prompt Engineering
Reduce costs with efficient prompts:
```python
# Expensive
prompt = """
You are an AI assistant. Your task is to help users.
Please provide detailed, comprehensive answers.
Now, answer this question: What is 2+2?
"""
# Economical
prompt = "Calculate: 2+2"
```
### Caching Strategies
Implement smart caching:
```python
# Cache embedding results
@lru_cache(maxsize=1000)
def get_embedding(text):
return client.embeddings.create(
model="text-embedding-3-small",
input=text
)
# Cache common responses
COMMON_RESPONSES = {
"greeting": "Hello! How can I help you?",
"goodbye": "Goodbye! Have a great day!"
}
```
### Batch Processing
Process multiple items efficiently:
```python
# Instead of multiple calls
for item in items:
response = client.chat.completions.create(...)
# Use single call with formatted prompt
prompt = "\n".join([f"{i+1}. {item}" for i, item in enumerate(items)])
response = client.chat.completions.create(
messages=[{"role": "user", "content": f"Process these items:\n{prompt}"}]
)
```
## Custom Pricing Rules
### Time-Based Pricing
Implement off-peak discounts:
```python
def calculate_multiplier():
hour = datetime.now().hour
if 2 <= hour <= 6: # 2 AM - 6 AM
return 0.8 # 20% discount
elif 18 <= hour <= 22: # 6 PM - 10 PM
return 1.2 # 20% premium
return 1.0
```
### Model-Specific Rules
Custom pricing logic:
```python
def adjust_model_price(model, base_price):
# Premium for latest models
if "turbo" in model or "latest" in model:
return base_price * 1.1
# Discount for older models
if "legacy" in model:
return base_price * 0.8
return base_price
```
## Pricing Transparency
### Public Pricing Page
Display current rates:
```html
<!-- Available at /pricing -->
<table>
<tr>
<th>Model</th>
<th>Input (sats/1K)</th>
<th>Output (sats/1K)</th>
</tr>
<!-- Dynamically generated from models.json -->
</table>
```
### Cost Estimation API
Provide cost estimates:
```bash
POST /v1/estimate
{
"model": "gpt-4",
"prompt_tokens": 500,
"max_tokens": 200
}
Response:
{
"estimated_cost_sats": 45,
"breakdown": {
"prompt": 30,
"completion": 12,
"fees": 3
}
}
```
## Troubleshooting
### Pricing Mismatches
**Issue**: Costs don't match expectations
- Check current BTC/USD rate
- Verify fee settings
- Review model configuration
**Issue**: Models not found
- Update models.json
- Check model ID spelling
- Verify upstream support
### Fee Calculations
**Issue**: Fees seem too high
- Review EXCHANGE_FEE setting
- Check UPSTREAM_PROVIDER_FEE
- Calculate total multiplier
## Next Steps
- [API Reference](../api/overview.md) - Technical details
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced configuration
- [Contributing](../contributing/setup.md) - Help improve Routstr

View File

@@ -0,0 +1,373 @@
# Payment Flow
Understanding how payments work in Routstr is key to using the system effectively. This guide explains the payment process in detail.
## Overview
Routstr uses a pre-funded account model where:
1. Users deposit eCash tokens to create an API key
2. Each API request deducts from the balance
3. Users can withdraw remaining balance as eCash
## Creating an API Key
### Step 1: Obtain eCash Token
Get a Cashu token from any compatible source:
**Option A: From a Cashu Wallet**
```bash
# Example: Creating a 10,000 sat token
cashu send 10000
```
**Option B: Lightning Invoice**
```bash
# Some mints support direct Lightning deposits
curl -X POST https://mint.example.com/v1/mint/quote/bolt11 \
-d '{"amount": 10000, "unit": "sat"}'
```
**Option C: Test Tokens**
```bash
# Get test tokens from testnet mints
# Check mint documentation for faucets
```
### Step 2: Create API Key
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys in the Authorization header.**
Send your token to Routstr:
```bash
curl -X POST https://api.routstr.com/v1/wallet/create \
-H "Content-Type: application/json" \
-d '{
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
}'
```
**Request Parameters:**
- `cashu_token` (required): The eCash token to deposit
**Response:**
```json
{
"api_key": "sk-1234567890abcdef",
"balance": 10000000,
"created_at": "2024-01-01T00:00:00Z"
}
```
### Step 3: Verify Balance
Check your key's balance:
```bash
curl -X GET https://api.routstr.com/v1/wallet/balance \
-H "Authorization: Bearer sk-1234567890abcdef"
```
Response:
```json
{
"balance": 10000000,
"total_deposited": 10000000,
"total_spent": 0,
"last_used": null
}
```
## Making API Requests
### Cost Calculation
Costs are calculated based on:
1. **Request Type**
- Chat completions
- Embeddings
- Image generation
- Audio processing
2. **Token Usage**
- Input tokens (prompt)
- Output tokens (response)
- Model-specific rates
3. **Additional Costs**
- Base request fee
- Image generation fees
- Audio processing time
### Example: Chat Completion
```python
import openai
client = openai.OpenAI(
api_key="sk-1234567890abcdef",
base_url="https://api.routstr.com/v1"
)
# Make request
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "Hello, how are you?"}
]
)
# Check usage
print(f"Input tokens: {response.usage.prompt_tokens}")
print(f"Output tokens: {response.usage.completion_tokens}")
print(f"Total tokens: {response.usage.total_tokens}")
```
### Cost Breakdown
For the above request:
```
Model: gpt-3.5-turbo
Input tokens: 13
Output tokens: 27
Model rates: $0.0015/1K input, $0.002/1K output
USD Cost = (13/1000 * 0.0015) + (27/1000 * 0.002) = $0.0000735
BTC/USD Rate: $50,000
BTC Cost = 0.0000735 / 50000 = 0.00000000147 BTC = 147 sats
With fees (5%): 154 sats
Final cost: 154 sats
```
## Balance Management
### Monitoring Usage
Track your usage in real-time:
```bash
# Get current balance
curl -X GET https://api.routstr.com/v1/wallet/balance \
-H "Authorization: Bearer your-api-key"
# View recent transactions (through admin dashboard)
# Access at https://api.routstr.com/admin/
```
### Low Balance Handling
When balance is insufficient:
```json
{
"error": {
"type": "insufficient_balance",
"message": "Insufficient balance. Current: 100 sats, Required: 154 sats",
"code": "payment_required"
}
}
```
### Topping Up
Add funds to existing key:
```bash
curl -X POST https://api.routstr.com/v1/wallet/topup \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"cashu_token": "cashuAeyJ0b2..."
}'
```
## Withdrawing Balance
### Via Admin Dashboard
1. Navigate to `/admin/`
2. Enter admin password
3. Find your API key
4. Click "Withdraw"
5. Receive eCash token
### Via API (if enabled)
```bash
curl -X POST https://api.routstr.com/v1/wallet/withdraw \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"mint": "https://mint.minibits.cash/Bitcoin"
}'
```
## Payment Security
### Token Validation
Routstr validates tokens by:
1. Checking signature validity
2. Verifying with the issuing mint
3. Ensuring no double-spending
4. Confirming sufficient value
### Failed Payments
Common failure reasons:
- Invalid token signature
- Already spent token
- Untrusted mint
- Network issues with mint
### Refund Policy
- Unused balance can be withdrawn anytime
- Expired keys with balance can be refunded
- Node operators may have additional policies
## Advanced Features
### Multi-Mint Support
Routstr accepts tokens from multiple mints:
```bash
CASHU_MINTS=https://mint1.com,https://mint2.com,https://mint3.com
```
Benefits:
- Redundancy if one mint is down
- User choice of mints
- Geographic distribution
### Automatic Payouts
Configure automatic Lightning payouts:
```bash
RECEIVE_LN_ADDRESS=satoshi@getalby.com
```
When enabled:
- Balances above threshold are swept
- Converted to Lightning payments
- Sent to configured address
### Per-Request Payments (Coming Soon)
Future support for Nut-24 headers:
```bash
curl -X POST https://api.routstr.com/v1/chat/completions \
-H "x-cashu: cashuAeyJ0..." \
-H "Content-Type: application/json" \
-d '{...}'
```
Response includes change:
```
HTTP/1.1 200 OK
x-cashu: cashuAeyJjaGFuZ2Ui...
```
## Best Practices
### API Key Management
1. **Separate Keys per Application**
- Easier tracking
- Better security
- Independent budgets
2. **Set Expiration Dates**
- Automatic cleanup
- Security improvement
- Budget control
3. **Monitor Balances**
- Set up alerts
- Regular checks
- Usage analytics
### Cost Optimization
1. **Choose Appropriate Models**
- Smaller models for simple tasks
- Larger models only when needed
2. **Optimize Prompts**
- Concise, clear instructions
- Avoid unnecessary tokens
3. **Use Streaming**
- Early termination possible
- Better user experience
### Security
1. **Secure Storage**
- Environment variables
- Secrets management
- Never in code
2. **Network Security**
- Always use HTTPS
- Verify certificates
- Consider Tor for privacy
3. **Regular Rotation**
- Change keys periodically
- Withdraw unused funds
- Audit usage logs
## Troubleshooting
### Payment Rejected
**Error:** "Invalid token"
- Check token format
- Verify mint is trusted
- Ensure not already spent
**Error:** "Insufficient value"
- Token value too low
- Check current pricing
- Add larger token
### Balance Discrepancies
- Allow for price fluctuations
- Check model pricing updates
- Review transaction history
### Mint Issues
- Try different mint from list
- Check mint status
- Contact mint operator
## Next Steps
- [Using the API](using-api.md) - Integration guide
- [Admin Dashboard](admin-dashboard.md) - Account management
- [Models & Pricing](models-pricing.md) - Cost details

View File

@@ -0,0 +1,545 @@
# Using the API
This guide shows how to integrate Routstr with your applications using various programming languages and tools.
## API Compatibility
Routstr maintains full compatibility with the OpenAI API, meaning:
- Existing OpenAI client libraries work without modification
- Only the base URL and API key need to change
- All parameters and responses match OpenAI's format
## Basic Setup
### Python
Using the official OpenAI Python library:
```python
from openai import OpenAI
# Initialize client with Routstr endpoint
client = OpenAI(
api_key="sk-...",
base_url="https://api.routstr.com/v1"
)
# Use exactly like OpenAI
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
]
)
print(response.choices[0].message.content)
```
### JavaScript/TypeScript
Using the official OpenAI Node.js library:
```javascript
import OpenAI from 'openai';
// Initialize client
const openai = new OpenAI({
apiKey: 'sk-...',
baseURL: 'https://api.routstr.com/v1'
});
// Make a request
async function main() {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' }
]
});
console.log(completion.choices[0].message.content);
}
main();
```
### cURL
Direct HTTP requests:
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-..." \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
```
## Common Use Cases
### Chat Completions
Standard chat with conversation history:
```python
messages = []
def chat(user_input):
# Add user message
messages.append({"role": "user", "content": user_input})
# Get AI response
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
temperature=0.7,
max_tokens=150
)
# Add AI response to history
ai_message = response.choices[0].message
messages.append({"role": "assistant", "content": ai_message.content})
return ai_message.content
# Usage
print(chat("What's the weather like?"))
print(chat("How should I dress?")) # Maintains context
```
### Streaming Responses
For real-time output:
```python
stream = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Write a short story"}],
stream=True
)
for chunk in stream:
if chunk.choices[0].delta.content is not None:
print(chunk.choices[0].delta.content, end="", flush=True)
```
### Function Calling
Using OpenAI's function calling feature:
```python
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
}]
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
tools=tools,
tool_choice="auto"
)
# Check if function was called
if response.choices[0].message.tool_calls:
tool_call = response.choices[0].message.tool_calls[0]
print(f"Function: {tool_call.function.name}")
print(f"Arguments: {tool_call.function.arguments}")
```
### Embeddings
Generate text embeddings:
```python
response = client.embeddings.create(
model="text-embedding-3-small",
input="The quick brown fox jumps over the lazy dog"
)
embedding = response.data[0].embedding
print(f"Embedding dimension: {len(embedding)}")
```
### Image Generation
Create images with DALL-E:
```python
response = client.images.generate(
model="dall-e-3",
prompt="A futuristic city with flying cars",
size="1024x1024",
quality="standard",
n=1
)
image_url = response.data[0].url
print(f"Image URL: {image_url}")
```
### Audio Transcription
Convert speech to text:
```python
with open("audio.mp3", "rb") as audio_file:
response = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
response_format="text"
)
print(response.text)
```
## Error Handling
### Balance Errors
Handle insufficient balance gracefully:
```python
try:
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
except Exception as e:
if "insufficient_balance" in str(e):
print("Low balance! Please top up your API key.")
# Implement top-up logic
else:
raise
```
### Rate Limiting
Implement exponential backoff:
```python
import time
from typing import Optional
def make_request_with_retry(
func,
max_retries: int = 3,
initial_delay: float = 1.0
) -> Optional[any]:
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if "rate_limit" in str(e) and attempt < max_retries - 1:
delay = initial_delay * (2 ** attempt)
print(f"Rate limited. Waiting {delay}s...")
time.sleep(delay)
else:
raise
return None
```
### Connection Errors
Handle network issues:
```python
import httpx
# Configure timeout and retries
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1",
timeout=httpx.Timeout(60.0, connect=5.0),
max_retries=2
)
```
## Advanced Features
### Using Tor
Route requests through Tor for privacy:
```python
import httpx
# Configure Tor proxy
proxies = {
"http://": "socks5://127.0.0.1:9050",
"https://": "socks5://127.0.0.1:9050"
}
http_client = httpx.Client(proxies=proxies)
client = OpenAI(
api_key="sk-...",
base_url="http://your-onion-address.onion/v1",
http_client=http_client
)
```
### Custom Headers
Add custom headers if needed:
```python
import httpx
class CustomClient(httpx.Client):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.headers["X-Custom-Header"] = "value"
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1",
http_client=CustomClient()
)
```
### Azure OpenAI compatibility
To use Azure OpenAI through Routstr with minimal changes:
- Set `UPSTREAM_BASE_URL` to your Azure deployments URL, for example: `https://<resource>.openai.azure.com/openai/deployments/<deployment>`
- Set `CHAT_COMPLETIONS_API_VERSION=2024-05-01-preview`
When this env var is set, Routstr automatically appends `api-version=2024-05-01-preview` to all upstream `/chat/completions` requests.
### Async Operations
For high-performance applications:
```python
import asyncio
from openai import AsyncOpenAI
async_client = AsyncOpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1"
)
async def process_messages(messages):
tasks = []
for msg in messages:
task = async_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": msg}]
)
tasks.append(task)
responses = await asyncio.gather(*tasks)
return [r.choices[0].message.content for r in responses]
# Run async
messages = ["Hello", "How are you?", "What's 2+2?"]
results = asyncio.run(process_messages(messages))
```
## Best Practices
### 1. Environment Variables
Never hardcode API keys:
```python
import os
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("ROUTSTR_API_KEY"),
base_url=os.getenv("ROUTSTR_BASE_URL", "https://api.routstr.com/v1")
)
```
### 2. Error Logging
Implement comprehensive logging:
```python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
response = client.chat.completions.create(...)
logger.info(f"Request successful. Tokens used: {response.usage.total_tokens}")
except Exception as e:
logger.error(f"API request failed: {e}")
raise
```
### 3. Cost Tracking
Monitor your usage:
```python
class UsageTracker:
def __init__(self):
self.total_tokens = 0
self.total_requests = 0
def track(self, response):
self.total_tokens += response.usage.total_tokens
self.total_requests += 1
# Estimate cost (example rates)
cost_per_1k = 0.002 # $0.002 per 1K tokens
estimated_cost = (self.total_tokens / 1000) * cost_per_1k
logger.info(f"Total usage: {self.total_tokens} tokens, "
f"${estimated_cost:.4f} (~{estimated_cost * 50000:.0f} sats)")
tracker = UsageTracker()
response = client.chat.completions.create(...)
tracker.track(response)
```
### 4. Caching Responses
Reduce costs with intelligent caching:
```python
import hashlib
import json
from functools import lru_cache
@lru_cache(maxsize=100)
def cached_completion(prompt: str, model: str = "gpt-3.5-turbo"):
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0 # Deterministic for caching
)
return response.choices[0].message.content
# Repeated calls with same prompt use cache
result1 = cached_completion("What is 2+2?")
result2 = cached_completion("What is 2+2?") # From cache, no API call
```
## Testing
### Mock Responses
For development without spending sats:
```python
class MockOpenAI:
class Completions:
def create(self, **kwargs):
return type('Response', (), {
'choices': [type('Choice', (), {
'message': type('Message', (), {
'content': 'Mock response'
})()
})],
'usage': type('Usage', (), {
'total_tokens': 10
})()
})()
def __init__(self):
self.chat = type('Chat', (), {
'completions': self.Completions()
})()
# Use mock in tests
if os.getenv('TESTING'):
client = MockOpenAI()
else:
client = OpenAI(...)
```
### Integration Tests
Test your Routstr integration:
```python
def test_routstr_connection():
try:
# Test models endpoint
models = client.models.list()
assert len(models.data) > 0
# Test simple completion
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "test"}],
max_tokens=5
)
assert response.choices[0].message.content
print("✅ Routstr integration working!")
return True
except Exception as e:
print(f"❌ Integration test failed: {e}")
return False
```
## Troubleshooting
### Common Issues
**SSL Certificate Errors**
```python
# For development only - not for production!
import ssl
import httpx
client = OpenAI(
api_key="sk-...",
base_url="https://localhost:8000/v1",
http_client=httpx.Client(verify=False)
)
```
**Timeout Issues**
```python
# Increase timeout for slow connections
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1",
timeout=httpx.Timeout(120.0) # 2 minutes
)
```
**Debugging Requests**
```python
import logging
import httpx
# Enable debug logging
logging.basicConfig(level=logging.DEBUG)
httpx_logger = logging.getLogger("httpx")
httpx_logger.setLevel(logging.DEBUG)
```
## Next Steps
- [Admin Dashboard](admin-dashboard.md) - Manage your account
- [Models & Pricing](models-pricing.md) - Understanding costs
- [API Reference](../api/overview.md) - Technical details

View File

@@ -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")

View File

@@ -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")

105
mkdocs.yml Normal file
View File

@@ -0,0 +1,105 @@
site_name: Routstr Core Documentation
site_url: https://docs.routstr.com
site_description: FastAPI-based reverse proxy for OpenAI-compatible APIs with Bitcoin eCash micropayments
site_author: Routstr Team
repo_name: routstr/routstr-core
repo_url: https://github.com/routstr/routstr-core
edit_uri: tree/main/docs
theme:
name: material
language: en
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: orange
accent: amber
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: orange
accent: amber
toggle:
icon: material/brightness-4
name: Switch to light mode
features:
- navigation.tabs
- navigation.sections
- navigation.expand
- navigation.top
- navigation.indexes
- search.suggest
- search.highlight
- content.tabs.link
- content.code.copy
- content.code.annotate
icon:
logo: material/lightning-bolt
repo: fontawesome/brands/github
plugins:
- search
- mermaid2
markdown_extensions:
- admonition
- codehilite:
guess_lang: false
- toc:
permalink: true
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.details
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.tasklist:
custom_checkbox: true
- attr_list
- md_in_html
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/routstr
- icon: fontawesome/brands/bitcoin
link: https://cashu.space
nav:
- Home: index.md
- Getting Started:
- Overview: getting-started/overview.md
- Quick Start: getting-started/quickstart.md
- Docker Setup: getting-started/docker.md
- Configuration: getting-started/configuration.md
- User Guide:
- Introduction: user-guide/introduction.md
- Payment Flow: user-guide/payment-flow.md
- Using the API: user-guide/using-api.md
- Admin Dashboard: user-guide/admin-dashboard.md
- Models & Pricing: user-guide/models-pricing.md
- Contributing:
- Setup Development: contributing/setup.md
- Architecture: contributing/architecture.md
- Code Structure: contributing/code-structure.md
- Testing: contributing/testing.md
- API Reference:
- Overview: api/overview.md
- Authentication: api/authentication.md
- Endpoints: api/endpoints.md
- Errors: api/errors.md
- Advanced:
- Tor Support: advanced/tor.md
- Nostr Discovery: advanced/nostr.md
- Custom Pricing: advanced/custom-pricing.md
- Migrations: advanced/migrations.md

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.1.1b"
version = "0.1.3"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -16,6 +16,9 @@ dependencies = [
"cashu",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
"mdurl==0.1.2",
]
[dependency-groups]

View File

@@ -1,7 +1,3 @@
import dotenv
dotenv.load_dotenv()
from .core.main import app as fastapi_app # noqa
__all__ = ["fastapi_app"]

View File

@@ -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)",
@@ -436,6 +432,40 @@ async def adjust_payment_for_tokens(
"max_cost": cost.total_msats,
},
)
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
else:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"new_balance": key.balance,
"model": model,
},
)
return cost.dict()
case CostData() as cost:
@@ -459,15 +489,27 @@ async def adjust_payment_for_tokens(
if cost_difference == 0:
logger.debug(
"No cost adjustment needed",
"Finalizing with exact reserved cost",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
if cost_difference > 0:
# Need to charge more
# Need to charge more than reserved, finalize by releasing reservation and charging total
logger.info(
"Additional charge required for token usage",
extra={
@@ -479,56 +521,41 @@ async def adjust_payment_for_tokens(
},
)
# this should never happen why do we handle this???
if key.balance < cost_difference:
logger.warning(
"Insufficient balance for token-based pricing adjustment",
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Finalized payment with additional charge",
extra={
"key_hash": key.hashed_key[:8] + "...",
"required": cost_difference,
"available": key.balance,
"shortfall": cost_difference - key.balance,
"charged_amount": total_cost_msats,
"new_balance": key.balance,
"model": model,
},
)
await session.commit()
else:
# this should never happen why do we handle this???
charge_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) >= cost_difference)
.values(
balance=col(ApiKey.balance) - cost_difference,
total_spent=col(ApiKey.total_spent) + cost_difference,
)
logger.warning(
"Failed to finalize additional charge (concurrent operation)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
result = await session.exec(charge_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = deducted_max_cost + cost_difference
await session.refresh(key)
logger.info(
"Additional charge applied successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"charged_amount": cost_difference,
"new_balance": key.balance,
"total_cost": cost.total_msats,
"model": model,
},
)
else:
logger.warning(
"Failed to apply additional charge (concurrent operation)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": cost_difference,
"model": model,
},
)
else:
# Refund some of the base cost
refund = abs(cost_difference)
@@ -606,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,
}

View File

@@ -1,3 +1,6 @@
import asyncio
import hashlib
from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
@@ -5,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")
@@ -30,9 +34,22 @@ 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,
}
# TODO: Implement POST /v1/wallet/create endpoint
# This endpoint should accept:
# - cashu_token (required): The eCash token to deposit
# - refund_lnurl (optional): LNURL for refunds (instead of refund_address in validate_bearer_key)
# - refund_expiry (optional): Expiry timestamp for the key (maps to key_expiry_time in validate_bearer_key)
# The endpoint should:
# 1. Create a new wallet/API key from the cashu_token
# 2. Store refund_lnurl and refund_expiry in the database
# 3. Return the API key (rstr_...) and balance
# Note: validate_bearer_key already supports refund_address and key_expiry_time params
@router.get("/create")
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
@@ -49,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,
}
@@ -86,11 +104,52 @@ async def topup_wallet_endpoint(
return {"msats": amount_msats}
_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]]] = {}
def _cache_key_for_authorization(authorization: str) -> str:
return hashlib.sha256(authorization.strip().encode()).hexdigest()
async def _refund_cache_get(authorization: str) -> dict[str, str] | None:
key = _cache_key_for_authorization(authorization)
async with _refund_cache_lock:
item = _refund_cache.get(key)
if item is None:
return None
expires_at, value = item
if expires_at <= monotonic():
del _refund_cache[key]
return None
return value
async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
key = _cache_key_for_authorization(authorization)
expiry = monotonic() + _REFUND_CACHE_TTL_SECONDS
async with _refund_cache_lock:
_refund_cache[key] = (expiry, value)
@router.post("/refund")
async def refund_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
authorization: Annotated[str, Header(...)],
session: AsyncSession = Depends(get_session),
) -> dict:
) -> dict[str, str]:
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
bearer_value: str = authorization[7:]
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
remaining_balance_msats: int = key.balance
if remaining_balance_msats <= 0:
@@ -100,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}
@@ -141,6 +202,8 @@ async def refund_wallet_endpoint(
else:
raise HTTPException(status_code=500, detail="Refund failed")
await _refund_cache_set(bearer_value, result)
await session.delete(key)
await session.commit()

View File

@@ -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"""<div class="currency-row {"error-row" if detail.get("error") else ""}">
<div class="mint-name">{detail["mint_url"].replace("https://", "").replace("http://", "")}{detail["unit"].upper()}</div>
<div class="balance-num">{detail["wallet_balance"] if not detail.get("error") else "error"}</div>
<div class="balance-num">{detail["user_balance"] if not detail.get("error") else "-"}</div>
<div class="balance-num {"owner-positive" if detail["owner_balance"] > 0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}</div>
</div>"""
for detail in balance_details
if detail.get("wallet_balance", 0) > 0 or detail.get("error")
]
)
return f"""
<h2>Cashu Wallet Balance</h2>
<div class="balance-item">
<span class="balance-label">Your Balance (Total)</span>
<span class="balance-value balance-primary">{owner_balance} sats</span>
</div>
<div class="balance-item">
<span class="balance-label">Total Wallet</span>
<span class="balance-value">{total_wallet_balance_sats} sats</span>
</div>
<div class="balance-item">
<span class="balance-label">User Balance</span>
<span class="balance-value">{total_user_balance_sats} sats</span>
</div>
<p style="margin-top: 1rem; font-size: 0.9rem; color: #718096;">Your balance = Total wallet - User balance</p>
<div class="currency-grid">
<div class="currency-row currency-header">
<div>Mint / Unit</div>
<div class="balance-num">Wallet</div>
<div class="balance-num">Users</div>
<div class="balance-num">Owner</div>
</div>
{rows}
</div>
<script>balanceDetails = {json.dumps(balance_details)};</script>
"""
@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"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
for key in api_keys
]
)
return f"""
<h2>Temporary Balances</h2>
<table>
<tr>
<th>Hashed Key</th>
<th>Balance (mSats)</th>
<th>Total Spent (mSats)</th>
<th>Total Requests</th>
<th>Refund Address</th>
<th>Refund Time</th>
</tr>
{rows}
</table>
"""
@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"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}</td></tr>"
)
# 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"""<!DOCTYPE html>
return (
f"""<!DOCTYPE html>
<html>
<head>
<style>
* {{ 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; }}
</style>
<style>{DASHBOARD_CSS}</style>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
"""
+ """<!--html-->
<script>
const balanceDetails = {json.dumps(balance_details)};
let balanceDetails = [];
function openWithdrawModal() {{
async function openWithdrawModal() {
const modal = document.getElementById('withdraw-modal');
updateWithdrawForm();
try {
if (!balanceDetails.length) {
const resp = await fetch('/admin/api/balances', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
balanceDetails = await resp.json();
}
const select = document.getElementById('mint-unit-select');
select.innerHTML = '';
balanceDetails
.filter(d => !d.error && d.wallet_balance > 0)
.forEach(d => {
const opt = document.createElement('option');
opt.value = `${d.mint_url}|${d.unit}`;
opt.textContent = `${d.mint_url.replace("https://", "").replace("http://", "")} • ${d.unit.toUpperCase()} (${d.owner_balance})`;
select.appendChild(opt);
});
updateWithdrawForm();
} catch (e) {
alert('Failed to load balances: ' + e.message);
}
modal.style.display = 'block';
}}
}
function closeWithdrawModal() {{
function closeWithdrawModal() {
const modal = document.getElementById('withdraw-modal');
modal.style.display = 'none';
}}
}
function updateWithdrawForm() {{
function updateWithdrawForm() {
const select = document.getElementById('mint-unit-select');
const selectedValue = select.value;
if (!selectedValue) return;
const [mint, unit] = selectedValue.split('|');
const detail = balanceDetails.find(d => d.mint_url === mint && d.unit === unit);
if (detail) {{
if (detail) {
const amountInput = document.getElementById('withdraw-amount');
const maxSpan = document.getElementById('max-amount');
const recommendedSpan = document.getElementById('recommended-amount');
amountInput.max = detail.wallet_balance;
amountInput.value = detail.owner_balance > 0 ? detail.owner_balance : 0;
maxSpan.textContent = `${{detail.wallet_balance}} ${{unit}}`;
recommendedSpan.textContent = `${{detail.owner_balance}} ${{unit}}`;
checkAmount();
}}
}}
maxSpan.textContent = `${detail.wallet_balance} ${unit}`;
recommendedSpan.textContent = `${detail.owner_balance} ${unit}`;
function checkAmount() {{
checkAmount();
}
}
function checkAmount() {
const select = document.getElementById('mint-unit-select');
const selectedValue = select.value;
if (!selectedValue) return;
const [mint, unit] = selectedValue.split('|');
const detail = balanceDetails.find(d => d.mint_url === mint && d.unit === unit);
if (detail) {{
if (detail) {
const amount = parseInt(document.getElementById('withdraw-amount').value) || 0;
const warning = document.getElementById('withdraw-warning');
if (amount > detail.owner_balance && amount <= detail.wallet_balance) {{
warning.style.display = 'block';
}} else {{
warning.style.display = 'none';
}}
}}
}}
async function performWithdraw() {{
if (amount > detail.owner_balance && amount <= detail.wallet_balance) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
}
}
async function performWithdraw() {
const amount = parseInt(document.getElementById('withdraw-amount').value);
const select = document.getElementById('mint-unit-select');
const selectedValue = select.value;
const button = document.getElementById('confirm-withdraw-btn');
const tokenResult = document.getElementById('token-result');
if (!selectedValue) {{
if (!selectedValue) {
alert('Please select a mint and unit');
return;
}}
}
const [mint, unit] = selectedValue.split('|');
const detail = balanceDetails.find(d => d.mint_url === mint && d.unit === unit);
if (!amount || amount <= 0) {{
if (!amount || amount <= 0) {
alert('Please enter a valid amount');
return;
}}
if (amount > detail.wallet_balance) {{
}
if (amount > detail.wallet_balance) {
alert('Amount exceeds wallet balance');
return;
}}
}
button.disabled = true;
button.textContent = 'Withdrawing...';
try {{
const response = await fetch('/admin/withdraw', {{
try {
const response = await fetch('/admin/withdraw', {
method: 'POST',
headers: {{
headers: {
'Content-Type': 'application/json',
}},
},
credentials: 'same-origin',
body: JSON.stringify({{
body: JSON.stringify({
amount: amount,
mint_url: mint,
unit: unit
}})
}});
if (response.ok) {{
})
});
if (response.ok) {
const data = await response.json();
document.getElementById('token-text').textContent = data.token;
tokenResult.style.display = 'block';
closeWithdrawModal();
}} else {{
} else {
const errorData = await response.json();
alert('Failed to withdraw balance: ' + (errorData.detail || 'Unknown error'));
}}
}} catch (error) {{
}
} catch (error) {
alert('Error: ' + error.message);
}} finally {{
} finally {
button.disabled = false;
button.textContent = 'Withdraw';
}}
}}
}
}
function copyToken() {{
function copyToken() {
const tokenText = document.getElementById('token-text');
navigator.clipboard.writeText(tokenText.textContent).then(() => {{
navigator.clipboard.writeText(tokenText.textContent).then(() => {
const copyBtn = document.getElementById('copy-btn');
const originalText = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
setTimeout(() => {{
setTimeout(() => {
copyBtn.textContent = originalText;
}}, 2000);
}}).catch(err => {{
}, 2000);
}).catch(err => {
alert('Failed to copy token');
}});
}}
});
}
function refreshPage() {{
function refreshPage() {
window.location.reload();
}}
}
function openInvestigateModal() {{
function openInvestigateModal() {
const modal = document.getElementById('investigate-modal');
modal.style.display = 'block';
}}
}
function closeInvestigateModal() {{
function closeInvestigateModal() {
const modal = document.getElementById('investigate-modal');
modal.style.display = 'none';
}}
}
function investigateLogs() {{
function investigateLogs() {
const requestId = document.getElementById('request-id').value.trim();
if (!requestId) {{
if (!requestId) {
alert('Please enter a Request ID');
return;
}}
window.location.href = `/admin/logs/${{requestId}}`;
}}
}
window.location.href = `/admin/logs/${requestId}`;
}
window.onclick = function(event) {{
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');
if (event.target == withdrawModal) {{
const settingsModal = document.getElementById('settings-modal');
if (event.target == withdrawModal) {
closeWithdrawModal();
}} else if (event.target == investigateModal) {{
} else if (event.target == investigateModal) {
closeInvestigateModal();
}}
}}
} else if (event.target == settingsModal) {
closeSettingsModal();
}
}
</script>
</head>
</head>
"""
+ """<!--html-->
<body>
<h1>Admin Dashboard</h1>
<div class="balance-card">
<h2>Cashu Wallet Balance</h2>
<div class="balance-item">
<span class="balance-label">Your Balance (Total)</span>
<span class="balance-value balance-primary">{
owner_balance
} sats</span>
</div>
<div class="balance-item">
<span class="balance-label">Total Wallet</span>
<span class="balance-value">{total_wallet_balance_sats} sats</span>
</div>
<div class="balance-item">
<span class="balance-label">User Balance</span>
<span class="balance-value">{total_user_balance_sats} sats</span>
</div>
<p style="margin-top: 1rem; font-size: 0.9rem; color: #718096;">Your balance = Total wallet - User balance</p>
<div class="currency-grid">
<div class="currency-row currency-header">
<div>Mint / Unit</div>
<div class="balance-num">Wallet</div>
<div class="balance-num">Users</div>
<div class="balance-num">Owner</div>
</div>
{
"".join(
[
f'''<div class="currency-row {"error-row" if detail.get("error") else ""}">
<div class="mint-name">{detail["mint_url"].replace("https://", "").replace("http://", "")}{detail["unit"].upper()}</div>
<div class="balance-num">{detail["wallet_balance"] if not detail.get("error") else "error"}</div>
<div class="balance-num">{detail["user_balance"] if not detail.get("error") else "-"}</div>
<div class="balance-num {"owner-positive" if detail["owner_balance"] > 0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}</div>
</div>'''
for detail in balance_details
if detail.get("wallet_balance", 0) > 0 or detail.get("error")
]
)
}
</div>
<div class="balance-card" id="balances-card"
hx-get="/admin/partials/balances"
hx-trigger="load"
hx-swap="innerHTML">
<div style="color:#718096;">Loading balances…</div>
</div>
<button id="withdraw-btn" onclick="openWithdrawModal()" {
"disabled" if total_wallet_balance_sats <= 0 else ""
}>
<button id="withdraw-btn" onclick="openWithdrawModal()">
💸 Withdraw Balance
</button>
<button class="refresh-btn" onclick="refreshPage()">
@@ -387,23 +529,16 @@ async def dashboard(request: Request) -> str:
<button class="investigate-btn" onclick="openInvestigateModal()">
🔍 Investigate Logs
</button>
<button onclick="openSettingsModal()">
⚙️ Settings
</button>
<div id="withdraw-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeWithdrawModal()">&times;</span>
<h3>Withdraw Balance</h3>
<p>Select mint and currency:</p>
<select id="mint-unit-select" onchange="updateWithdrawForm()">
{
"".join(
[
f'<option value="{detail["mint_url"]}|{detail["unit"]}">{detail["mint_url"].replace("https://", "").replace("http://", "")}{detail["unit"].upper()} ({detail["owner_balance"]})</option>'
for detail in balance_details
if not detail.get("error") and detail["owner_balance"] > 0
]
)
}
</select>
<select id="mint-unit-select" onchange="updateWithdrawForm()"></select>
<p>Enter amount to withdraw:</p>
<input type="number" id="withdraw-amount" min="1" placeholder="Amount" oninput="checkAmount()">
<p>Maximum: <span id="max-amount">-</span></p>
@@ -416,6 +551,20 @@ async def dashboard(request: Request) -> str:
</div>
</div>
<div id="settings-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeSettingsModal()">&times;</span>
<h3>Edit Settings (JSON)</h3>
<p style="font-size: 0.9rem; color: #718096; margin-bottom: 8px;">Values shown as "[REDACTED]" will remain unchanged if left as-is.</p>
<textarea id="settings-json" placeholder="{{}}" style="width: 100%; min-height: 280px; font-family: 'Monaco', monospace; font-size: 13px; background: #f8fafc; color: #2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<div id="settings-error" style="display: none; margin-top: 8px; font-size: 0.95rem; color: #e53e3e;"></div>
<div style="margin-top: 12px; display: flex; gap: 10px;">
<button onclick="saveSettings()">💾 Save</button>
<button onclick="closeSettingsModal()" style="background-color: #718096;">Cancel</button>
</div>
</div>
</div>
<div id="investigate-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeInvestigateModal()">&times;</span>
@@ -434,35 +583,29 @@ async def dashboard(request: Request) -> str:
<p><em>Save this token! It represents your withdrawn balance.</em></p>
</div>
<h2>Temporary Balances</h2>
<table>
<tr>
<th>Hashed Key</th>
<th>Balance (mSats)</th>
<th>Total Spent (mSats)</th>
<th>Total Requests</th>
<th>Refund Address</th>
<th>Refund Time</th>
</tr>
{"".join(api_keys_table_rows)}
</table>
<div id="apikeys-table"
hx-get="/admin/partials/apikeys"
hx-trigger="load"
hx-swap="outerHTML">
<h2>Temporary Balances</h2>
<div style="color:#718096;">Loading API keys…</div>
</div>
</body>
</html>
"""
)
@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"):
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")
if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"):
if not is_admin_authenticated(request):
return admin_auth()
logger.info(f"Investigating logs for request_id: {request_id}")
@@ -545,99 +688,17 @@ async def view_logs(request: Request, request_id: str) -> str:
formatted_entry += "</div>"
formatted_logs.append(formatted_entry)
return f"""<!DOCTYPE html>
return (
f"""<!DOCTYPE html>
<html>
<head>
<style>
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;
}}
</style>
</head>
{LOGS_CSS}
</style>
</head>
</head>
"""
+ f"""<!--html-->
<body>
<a href="/admin" class="back-btn">← Back to Dashboard</a>
<h1>Log Investigation</h1>
@@ -653,23 +714,22 @@ async def view_logs(request: Request, request_id: str) -> str:
</body>
</html>
"""
)
@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; }
"""

View File

@@ -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:

View File

@@ -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:
@@ -293,14 +303,19 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"websockets": {
"level": "WARNING",
"handlers": [],
"propagate": False,
},
"uvicorn.access": {
"level": log_level, # Use the configured log level instead of WARNING
"handlers": handlers, # Use both console and file handlers
"level": "WARNING",
"handlers": ["file"],
"propagate": False,
},
"uvicorn.error": {
"level": log_level, # Use the configured log level
"handlers": handlers, # Use both console and file handlers
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},

View File

@@ -1,5 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@@ -9,21 +8,29 @@ from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
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.1b"
__version__ = "0.1.3"
@asynccontextmanager
@@ -32,6 +39,9 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = None
payout_task = None
nip91_task = None
providers_task = None
models_refresh_task = None
try:
# Run database migrations on startup
@@ -44,8 +54,24 @@ 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())
yield
@@ -62,6 +88,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not 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 = []
@@ -69,6 +101,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not 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)
@@ -80,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=["*"],
@@ -110,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
}

307
routstr/core/settings.py Normal file
View File

@@ -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

View File

@@ -1,6 +1,5 @@
import asyncio
import json
import os
import random
import string
from typing import Any
@@ -10,11 +9,16 @@ import websockets
from fastapi import APIRouter
from .core.logging import get_logger
from .core.settings import settings
logger = get_logger(__name__)
providers_router = APIRouter(prefix="/v1/providers")
# In-memory providers cache and lock
_PROVIDERS_CACHE: list[dict[str, Any]] = []
_PROVIDERS_CACHE_LOCK = asyncio.Lock()
def generate_subscription_id() -> str:
"""Generate a random subscription ID."""
@@ -28,14 +32,14 @@ async def query_nostr_relay_for_providers(
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for provider announcements using RIP-02 spec.
Searches for kind 31338 events (Routstr Provider Announcements).
Query a Nostr relay for provider announcements.
Searches for NIP-91 (kind:38421) events.
"""
events = []
# Build filter according to RIP-02 spec
# Build filter for NIP-91 events
filter_obj: dict[str, Any] = {
"kinds": [31338], # RIP-02 Provider Announcement events
"kinds": [38421], # NIP-91 Provider Announcements
"limit": limit,
}
@@ -47,8 +51,8 @@ async def query_nostr_relay_for_providers(
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for kind 31338 events")
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)")
await websocket.send(req_message)
while True:
@@ -64,7 +68,13 @@ async def query_nostr_relay_for_providers(
logger.debug("Received EOSE message")
break
elif data[0] == "NOTICE":
logger.warning(f"Relay notice: {data[1]}")
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")
except asyncio.TimeoutError:
logger.debug("Timeout waiting for message")
@@ -76,7 +86,7 @@ async def query_nostr_relay_for_providers(
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
logger.error(f"Query failed: {e}")
logger.debug(f"Query failed: {type(e).__name__}")
logger.info(f"Query complete. Found {len(events)} provider announcements")
return events
@@ -84,17 +94,19 @@ async def query_nostr_relay_for_providers(
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
"""
Parse a kind 31338 provider announcement event according to RIP-02 spec.
Parse provider announcement events.
Handles NIP-91 (kind:38421) format.
Returns structured provider data or None if invalid.
"""
try:
# Extract required tags according to RIP-02
tags = event.get("tags", [])
kind = event.get("kind")
# Find required tags
endpoint_url = None
provider_name = None
# Common fields
d_tag = None
endpoint_urls = []
provider_name = None
endpoint_url = None
for tag in tags:
if len(tag) >= 2:
@@ -105,8 +117,8 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
elif tag[0] == "d":
d_tag = tag[1]
# Validate required fields
if not endpoint_url or not provider_name or not d_tag:
# Early validation only applies to legacy/other kinds, not NIP-91
if kind != 38421 and (not endpoint_url or not provider_name or not d_tag):
logger.warning(
f"Invalid provider announcement - missing required tags: {event['id']}"
)
@@ -114,63 +126,229 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
# Extract optional tags
description = None
contact = None
pricing_url = None
supported_models = []
mint_urls = []
version = None
for tag in tags:
if len(tag) >= 2:
if tag[0] == "description":
description = tag[1]
elif tag[0] == "contact":
contact = tag[1]
elif tag[0] == "pricing":
pricing_url = tag[1]
elif tag[0] == "model":
supported_models.append(tag[1])
# Parse NIP-91 format
if kind == 38421: # NIP-91 format
for tag in tags:
if len(tag) >= 2:
if tag[0] == "d":
d_tag = tag[1]
elif tag[0] == "u":
endpoint_urls.append(tag[1])
elif tag[0] == "mint":
mint_urls.append(tag[1])
elif tag[0] == "version":
version = tag[1]
# Parse metadata from content for NIP-91
content = event.get("content", "")
if content:
try:
metadata = json.loads(content)
provider_name = metadata.get("name", "Unknown Provider")
description = metadata.get("about")
except (json.JSONDecodeError, TypeError):
provider_name = "Unknown Provider"
else:
provider_name = "Unknown Provider"
# Use first URL as primary endpoint
endpoint_url = endpoint_urls[0] if endpoint_urls else None
# Validate NIP-91 required fields
if not endpoint_url or not d_tag:
logger.warning(
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
)
return None
else:
logger.warning(
f"Unknown event kind when parsing provider announcement: {kind}"
)
return None
return {
"id": event["id"],
"id": d_tag,
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"d_tag": d_tag,
"kind": kind,
"endpoint_url": endpoint_url,
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
"name": provider_name,
"description": description,
"contact": contact,
"pricing_url": pricing_url,
"supported_models": supported_models,
"mint_urls": mint_urls,
"version": version,
"content": event.get("content", ""),
}
except Exception as e:
logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
logger.error(
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
)
return None
async def get_cache() -> list[dict[str, Any]]:
return [] # TODO: Implement cache
async with _PROVIDERS_CACHE_LOCK:
return list(_PROVIDERS_CACHE)
def _get_discovery_relays() -> list[str]:
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 relays
async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]:
discovery_relays = _get_discovery_relays()
tasks = [
query_nostr_relay_for_providers(relay_url=r, pubkey=pubkey, limit=100)
for r in discovery_relays
]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_events: list[dict[str, Any]] = []
event_ids: set[str] = set()
for res in results:
if isinstance(res, BaseException):
logger.error(f"Relay query failed: {res}")
continue
if isinstance(res, list):
for event in res:
# Filter out localhost announcements
try:
tags = event.get("tags", [])
is_localhost = any(
isinstance(tag, list)
and len(tag) >= 2
and tag[0] == "u"
and tag[1] == "http://localhost:8000"
for tag in tags
)
if is_localhost:
logger.debug(
f"Skipping localhost provider event: {event.get('id', 'unknown')}"
)
continue
except Exception:
# If tags are malformed, fall through to normal handling
pass
if (eid := event.get("id")) and eid not in event_ids:
event_ids.add(eid)
all_events.append(event)
else:
logger.error(f"Unexpected relay result type: {type(res)}")
providers: list[dict[str, Any]] = []
seen_endpoints: set[str] = set()
for event in all_events:
parsed = parse_provider_announcement(event)
if parsed and (eu := parsed.get("endpoint_url")) and eu not in seen_endpoints:
seen_endpoints.add(eu)
providers.append(parsed)
random.shuffle(providers)
return providers[:42]
async def refresh_providers_cache(pubkey: str | None = None) -> None:
try:
providers = await _discover_providers(pubkey=pubkey)
health_tasks = [
fetch_provider_health(provider["endpoint_url"]) for provider in providers
]
health_results = await asyncio.gather(*health_tasks, return_exceptions=True)
new_cache: list[dict[str, Any]] = []
for provider, hr in zip(providers, health_results):
if isinstance(hr, Exception):
health: dict[str, Any] = {
"status_code": 500,
"endpoint": "error",
"json": {"error": str(hr)},
}
else:
health = hr # type: ignore[assignment]
new_cache.append({"provider": provider, "health": health})
async with _PROVIDERS_CACHE_LOCK:
_PROVIDERS_CACHE.clear()
_PROVIDERS_CACHE.extend(new_cache)
logger.info(
f"Providers cache refreshed with {len(new_cache)} entries (limit 42)"
)
except Exception as e:
logger.error(f"Failed to refresh providers cache: {e}")
async def providers_cache_refresher(
interval_seconds: int | None = None, pubkey: str | None = None
) -> None:
if interval_seconds is None:
try:
interval_seconds = settings.providers_refresh_interval_seconds
except Exception:
interval_seconds = 300
await refresh_providers_cache(pubkey=pubkey)
while True:
try:
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
break
await refresh_providers_cache(pubkey=pubkey)
async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
"""Check if a provider endpoint is healthy by making a GET request."""
"""Fetch provider health and info, preferring /v1/info for models and pricing."""
try:
# Determine if we need Tor proxy based on .onion domain
is_onion = ".onion" in endpoint_url
# Set up client arguments conditionally
proxies = None
proxies: dict[str, str] | None = None
if is_onion:
# Get Tor proxy URL from environment variable
tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050")
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
try:
tor_proxy = settings.tor_proxy_url
except Exception:
tor_proxy = "socks5://127.0.0.1:9050"
proxies = {"http://": tor_proxy, "https://": tor_proxy}
async with httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
follow_redirects=True,
proxies=proxies, # type: ignore[arg-type]
# NOTE: httpx < 0.28 supports the 'proxies' kwarg, 0.28+ removed it.
# We ignore the call-arg type here to keep compatibility across versions.
proxies=proxies, # type: ignore[call-arg]
) as client:
# Try to fetch models endpoint first (common for AI providers)
# Prefer provider's /v1/info for full details
info_url = f"{endpoint_url.rstrip('/')}/v1/info"
try:
response = await client.get(info_url)
if response.status_code == 200:
return {
"status_code": response.status_code,
"endpoint": "info",
"json": response.json(),
}
except Exception:
pass
# Fallback to /v1/models
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
try:
response = await client.get(models_url)
@@ -208,65 +386,16 @@ async def get_providers(
include_json: bool = False, pubkey: str | None = None
) -> dict[str, list[dict[str, Any]]]:
"""
Discover Routstr providers using RIP-02 specification.
Searches for kind 31338 provider announcement events on Nostr relays.
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
Return cached providers. If include_json, return provider+health; otherwise provider only.
Optional filter by pubkey.
"""
# Default relays for provider discovery
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
all_events = []
event_ids = set() # To avoid duplicates
# Query multiple relays for provider announcements
for relay_url in discovery_relays:
logger.info(f"Querying relay for providers: {relay_url}")
try:
events = await query_nostr_relay_for_providers(
relay_url=relay_url,
pubkey=pubkey,
limit=100,
)
# Add unique events
for event in events:
if event["id"] not in event_ids:
event_ids.add(event["id"])
all_events.append(event)
logger.info(f"Got {len(events)} provider announcements from {relay_url}")
except Exception as e:
logger.error(f"Failed to query {relay_url}: {e}")
continue
logger.info(f"Found {len(all_events)} total unique provider announcements")
# Parse provider announcements according to RIP-02
providers = []
for event in all_events:
parsed_provider = parse_provider_announcement(event)
if parsed_provider:
providers.append(parsed_provider)
logger.info(f"Parsed {len(providers)} valid provider announcements")
# Check provider health if requested
healthy_providers: list[dict[str, Any]] = []
for provider in providers:
endpoint_url = provider["endpoint_url"]
if include_json:
health_check = await fetch_provider_health(endpoint_url)
provider_data = {"provider": provider, "health": health_check}
healthy_providers.append(provider_data)
else:
# Just return the provider info without health check
healthy_providers.append(provider)
return {"providers": healthy_providers}
cache = await get_cache()
if not cache:
await refresh_providers_cache(pubkey=pubkey)
cache = await get_cache()
if pubkey:
cache = [c for c in cache if c.get("provider", {}).get("pubkey") == pubkey]
if include_json:
return {"providers": cache}
providers_only = [c["provider"] for c in cache]
return {"providers": providers_only}

574
routstr/nip91.py Normal file
View File

@@ -0,0 +1,574 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
import asyncio
import json
import os
import random
import ssl
import time
from typing import Any, cast
from nostr.event import Event
from nostr.filter import Filter, Filters
from nostr.key import PrivateKey
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__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
return imported_version
except Exception:
return None
def _event_to_dict(ev: Event) -> dict[str, Any]:
return {
"id": ev.id,
"pubkey": ev.public_key,
"created_at": ev.created_at,
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
"tags": ev.tags,
"content": ev.content,
"sig": ev.signature,
}
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
"""
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
Args:
nsec: Nostr private key in nsec format or hex format
Returns:
Tuple of (private_key_hex, public_key_hex) or None if invalid
"""
try:
if nsec.startswith("nsec"):
pk = PrivateKey.from_nsec(nsec)
return (pk.hex(), pk.public_key.hex())
if len(nsec) == 64:
pk = PrivateKey(bytes.fromhex(nsec))
return (pk.hex(), pk.public_key.hex())
logger.error(f"Invalid private key format/length: {len(nsec)}")
return None
except Exception as e:
logger.error(f"Failed to convert nsec to keypair: {e}")
return None
def create_nip91_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
mint_urls: list[str] | None = None,
version: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
provider_id: Unique identifier for this provider (d tag)
endpoint_urls: List of URLs to connect to the provider
mint_urls: Optional list of ecash mint URLs for payments
version: Provider software version
metadata: Optional metadata dictionary (name, picture, about, etc.)
Returns:
Complete signed nostr event as a dict ready for publishing
"""
pk = PrivateKey(bytes.fromhex(private_key_hex))
tags = [["d", provider_id]]
for url in endpoint_urls:
tags.append(["u", url])
if mint_urls:
for m in mint_urls:
if m:
tags.append(["mint", m])
if version:
tags.append(["version", version])
content = json.dumps(metadata, separators=(",", ":")) if metadata else ""
ev = Event(pk.public_key.hex(), content, kind=38421, tags=tags)
pk.sign_event(ev)
return _event_to_dict(ev)
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
tags = event.get("tags", [])
values: list[str] = []
for tag in tags:
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
values.append(tag[1])
return values
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
values = _get_tag_values(event, key)
return values[0] if values else None
def _parse_content_json(content: str) -> dict[str, Any]:
if not content:
return {}
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
if a.get("kind") != b.get("kind"):
return False
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
return False
urls_a = set(_get_tag_values(a, "u"))
urls_b = set(_get_tag_values(b, "u"))
if urls_a != urls_b:
return False
mints_a = set(_get_tag_values(a, "mint"))
mints_b = set(_get_tag_values(b, "mint"))
if mints_a != mints_b:
return False
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
return False
content_a = _parse_content_json(cast(str, a.get("content", "")))
content_b = _parse_content_json(cast(str, b.get("content", "")))
if content_a != content_b:
return False
return True
async def query_nip91_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
"""
def _sync_query() -> tuple[list[dict[str, Any]], bool]:
rm = RelayManager()
rm.add_relay(relay_url)
events_out: list[dict[str, Any]] = []
ok = True
try:
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
time.sleep(1.0)
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
filters = Filters([flt])
sub_id = f"nip91_{int(time.time())}"
rm.add_subscription(sub_id, filters)
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
req.extend(filters.to_json_array())
rm.publish_message(json.dumps(req))
start = time.time()
last_event_ts = start
while time.time() - start < timeout:
drained = False
while rm.message_pool.has_events():
drained = True
ev_msg = rm.message_pool.get_event()
ev = ev_msg.event
ev_dict = _event_to_dict(ev)
if provider_id is not None:
tags = ev_dict.get("tags", [])
if not any(
isinstance(t, list)
and len(t) >= 2
and t[0] == "d"
and t[1] == provider_id
for t in tags
):
continue
events_out.append(ev_dict)
logger.debug(
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
)
if drained:
last_event_ts = time.time()
while rm.message_pool.has_notices():
notice = rm.message_pool.get_notice()
try:
content = getattr(notice, "content", notice)
s = str(content)
if len(s) > 200:
s = s[:200] + "..."
logger.debug(f"Relay notice: {s}")
except Exception:
pass
if time.time() - last_event_ts > 2.5:
break
time.sleep(0.1)
except Exception as e:
ok = False
logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}")
finally:
try:
rm.close_connections()
except Exception:
pass
return events_out, ok
return await asyncio.to_thread(_sync_query)
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
"""Discover onion URL by reading Tor hidden service hostname files.
Tries common paths first, then scans recursively for any 'hostname' file.
Returns an http URL like 'http://<host>.onion' if found.
"""
common_candidates = [
os.path.join(base_dir, "hs", "router", "hostname"),
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
os.path.join(base_dir, "hidden_service", "hostname"),
]
for candidate in common_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
pass
try:
for root, _dirs, files in os.walk(base_dir):
if "hostname" in files:
path = os.path.join(root, "hostname")
try:
with open(path, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
continue
except Exception:
pass
return None
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
explicit = settings.provider_id
if explicit:
logger.info(f"Using configured provider_id from env: {explicit}")
return explicit
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)
return events
except Exception:
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:
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
return existing_d
fallback = public_key_hex[:12]
logger.info(f"No existing provider_id found; using fallback: {fallback}")
return fallback
async def publish_to_relay(
relay_url: str,
event: dict[str, Any],
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay via nostr library.
"""
def _sync_publish() -> bool:
rm = RelayManager()
rm.add_relay(relay_url)
try:
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
time.sleep(1.0)
# Publish the event as-is via publish_message to preserve signature
rm.publish_message(json.dumps(["EVENT", event]))
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
time.sleep(1.0)
return True
except Exception as e:
logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}")
return False
finally:
try:
rm.close_connections()
except Exception:
pass
return await asyncio.to_thread(_sync_publish)
async def announce_provider() -> None:
"""
Background task to announce this Routstr provider to Nostr relays.
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment (use NSEC only)
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
return
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# 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}")
mint_urls = cashu_mints if cashu_mints else None
endpoint_urls: list[str] = []
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
endpoint_urls.append(base_url.strip())
if onion_url and onion_url.strip():
ou = onion_url.strip()
if ou.endswith(".onion") and not (
ou.startswith("http://") or ou.startswith("https://")
):
ou = f"http://{ou}"
endpoint_urls.append(ou)
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
)
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,
"about": provider_about,
}
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# 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] = {}
def _should_skip(relay: str) -> bool:
return time.time() < relay_next_allowed.get(relay, 0.0)
def _register_success(relay: str) -> None:
relay_current_delay[relay] = 0.0
relay_next_allowed[relay] = time.time()
def _register_failure(relay: str) -> None:
previous = relay_current_delay.get(relay, 0.0)
delay = backoff_base if previous <= 0.0 else min(backoff_max, previous * 2.0)
jitter = delay * backoff_jitter_ratio * (2.0 * random.random() - 1.0)
scheduled = time.time() + max(0.0, delay + jitter)
relay_current_delay[relay] = delay
relay_next_allowed[relay] = scheduled
logger.debug(
f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}"
)
# Fetch existing events for this provider_id
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
# Decide whether to publish: publish if none exist or any differ from candidate
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
if await publish_to_relay(relay_url, candidate_event):
_register_success(relay_url)
success_count += 1
else:
_register_failure(relay_url)
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
announcement_interval = 24 * 60 * 60
while True:
try:
await asyncio.sleep(announcement_interval)
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(
relay_url, public_key_hex, provider_id
)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
)
continue
logger.debug(
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
)
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
ok = await publish_to_relay(relay_url, candidate_event)
if ok:
_register_success(relay_url)
else:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
# Continue running despite errors

View File

@@ -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",

View File

@@ -1,24 +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", "")
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
@@ -87,49 +84,154 @@ 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)
max_tokens_raw = body.get("max_tokens", None)
if max_tokens_raw is not None:
try:
max_tokens_int = int(max_tokens_raw)
except (TypeError, ValueError):
logger.warning(
"Invalid max_tokens; ignoring in cost adjustment",
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
)
else:
estimated_completion_delta_sats = (
max_completion_allowed_sats - max_tokens_int * 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(
@@ -159,11 +261,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),
},
)
@@ -182,8 +285,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:
@@ -196,8 +299,19 @@ 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),
},
)
return headers
def prepare_upstream_params(
path: str, query_params: Mapping[str, str] | None
) -> dict[str, str]:
"""Prepare query params for upstream request, optionally adding api-version for chat/completions."""
params: dict[str, str] = dict(query_params or {})
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

View File

@@ -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}

View File

@@ -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:

View File

@@ -7,9 +7,15 @@ 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
from .helpers import (
create_error_response,
prepare_upstream_headers,
prepare_upstream_params,
)
logger = get_logger(__name__)
@@ -104,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",
@@ -128,7 +134,7 @@ async def forward_to_upstream(
url,
headers=headers,
content=request.stream(),
params=request.query_params,
params=prepare_upstream_params(path, request.query_params),
),
stream=True,
)
@@ -548,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:

View File

@@ -15,12 +15,14 @@ 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,
prepare_upstream_headers,
prepare_upstream_params,
)
from .payment.x_cashu import x_cashu_handler
@@ -42,106 +44,157 @@ async def handle_streaming_chat_completion(
)
async def stream_with_cost(max_cost_for_model: int) -> AsyncGenerator[bytes, None]:
# Store all chunks to analyze
stored_chunks = []
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
async for chunk in response.aiter_bytes():
# Store chunk for later analysis
stored_chunks.append(chunk)
async def finalize_without_usage() -> bytes | None:
nonlocal usage_finalized
if usage_finalized:
return None
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
return None
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
)
usage_finalized = True
logger.info(
"Finalized streaming payment without explicit usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
return f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error finalizing payment without usage",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
return None
# Pass through each chunk to client
yield chunk
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
# Opportunistically capture model id
try:
for part in re.split(b"data: ", chunk):
if not part or part.strip() in (b"[DONE]", b""):
continue
try:
obj = json.loads(part)
if isinstance(obj, dict) and obj.get("model"):
last_model_seen = str(obj.get("model"))
except json.JSONDecodeError:
pass
except Exception:
pass
logger.debug(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
yield chunk
# Process stored chunks to find usage data
# Start from the end and work backwards
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk or chunk == b"":
continue
logger.debug(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
try:
# Split by "data: " to get individual SSE events
events = re.split(b"data: ", chunk)
for event_data in events:
if (
not event_data
or event_data.strip() == b"[DONE]"
or event_data.strip() == b""
):
continue
# Process stored chunks to find usage data from the tail
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
data,
new_session,
max_cost_for_model,
)
usage_finalized = True
logger.info(
"Token adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8]
+ "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
yield f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error adjusting payment for streaming tokens",
extra={
"error": str(cost_error),
"error_type": type(
cost_error
).__name__,
"key_hash": key.hashed_key[:8]
+ "...",
},
)
break
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(
"Error processing streaming response chunk",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
try:
data = json.loads(event_data)
if (
"usage" in data
and data["usage"] is not None
and isinstance(data["usage"], dict)
):
logger.info(
"Found usage data in streaming response",
extra={
"key_hash": key.hashed_key[:8] + "...",
"usage_data": data["usage"],
"model": data.get("model", "unknown"),
},
)
# If we reach here without finding usage, finalize with max-cost
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
# Found usage data, calculate cost
# Create a new session for this operation
async with create_session() as new_session:
# Re-fetch the key in the new session
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
data,
new_session,
max_cost_for_model,
)
logger.info(
"Token adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
# Format as SSE and yield
cost_json = json.dumps({"cost": cost_data})
yield f"data: {cost_json}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error adjusting payment for streaming tokens",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
break
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(
"Error processing streaming response chunk",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
except Exception as stream_error:
# On stream interruption, still finalize reservation with max-cost
logger.warning(
"Streaming interrupted; finalizing without usage",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
await finalize_without_usage()
raise
return StreamingResponse(
stream_with_cost(max_cost_for_model),
@@ -255,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",
@@ -283,7 +336,7 @@ async def forward_to_upstream(
url,
headers=headers,
content=request_body,
params=request.query_params,
params=prepare_upstream_params(path, request.query_params),
),
stream=True,
)
@@ -294,7 +347,7 @@ async def forward_to_upstream(
url,
headers=headers,
content=request.stream(),
params=request.query_params,
params=prepare_upstream_params(path, request.query_params),
),
stream=True,
)
@@ -502,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
@@ -704,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",
@@ -722,7 +778,7 @@ async def forward_get_to_upstream(
url,
headers=headers,
content=request.stream(),
params=request.query_params,
params=prepare_upstream_params(path, request.query_params),
),
)

View File

@@ -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.rstrip("/") not in [mint.rstrip("/") for mint 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(
@@ -157,7 +153,7 @@ async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wal
id = f"{mint_url}_{unit}"
if id not in _wallets:
_wallets[id] = await Wallet.with_db(
mint_url, db=".wallet", load_all_keysets=True, unit=unit
mint_url, db=".wallet", unit=unit
)
if load:
@@ -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",

View File

@@ -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

View File

@@ -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] = []

View File

@@ -1,268 +0,0 @@
#!/usr/bin/env python3
"""
Simple Python function to publish one provider listing to a nostr relay
according to the RIP-02 specification.
Based on: https://github.com/Routstr/protocol/blob/main/RIP-02.md
Event Kind: 31338 (Routstr Provider Announcements)
"""
import asyncio
import hashlib
import json
import time
from typing import Any
import secp256k1
import websockets
def create_provider_announcement_event(
private_key_hex: str,
provider_name: str,
endpoint_url: str,
d_tag: str,
description: str | None = None,
contact: str | None = None,
pricing_url: str | None = None,
supported_models: list[str] | None = None,
) -> dict[str, Any]:
"""
Create a RIP-02 compliant provider announcement event.
Args:
private_key_hex: 32-byte hex private key for signing
provider_name: Human readable name for the provider
endpoint_url: Base URL for the provider's API endpoint
d_tag: Unique identifier for this provider (required for addressable events)
description: Optional description of the provider
contact: Optional contact information
pricing_url: Optional URL to pricing information
supported_models: Optional list of supported model names
Returns:
Complete signed nostr event ready for publishing
"""
# Convert hex private key to secp256k1 PrivateKey object
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key = private_key.pubkey.serialize(compressed=True)[
1:
] # Remove 0x02/0x03 prefix
# Build required tags according to RIP-02
tags = [
["d", d_tag], # Required for addressable events (kind 30000-39999)
["endpoint", endpoint_url],
["name", provider_name],
]
# Add optional tags if provided
if description:
tags.append(["description", description])
if contact:
tags.append(["contact", contact])
if pricing_url:
tags.append(["pricing", pricing_url])
if supported_models:
for model in supported_models:
tags.append(["model", model])
# Create the event structure
created_at = int(time.time())
event_data = [
0, # Reserved field
public_key.hex(), # Public key as hex
created_at, # Unix timestamp
31338, # Kind for RIP-02 Provider Announcements
tags, # Tags array
"", # Content (empty for provider announcements)
]
# Serialize event data for hashing
event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False)
# Calculate event ID (SHA256 hash)
event_id = hashlib.sha256(event_json.encode("utf-8")).hexdigest()
# Sign the event ID
signature = private_key.ecdsa_sign(bytes.fromhex(event_id), raw=True)
signature_der = private_key.ecdsa_serialize(signature)
# Create the final event
event = {
"id": event_id,
"pubkey": public_key.hex(),
"created_at": created_at,
"kind": 31338,
"tags": tags,
"content": "",
"sig": signature_der.hex(),
}
return event
async def publish_provider_to_relay(
relay_url: str, event: dict[str, Any], timeout: int = 30
) -> bool:
"""
Publish a provider announcement event to a nostr relay.
Args:
relay_url: WebSocket URL of the nostr relay (e.g., "wss://relay.damus.io")
event: Complete signed nostr event to publish
timeout: Connection timeout in seconds
Returns:
True if successfully published, False otherwise
"""
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
# Send EVENT message
event_message = json.dumps(["EVENT", event])
await websocket.send(event_message)
print(f"Published event {event['id']} to {relay_url}")
# Wait for OK response
try:
response = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(response)
if data[0] == "OK" and data[1] == event["id"]:
if data[2]: # True means accepted
print(
f"✅ Event accepted by relay: {data[3] if len(data) > 3 else ''}"
)
return True
else:
print(
f"❌ Event rejected by relay: {data[3] if len(data) > 3 else ''}"
)
return False
elif data[0] == "NOTICE":
print(f"📢 Relay notice: {data[1]}")
return False
else:
print(f"🤔 Unexpected response: {data}")
return False
except asyncio.TimeoutError:
print("⏰ No response from relay within timeout")
return False
except Exception as e:
print(f"💥 Failed to publish to {relay_url}: {e}")
return False
async def publish_provider_listing(
private_key_hex: str,
provider_name: str,
endpoint_url: str,
d_tag: str,
relay_urls: list[str] | None = None,
description: str | None = None,
contact: str | None = None,
pricing_url: str | None = None,
supported_models: list[str] | None = None,
) -> dict[str, bool]:
"""
Complete function to create and publish a provider listing to nostr relays.
Args:
private_key_hex: 32-byte hex private key for signing
provider_name: Human readable name for the provider
endpoint_url: Base URL for the provider's API endpoint
d_tag: Unique identifier for this provider
relay_urls: List of relay URLs to publish to (uses defaults if None)
description: Optional description of the provider
contact: Optional contact information
pricing_url: Optional URL to pricing information
supported_models: Optional list of supported model names
Returns:
Dictionary mapping relay URLs to success status
"""
# Use default relays if none provided
if relay_urls is None:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
# Create the provider announcement event
event = create_provider_announcement_event(
private_key_hex=private_key_hex,
provider_name=provider_name,
endpoint_url=endpoint_url,
d_tag=d_tag,
description=description,
contact=contact,
pricing_url=pricing_url,
supported_models=supported_models,
)
print(f"📝 Created provider announcement event: {event['id']}")
print(f"🔑 Public key: {event['pubkey']}")
print(f"🏷️ Provider: {provider_name}")
print(f"🌐 Endpoint: {endpoint_url}")
print()
# Publish to all specified relays
results = {}
tasks = []
for relay_url in relay_urls:
task = publish_provider_to_relay(relay_url, event)
tasks.append((relay_url, task))
# Execute all publishing tasks concurrently
for relay_url, task in tasks:
try:
success = await task
results[relay_url] = success
except Exception as e:
print(f"💥 Failed to publish to {relay_url}: {e}")
results[relay_url] = False
return results
# Example usage
async def main() -> None:
"""Example of how to use the provider publishing function."""
# Example private key (DO NOT use this in production!)
private_key = "3185a47e3802f956ca207b46c8d6b8b5c5dbad53a5ca29816050e9b66badc33c"
# Example provider information
provider_name = "My AI Provider"
endpoint_url = "https://api.myaiprovider.com"
d_tag = "my-ai-provider-v1" # Unique identifier
description = "High-quality AI models with competitive pricing"
contact = "admin@myaiprovider.com"
pricing_url = "https://myaiprovider.com/pricing"
supported_models = ["gpt-4o", "claude-3-sonnet", "llama-3.1-70b"]
# Publish to relays
results = await publish_provider_listing(
private_key_hex=private_key,
provider_name=provider_name,
endpoint_url=endpoint_url,
d_tag=d_tag,
description=description,
contact=contact,
pricing_url=pricing_url,
supported_models=supported_models,
)
# Print results
print("\n📊 Publishing Results:")
for relay_url, success in results.items():
status = "✅ Success" if success else "❌ Failed"
print(f" {relay_url}: {status}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -9,9 +9,16 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from .utils import PerformanceValidator, ResponseValidator
@pytest.fixture(autouse=True)
def _clear_providers_cache() -> None:
_PROVIDERS_CACHE.clear()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_default_response(
@@ -26,13 +33,25 @@ async def test_providers_endpoint_default_response(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Check out this provider: http://provider1.onion",
"pubkey": "test_pubkey1",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": '{"name": "Provider 1", "about": "Test provider 1"}',
"tags": [
["d", "provider1"],
["u", "http://provider1.onion"],
],
},
{
"id": "event2",
"content": "Another provider at http://provider2.onion is good",
"pubkey": "test_pubkey2",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567891,
"content": '{"name": "Provider 2", "about": "Test provider 2"}',
"tags": [
["d", "provider2"],
["u", "http://provider2.onion"],
],
},
]
@@ -60,10 +79,11 @@ async def test_providers_endpoint_default_response(
assert "providers" in data
assert isinstance(data["providers"], list)
# In default format, should return list of provider URLs (strings)
# In default format, should return list of provider objects
for provider in data["providers"]:
assert isinstance(provider, str)
assert provider.endswith(".onion")
assert isinstance(provider, dict)
assert "endpoint_url" in provider
assert provider["endpoint_url"].endswith(".onion")
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -86,8 +106,14 @@ async def test_providers_endpoint_with_include_json(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Provider info: http://test-provider.onion",
"pubkey": "test_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": '{"name": "Test Provider", "about": "A test provider"}',
"tags": [
["d", "test-provider"],
["u", "http://test-provider.onion"],
],
}
]
@@ -117,15 +143,20 @@ async def test_providers_endpoint_with_include_json(
assert "providers" in data
assert isinstance(data["providers"], list)
# With include_json=true, should return list of dictionaries
for provider in data["providers"]:
assert isinstance(provider, dict)
# Each provider should be in format {url: json_data}
assert len(provider) == 1
url = list(provider.keys())[0]
json_data = provider[url]
assert url.endswith(".onion")
assert isinstance(json_data, dict)
# With include_json=true, should return list of dictionaries with provider and health info
for provider_data in data["providers"]:
assert isinstance(provider_data, dict)
# Each provider should have 'provider' and 'health' keys
assert "provider" in provider_data
assert "health" in provider_data
provider_info = provider_data["provider"]
assert "endpoint_url" in provider_info
assert provider_info["endpoint_url"].endswith(".onion")
health_info = provider_data["health"]
assert isinstance(health_info, dict)
assert "status_code" in health_info
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -141,20 +172,18 @@ async def test_providers_data_structure_validation(
) -> None:
"""Test provider data structure contains expected fields"""
# Mock RIP-02 provider announcement event
# Mock NIP-91 provider announcement event
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"pubkey": "test_pubkey",
"created_at": 1234567890,
"content": "Comprehensive provider announcement",
"kind": 38421, # NIP-91 event kind
"content": '{"name": "Comprehensive Provider", "about": "A comprehensive AI provider"}',
"tags": [
["d", "provider-123"],
["endpoint", "https://api.provider.example/v1"],
["name", "Comprehensive Provider"],
["description", "A comprehensive AI provider"],
["model", "gpt-3.5-turbo"],
["model", "gpt-4"],
["u", "https://api.provider.example/v1"],
["models", "gpt-3.5-turbo", "gpt-4"],
],
}
]
@@ -190,19 +219,13 @@ async def test_providers_data_structure_validation(
assert "health" in provider_data
provider_info = provider_data["provider"]
# Expected fields from RIP-02 parser
expected_fields = ["id", "name", "endpoint_url", "supported_models"]
# health_info = provider_data["health"]
# Expected fields from NIP-91 parser (supported_models removed)
expected_fields = ["id", "name", "endpoint_url"]
for field in expected_fields:
assert field in provider_info
# Validate models structure if present
if "supported_models" in provider_info:
models = provider_info["supported_models"]
assert isinstance(models, list)
# Should have the models from the mocked event
assert "gpt-3.5-turbo" in models
assert "gpt-4" in models
@pytest.mark.integration
@pytest.mark.asyncio
@@ -211,8 +234,17 @@ async def test_providers_endpoint_no_providers_found(
) -> None:
"""Test providers endpoint when no providers are found"""
# Mock empty events (no providers mentioned)
mock_events: list[dict[str, Any]] = []
# Force empty discovery by returning events that are filtered out
mock_events: list[dict[str, Any]] = [
{
"id": "localhost-event",
"pubkey": "ignored_pubkey",
"kind": 38421,
"created_at": 1234567899,
"content": '{"name": "Local"}',
"tags": [["d", "local"], ["u", "http://localhost:8000"]],
}
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
@@ -239,23 +271,23 @@ async def test_providers_endpoint_offline_providers(
{
"id": "event1",
"pubkey": "healthy_provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": "Healthy provider announcement",
"content": '{"name": "Healthy Provider", "about": "Healthy provider announcement"}',
"tags": [
["d", "healthy-provider"],
["endpoint", "http://healthy-provider.onion"],
["name", "Healthy Provider"],
["u", "http://healthy-provider.onion"],
],
},
{
"id": "event2",
"pubkey": "offline_provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567891,
"content": "Offline provider announcement",
"content": '{"name": "Offline Provider", "about": "Offline provider announcement"}',
"tags": [
["d", "offline-provider"],
["endpoint", "http://offline-provider.onion"],
["name", "Offline Provider"],
["u", "http://offline-provider.onion"],
],
},
]
@@ -323,23 +355,23 @@ async def test_providers_endpoint_duplicate_urls(
{
"id": "event1",
"pubkey": "provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": "Provider announcement",
"content": '{"name": "Provider", "about": "Provider announcement"}',
"tags": [
["d", "provider-1"],
["endpoint", "http://provider.onion"],
["name", "Provider"],
["u", "http://provider.onion"],
],
},
{
"id": "event2",
"pubkey": "other_provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567892,
"content": "Different provider announcement",
"content": '{"name": "Other Provider", "about": "Different provider announcement"}',
"tags": [
["d", "other-provider"],
["endpoint", "http://other-provider.onion"],
["name", "Other Provider"],
["u", "http://other-provider.onion"],
],
},
]
@@ -432,11 +464,10 @@ async def test_providers_endpoint_malformed_urls(
assert response.status_code == 200
data = response.json()
# Should only extract valid onion URLs
providers = data["providers"]
for provider in providers:
assert provider.startswith("http://") or provider.startswith("https://")
assert provider.endswith(".onion")
# With NIP-91-only parsing, events without required tags are ignored
assert "providers" in data
assert isinstance(data["providers"], list)
assert len(data["providers"]) == 0
@pytest.mark.integration
@@ -575,8 +606,14 @@ async def test_providers_endpoint_parameter_validation(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Provider: http://param-test-provider.onion",
"pubkey": "param_pubkey",
"kind": 38421,
"created_at": 1234567890,
"content": '{"name": "Param Test Provider"}',
"tags": [
["d", "param-test-provider"],
["u", "http://param-test-provider.onion"],
],
}
]
@@ -604,13 +641,14 @@ async def test_providers_endpoint_parameter_validation(
if len(providers) > 0:
if expected_json_format:
# Should be list of dictionaries
# Should be list of {provider, health} dictionaries
for item in providers:
assert isinstance(item, dict)
assert "provider" in item and "health" in item
else:
# Should be list of provider objects
for provider in providers:
assert isinstance(provider, dict)
else:
# Should be list of strings
for provider in providers:
assert isinstance(provider, str)
@pytest.mark.integration

View File

@@ -355,27 +355,29 @@ async def test_proxy_post_model_specific_endpoints(
},
"response": {"object": "chat.completion", "model": "gpt-3.5-turbo"},
},
{
"endpoint": "/v1/completions",
"payload": {
"model": "text-davinci-003",
"prompt": "Hello world",
"max_tokens": 50,
},
"response": {"object": "text_completion", "model": "text-davinci-003"},
},
{
"endpoint": "/v1/embeddings",
"payload": {
"model": "text-embedding-ada-002",
"input": "The quick brown fox",
},
"response": {
"object": "list",
"model": "text-embedding-ada-002",
"data": [{"object": "embedding", "embedding": [0.1, 0.2, 0.3]}],
},
},
# Coming soon - completions endpoint
# {
# "endpoint": "/v1/completions",
# "payload": {
# "model": "text-davinci-003",
# "prompt": "Hello world",
# "max_tokens": 50,
# },
# "response": {"object": "text_completion", "model": "text-davinci-003"},
# },
# Coming soon - embeddings endpoint
# {
# "endpoint": "/v1/embeddings",
# "payload": {
# "model": "text-embedding-ada-002",
# "input": "The quick brown fox",
# },
# "response": {
# "object": "list",
# "model": "text-embedding-ada-002",
# "data": [{"object": "embedding", "embedding": [0.1, 0.2, 0.3]}],
# },
# },
]
for test_case in test_cases:

View File

@@ -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

View File

@@ -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"

View File

@@ -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"),

24
uv.lock generated
View File

@@ -1361,6 +1361,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/73/d6b999782ae22f16971cc05378b3b33f6a89ede3b9619e8366aa23484bca/mypy_protobuf-3.6.0-py3-none-any.whl", hash = "sha256:56176e4d569070e7350ea620262478b49b7efceba4103d468448f1d21492fd6c", size = 16434 },
]
[[package]]
name = "nostr"
version = "0.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "cryptography" },
{ name = "pycparser" },
{ name = "secp256k1" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/e1/1e24d8d2d75d28871f5b7d03304eda8250121ab665180872b9ba4ff70cc9/nostr-0.0.2.tar.gz", hash = "sha256:5c0c472f69764ae57870710d6b3bfe584df3ccdb0e2e3cd3f302f6b848124d24", size = 17189 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/ef/468ed56f0bea8e8979acf561273f8af2e7c9b3d8dc37bf80e81df08372a3/nostr-0.0.2-py3-none-any.whl", hash = "sha256:3d17d22dbd3aecf1ddf8cc72e330f14702e159fb0f43320d1ac88142db96aaba", size = 15397 },
]
[[package]]
name = "openai"
version = "1.98.0"
@@ -1767,7 +1783,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.1.1b"
version = "0.1.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1777,9 +1793,12 @@ dependencies = [
{ name = "greenlet" },
{ name = "httpx", extra = ["socks"] },
{ name = "marshmallow" },
{ name = "mdurl" },
{ name = "nostr" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
{ name = "sqlmodel" },
{ name = "websockets" },
]
[package.dev-dependencies]
@@ -1806,9 +1825,12 @@ 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" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
{ name = "websockets", specifier = ">=12.0" },
]
[package.metadata.requires-dev]