Compare commits

...

104 Commits

Author SHA1 Message Date
Shroominic
58c4d3bf5f improved readme 2026-01-24 18:08:45 +08:00
Shroominic
2210e9be6c rm old docs 2026-01-24 17:55:50 +08:00
Shroominic
9c66789665 improved docs 2026-01-24 17:55:45 +08:00
shroominic
85aa8fbbc5 Merge pull request #315 from Routstr/314-fix-timeout-error
make sure to refund
2026-01-23 09:42:59 +08:00
9qeklajc
701b870d63 make sure to refund 2026-01-22 13:44:04 +01:00
shroominic
3465a44d0e routstr v0.2.2 - fixfix
v0.2.2 - release summary
--------------------------
#292 - Fix not enough inputs to melt
#295 - Update UI dependencies
#291 - Fix reserved balance
#289 - Better filtering options (#282)
#284 - Do not charge for empty content (#274)
#298 - Fix Provider Balance display in dashboard
#299 - fix refunds not accounting reserved balance
#300 - reset reserved balance on startup (optional)
#303 - ignore disabled provider
#301 - optimize price fetching
2026-01-15 11:21:22 +08:00
shroominic
a4259af38f Merge pull request #301 from Routstr/optimize-price-fetching
optimize price fetching
2026-01-15 11:13:14 +08:00
shroominic
0d07dd0cdb Merge pull request #303 from Routstr/ignore-disabled-provider
ignore disabled provider
2026-01-15 11:12:16 +08:00
Shroominic
24015ebec1 Revert "Merge remote-tracking branch 'origin/mock-upstream-with-testnut-mint' into v0.2.2"
This reverts commit 5a4ba60072, reversing
changes made to eed5bc5b04.
2026-01-13 06:31:24 +08:00
9qeklajc
3bc38937e8 ignore disabled provider 2026-01-10 18:39:24 +01:00
Shroominic
9229b87b70 optimize price fetching 2026-01-10 17:37:05 +08:00
shroominic
367265b9fe Merge pull request #300 from Routstr/reset-reserved-balance-on-startup
Reset reserved balance on startup
2026-01-10 08:50:32 +08:00
shroominic
0b3ccb5fb0 Merge pull request #299 from Routstr/urgent-reserved-balance-fix
Reserved balance fix
2026-01-10 08:50:20 +08:00
shroominic
dbd43f52fb Merge pull request #298 from Routstr/fix-provider-balance
Fix provider balance
2026-01-10 08:49:23 +08:00
Shroominic
39657ed64f reset reserved balance on startup 2026-01-09 17:36:04 +08:00
Shroominic
493b4f0f1f urgent reserved balance fix 2026-01-09 17:32:35 +08:00
Shroominic
ca7e8bec71 fmt 2026-01-09 16:53:25 +08:00
Shroominic
c4cc09d61e fix provider balance not displaying when 0 2026-01-09 16:50:10 +08:00
9qeklajc
fad792068e Merge pull request #287 from Routstr/provider-discovery-default
Disable provider discovery by default
2026-01-06 19:39:09 +01:00
Shroominic
21d363f6aa bump v0.2.2 2026-01-06 19:26:10 +01:00
shroominic
5e21f6ccbc Merge pull request #292 from Routstr/fix-not-enough-inputs-to-melt
Fix not enough inputs to melt
2026-01-06 19:24:33 +01:00
shroominic
1e2d130022 Merge pull request #285 from Routstr/admin-info
Add admin password warning
2026-01-06 19:14:53 +01:00
Shroominic
1e21dce735 change to warning log 2026-01-06 18:23:01 +01:00
shroominic
b7603dcf69 Merge pull request #295 from Routstr/update-ui-deps
update vuln deps
2026-01-06 17:38:13 +01:00
9qeklajc
7dccfa745f fix test 2026-01-06 12:02:27 +01:00
9qeklajc
54d5118980 fmt 2026-01-06 11:50:12 +01:00
9qeklajc
7723ab4a95 update build script 2026-01-06 11:48:53 +01:00
9qeklajc
86c022d8db update vuln deps 2026-01-06 11:46:12 +01:00
9qeklajc
21ae22abec Merge pull request #293 from Routstr/rm-performance-tests
test: Remove flaky performance requirement tests
2026-01-05 22:00:46 +01:00
9qeklajc
3a939d0dd1 Merge pull request #291 from Routstr/fix-reserved-balance
Fix reserved balance
2026-01-05 21:59:46 +01:00
Shroominic
50eabafa57 remove performance tests due to unpredictable behaviour 2026-01-05 12:12:04 +01:00
Shroominic
d192a6a6b4 fix not enough inputs to melt bug 2026-01-05 00:34:58 +01:00
Shroominic
7d829af681 fix types 2026-01-05 00:06:53 +01:00
Shroominic
57bf1b68d9 bump delay to make sure its not taking more time to reset 2026-01-05 00:04:58 +01:00
Shroominic
00d0415518 lint pls 2026-01-04 23:40:17 +01:00
Shroominic
e8585b276f fix typing 2026-01-04 23:39:55 +01:00
Shroominic
4b5e911435 rm not needed generic 2026-01-04 23:39:14 +01:00
Shroominic
761aabfec3 fix linting 2026-01-04 23:38:14 +01:00
Shroominic
f0c45a7ce4 fix other potential reserved balance problems 2026-01-04 23:35:26 +01:00
Shroominic
fc8ccf63ba finalize_without_usage when client disconnects 2026-01-04 23:34:29 +01:00
9qeklajc
eeb70e4ee5 Merge pull request #289 from Routstr/282-better-filltering
#282 more filter options
2026-01-03 23:27:22 +01:00
9qeklajc
a3b410b467 Merge pull request #284 from Routstr/274-do-not-charge-when-empty-content
#274 do not charge user for empty response by upstream
2026-01-03 23:26:54 +01:00
Shroominic
9e9bc5bff8 fix pytests 2026-01-03 22:49:46 +01:00
9qeklajc
bdf0e2c192 #282 more filter options 2026-01-03 22:49:35 +01:00
Shroominic
6d780ef96d feat: disable provider discovery by default 2026-01-03 22:12:55 +01:00
Shroominic
b70b94b9b4 feat: add admin password warning log 2026-01-03 22:12:23 +01:00
9qeklajc
334453f934 #274 do not change user for empty response by upstream 2026-01-03 21:27:19 +01:00
Shroominic
5a4ba60072 Merge remote-tracking branch 'origin/mock-upstream-with-testnut-mint' into v0.2.2 2025-12-28 11:18:08 +01:00
shroominic
f1fa7d094f routstr/v0.2.1
v0.2.1
2025-12-27 22:08:51 +01:00
shroominic
eed5bc5b04 Merge pull request #278 from Routstr/openai-responses-api
OpenAI responses api
2025-12-27 22:02:51 +01:00
Shroominic
f4b014cb05 Merge remote-tracking branch 'origin/examples' into openai-responses-api 2025-12-27 21:39:19 +01:00
9qeklajc
4418d87664 Merge branch 'v0.2.1' into openai-responses-api 2025-12-26 22:37:07 +01:00
shroominic
634a473f50 Merge pull request #276 from Routstr/force-sats-pricing
Force sats pricing
2025-12-26 12:36:02 +01:00
Shroominic
ea655b748b optimize startup time 2025-12-26 12:33:42 +01:00
9qeklajc
2d247ddc8b fmt 2025-12-26 10:29:51 +01:00
9qeklajc
c064452aea Merge branch 'v0.2.1' into force-sats-pricing 2025-12-26 10:27:11 +01:00
9qeklajc
1c6a603042 Merge pull request #271 from Routstr/embedding-with-aliases
Embedding with aliases
2025-12-26 10:26:38 +01:00
9qeklajc
84b0007b05 force sats pricig 2025-12-26 09:55:15 +01:00
Shroominic
525476ccfa fix alias not displaying correctly 2025-12-25 22:34:52 +01:00
Shroominic
b54812cb04 fix: alias_id not displayed correctly by dashboard 2025-12-25 22:28:26 +01:00
Shroominic
ee508cbb3a rm model cleanup 2025-12-25 21:50:09 +01:00
Shroominic
0c61fdee07 ruff format 2025-12-25 21:48:20 +01:00
Shroominic
b9418db31f rm model cleanup task 2025-12-25 21:47:17 +01:00
Shroominic
71e7c2171b add missing migration 2025-12-25 19:36:26 +01:00
Shroominic
a3e8d5fd38 ignore cloudflare headers from logs 2025-12-24 15:09:03 +01:00
Shroominic
41fd2e2dfc fix typing 2025-12-24 12:12:32 +01:00
Shroominic
2c404c66d6 ruff fix 2025-12-24 12:10:51 +01:00
Shroominic
0fa3e77f9a more examples for devs and testing 2025-12-24 12:09:50 +01:00
9qeklajc
5b8e56f590 Merge branch 'v0.2.1' into embedding-with-aliases
# Conflicts:
#	routstr/upstream/base.py
2025-12-23 23:43:46 +01:00
9qeklajc
a6d0bd1a19 update aliases 2025-12-23 23:43:16 +01:00
shroominic
9594e9fb52 Merge pull request #251 from Routstr/embeddings-integration
Embeddings integration
2025-12-23 20:53:26 +01:00
9qeklajc
b01c7b2e56 Merge branch 'v0.2.1' into embeddings 2025-12-22 21:04:28 +01:00
Shroominic
dd4ed7541f undo 43e9732 2025-12-22 11:31:19 +01:00
Shroominic
cc42534a97 manual alias due to openrouter api bug 2025-12-22 11:30:51 +01:00
shroominic
d203370f01 Merge pull request #265 from Routstr/update-cost-calculation
use cost field if available
2025-12-22 10:20:35 +01:00
Shroominic
e39742c429 rm completion_image pricing with manual overrides 2025-12-22 09:35:23 +01:00
Shroominic
301dd81215 include openrouters upstream_inference_cost + manually add gemini image completion cost 2025-12-21 09:55:25 +01:00
9qeklajc
5416cefd87 use cost field if available 2025-12-20 16:16:23 +01:00
Shroominic
d41c214d9e fix typing 2025-12-20 14:02:56 +01:00
Shroominic
ec0fcfb48b mock-upstream-with-testnut-mint 2025-12-20 13:54:14 +01:00
shroominic
8edc3512c1 Merge pull request #258 from Routstr/remove-flaky-wallet-tests
Remove flaky wallet tests
2025-12-19 10:54:26 +01:00
9qeklajc
82d2627c60 added reponse api 2025-12-15 21:11:43 +01:00
9qeklajc
43e97326e0 fix model naming issue in response 2025-12-12 20:14:43 +01:00
9qeklajc
06770a0702 Merge branch 'v0.2.1' into embeddings-integration 2025-12-12 19:44:45 +01:00
Shroominic
590fb4bc2c ruff fix 2025-12-11 14:13:41 +08:00
Shroominic
5db9abc3ce remove flaky wallet tests 2025-12-11 14:12:34 +08:00
shroominic
c11cc107c8 Merge pull request #248 from Routstr/feature/dynamic-settings
feat(ui): make admin settings dynamic based on backend response
2025-12-11 14:04:49 +08:00
Shroominic
547365894d add simple test 2025-12-11 13:58:57 +08:00
shroominic
19b5f2889a Merge pull request #249 from Routstr/refactor-remove-unused-functions
refactor: rm unused functions
2025-12-11 13:30:59 +08:00
Shroominic
52601f89bd ruff fix 2025-12-11 13:28:28 +08:00
shroominic
72b281b815 Merge branch 'v0.2.1' into refactor-remove-unused-functions 2025-12-11 13:24:59 +08:00
Shroominic
c0176a5274 Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 13:06:20 +08:00
Shroominic
329d22363f Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 12:59:41 +08:00
shroominic
87b1443c23 Merge pull request #253 from Routstr/lightning
create and topup token with lightning
2025-12-11 12:58:36 +08:00
Shroominic
c97c74a2ee cleanup logs 2025-12-11 12:55:01 +08:00
Shroominic
4c7887fa4e update algorithm logs 2025-12-11 11:04:00 +08:00
9qeklajc
9438bc957f clean up 2025-12-03 22:24:43 +01:00
9qeklajc
5d2219880d embedding integration 2025-12-03 22:23:03 +01:00
9qeklajc
195da0c9da embedding integration 2025-12-03 22:22:57 +01:00
9qeklajc
8df0c17bc3 embedding integration 2025-12-03 22:20:31 +01:00
9qeklajc
7bc9ee0653 Merge branch 'gemini-upstream' into embeddings 2025-12-03 22:01:39 +01:00
9qeklajc
355f8601c1 embedding integration 2025-12-03 21:58:31 +01:00
Shroominic
6b4b3924a1 feat(ui): make admin settings dynamic based on backend response
This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes.

- Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX.
- All other fields are rendered dynamically based on their type (boolean, number, string, array).
- Sensitive fields (keys, passwords) are automatically masked.
- Specific internal/unused fields are ignored.
2025-12-02 15:23:00 +08:00
Shroominic
a226a78222 rm unused functions 2025-12-02 12:40:54 +08:00
101 changed files with 5698 additions and 17421 deletions

View File

@@ -59,25 +59,30 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
cache-dependency-path: ui/package-lock.json
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install UI dependencies
working-directory: ./ui
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run UI format check
working-directory: ./ui
run: npm run format-check
run: pnpm run format-check
- name: Run UI linting
working-directory: ./ui
run: npm run lint
run: pnpm run lint
- name: Run UI build
working-directory: ./ui
run: npm run build
run: pnpm run build

279
README.md
View File

@@ -1,256 +1,73 @@
# Routstr Payment Proxy
Routstr is a FastAPI-based reverse proxy that sits in front of any OpenAI-compatible API. It handles pay-per-request billing using the [Cashu](https://cashu.space/) eCash protocol on Bitcoin and tracks usage in a local SQL database.
[![License](https://img.shields.io/github/license/routstr/routstr-core?style=flat-square)](LICENSE)
[![Stars](https://img.shields.io/github/stars/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/stargazers)
[![Issues](https://img.shields.io/github/issues/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/issues)
[![Release](https://img.shields.io/github/v/release/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/releases)
The server exposes the same endpoints as the upstream API and deducts sats from user accounts for each call. Pricing can be static or model-specific by loading `models.json` (falls back to `models.example.json`).
Routstr is a decentralized protocol for permissionless, private, and censorship-resistant AI inference. It combines Nostr for discovery and Cashu for private Bitcoin micropayments.
## How It Works
This repo contains Routstr Core: a FastAPI-based reverse proxy that sits in front of OpenAI-compatible APIs and handles pay-per-request billing.
The proxy implements a seamless eCash payment flow that maintains compatibility with existing OpenAI clients while enabling Bitcoin micropayments:
## Start Here
```mermaid
sequenceDiagram
participant Client
participant Proxy as Routstr Proxy
participant DB as Database
participant Upstream as OpenAI API
participant Wallet as Cashu Wallet
- **Overview**: <https://docs.routstr.com/overview/>
- **Provider Guide**: <https://docs.routstr.com/provider/quickstart/>
- **User Guide**: <https://docs.routstr.com/user-guide/>
Client->>Proxy: API Request + eCash Token
Proxy->>Wallet: Validate & Redeem Token
Wallet-->>Proxy: Token Value (sats)
Proxy->>DB: Store/Update Balance
Proxy->>Upstream: Forward API Request
Upstream-->>Proxy: API Response + Usage Data
Proxy->>DB: Deduct Actual Request Cost
Proxy->>DB: Update Final Balance
Proxy-->>Client: API Response
## Basic Usage
If you are a user/developer, you just point an OpenAI-compatible SDK at a Routstr node and pay with a Cashu token.
### OpenAI SDK
```python
from openai import OpenAI
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="cashuBo2FteCJodHRwczovL21...",
)
response = client.chat.completions.create(
model="gpt-5-nano",
messages=[{"role": "user", "content": "hello"}],
)
print(response.choices[0].message.content)
```
## Features
### cURL
- **Cashu Wallet Integration** Accept Lightning payments and redeem eCash tokens before forwarding requests
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **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
```bash
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-cashu: cashuBo2FteCJodHRwczovL21..." \
-d '{
"model": "gpt-5-nano",
"messages": [{"role": "user", "content": "hello"}]
}'
```
## Getting Started
## Quick Start (Docker)
### Running the proxy using Docker
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
```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
--name routstr-proxy \
-p 8000:8000 \
ghcr.io/routstr/proxy:latest
```
### Development Requirements
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
### Installation
```bash
uv sync # install dependencies
```
Create a `.env` file based on `.env.example` and fill in the required values:
## Development
```bash
make setup
cp .env.example .env
```
### Running Locally
```bash
fastapi run routstr --host 0.0.0.0 --port 8000
```
The service forwards requests to `UPSTREAM_BASE_URL`. Supply the upstream API key via the `UPSTREAM_API_KEY` environment variable if required.
### Docker
```bash
docker compose up --build
```
This builds the image and also starts a Tor container exposing the API as a hidden service.
## Environment Variables
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)
- `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
- `DESCRIPTION` Description of the proxy
- `NPUB` Nostr public key of the proxy
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
## Database Migrations
The application uses Alembic for database schema management and **automatically runs migrations on startup**. This ensures your database is always up-to-date when deploying new versions.
### Automatic Migrations in Production
When the FastAPI application starts, it automatically:
1. Runs all pending database migrations
2. Updates the schema to the latest version
3. Logs the migration status
This means you don't need to manually run migrations when deploying - just restart the application and migrations will be applied automatically.
### Manual Migration Commands
For development or troubleshooting, you can use these Makefile commands:
```bash
make db-upgrade # Apply all pending migrations
make db-downgrade # Downgrade one migration
make db-current # Show current migration revision
make db-history # Show migration history
make db-migrate # Auto-generate new migration from model changes
make db-revision # Create empty migration file
make db-heads # Show current migration heads
make db-clean # Clean migration cache files
```
### Creating New Migrations
When you modify SQLModel models:
```bash
# Auto-generate a migration from model changes
make db-migrate
# Enter a descriptive message when prompted
# Review the generated migration file in migrations/versions/
# Edit if needed, then test with:
make db-upgrade
```
## Admin UI
Routstr includes a modern Next.js admin dashboard that's served directly from the Python backend as static files - no separate Node.js server required.
### Building the UI
```bash
make ui-build
```
This compiles the Next.js application into static HTML, CSS, and JavaScript files in `ui/out/`.
### Accessing the Dashboard
Once built, the UI is automatically served by the FastAPI backend:
- **Dashboard**: `http://localhost:8000/`
- **Login**: `http://localhost:8000/login`
- **Models Management**: `http://localhost:8000/model`
- **Providers Management**: `http://localhost:8000/providers`
- **Settings**: `http://localhost:8000/settings`
The dashboard provides:
- Real-time wallet balance monitoring
- Model pricing configuration
- Upstream provider management
- Transaction history
- System settings
**Authentication**: Use the `ADMIN_PASSWORD` environment variable to access the dashboard.
## Withdrawing Balance
Go to the admin dashboard at `http://localhost:8000/` and login with your `ADMIN_PASSWORD` to withdraw your balance as a Cashu token.
## Example Client
`example.py` shows how to use the proxy with the official OpenAI client:
```bash
CASHU_TOKEN=<redeemable token> python example.py
```
The script sends streaming chat completions and pays for each request using the provided token.
## Running Tests
```bash
uv run pytest
```
The tests create a temporary SQLite database and mock the Cashu wallet. See `tests/README.md` for more details.
## Future Features
### Nut-24 Header Support (Coming Soon)
We're implementing support for the Cashu Nut-24 specification, which will enable per-request token exchange with automatic change handling:
```mermaid
graph TD
A["Client Request<br/>x-cashu: token"] --> B[Proxy Validates Token]
B --> C{Token ≥ Minimum Amount?}
C -->|No| F[Return 402 Payment Required]
C -->|Yes| D[Calculate Request Cost]
D --> E[Process Request]
E --> G[Forward to Upstream API]
G --> H[Receive API Response]
H --> I[Calculate Change]
I --> J["Return Response<br/>x-cashu: change_token"]
F --> K[End]
J --> K
```
**Key Benefits:**
- **Per-Request Payments** Send exact tokens for each API call
- **Automatic Change** Receive change tokens in response headers
- **No Pre-funding** No need to maintain account balances
- **Precise Billing** Pay only for actual usage with msat-level precision
- **Minimum Amount Protection** Proxy enforces minimum token value to prevent dust attacks
**Header Format:**
- **Request**: `x-cashu: <ecash_token>` Token to spend for this request (must meet minimum amount)
- **Response**: `x-cashu: <change_token>` Change token if payment exceeds cost
**Implementation Note:**
The proxy should implement either a dedicated endpoint to communicate minimum eCash requirements per request, or extend the existing `models.json` to include minimum token amounts per model. This allows clients to autonomously determine the appropriate token amount to send with each request.
**Compatible Clients:**
To use this feature, you'll need a client that handles both OpenAI API calls and eCash header management. The following clients provide seamless integration:
- **[routstr-chat](https://github.com/routstr/routstr-chat)** chat app for the routstr network
- **[otrta-client](https://github.com/routstr/otrta-client)** rust web app for the routstr network
clients automatically:
- **Handle eCash Headers** Add `x-cashu` tokens to requests and process change tokens
- **Manage Wallets** Maintain your Cashu wallet
- **Configure Proxy** Set Routstr proxy endpoints
- **Top-up Balances** Automatically request ecash when tokens run low and redeem ecash tokens
This approach eliminates the need for account management while maintaining the security and privacy benefits of eCash payments.
## License
This project is licensed under the terms of the GPLv3. See the `LICENSE` file for the full license text.
GPLv3. See `LICENSE`.

View File

@@ -1,567 +0,0 @@
# 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

View File

@@ -1,646 +0,0 @@
# 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

View File

@@ -1,598 +0,0 @@
# 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

View File

@@ -1,530 +0,0 @@
# 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

View File

@@ -425,4 +425,4 @@ All API key usage is logged:
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Using the API](../user-guide/using-api.md) - Integration examples
- [Using the API](../client/integration.md) - Integration examples

View File

@@ -527,4 +527,4 @@ Rate limit information is included in response headers.
- [Errors](errors.md) - Error handling reference
- [Authentication](authentication.md) - Auth details
- [Examples](../user-guide/using-api.md) - Code examples
- [Integration Guide](../client/integration.md) - Code examples

View File

@@ -589,4 +589,4 @@ class ErrorMetrics:
- [Authentication](authentication.md) - Auth error details
- [Endpoints](endpoints.md) - Endpoint-specific errors
- [Examples](../user-guide/using-api.md) - Error handling examples
- [Integration Guide](../client/integration.md) - Error handling examples

View File

@@ -99,19 +99,19 @@ All errors follow a consistent format:
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`
- **Responses**: `/v1/responses`
- **Chat Completions**: `/v1/chat/completions`
- **Embeddings**: `/v1/embeddings`
- **Completions**: `/v1/completions` *(planned)*
- **Images**: `/v1/images/generations` *(planned)*
- **Audio**: `/v1/audio/transcriptions` *(planned)*
### Payment Endpoints
Routstr-specific payment management:
- **Wallet**: `/v1/wallet/*`
- **Balance**: `/v1/balance`
- **Balance**: `/v1/balance/*`
- **Node Info**: `/v1/info`
### Admin Endpoints
@@ -137,9 +137,25 @@ Protected administrative functions:
| Header | Description |
|--------|-------------|
| `X-Routstr-Version` | API version override |
| `X-Cashu` | eCash token for per-request payment |
| `X-Max-Cost` | Maximum acceptable cost in sats |
#### X-Cashu: Stateless Per-Request Payment
Instead of using `Authorization: Bearer sk-...`, you can send a Cashu token directly in the `X-Cashu` header. The response will include an `X-Cashu-Refund` header with your change.
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "X-Cashu: cashuA3s8jKx9..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
```
The response includes your change in the same header:
```
X-Cashu: cashuA7k2mNp4...
```
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
## Response Headers
@@ -149,16 +165,8 @@ Protected administrative functions:
|--------|-------------|
| `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) |
| `X-Request-ID` | Unique request identifier |
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
## Streaming Responses
@@ -245,8 +253,6 @@ X-Webhook-Signature: sha256=...
- Current version: `v1`
- Version in URL path: `/v1/endpoint`
- Override with header: `X-Routstr-Version: v2`
- Deprecation notices: 6 months
## Status Codes
@@ -284,82 +290,23 @@ Responses are compressed with gzip when:
- Response is larger than 1KB
- Content type is compressible
## Pagination
## Batch Requests *(planned)*
List endpoints support pagination:
Process multiple operations in one request. Coming soon.
## Node Info
Get node metadata:
```
GET /v1/transactions?limit=50&offset=100
GET /v1/info
```
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.2.0",
"timestamp": "2024-01-01T00:00:00Z",
"checks": {
"database": "ok",
"upstream": "ok",
"mint": "ok"
}
}
```
Supported models and pricing are available at `/v1/models`.
## 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
- [Integration Guide](../client/integration.md) - Code examples

143
docs/client/integration.md Normal file
View File

@@ -0,0 +1,143 @@
# Using the API
Routstr is **OpenAI-compatible**. Almost any AI application, SDK, or tool that supports custom endpoints will work out of the box. Just change two things:
```
BASE_URL → https://api.routstr.com/v1
API_KEY → sk-... or cashuA...
```
**Both work as API keys:**
- `sk-7f8e9d...` — Session key (from Lightning invoice or Cashu import)
- `cashuA3s8j...` — Raw Cashu token (use directly from your wallet)
If the app lets you set a base URL and API key, you're good to go.
---
## Quick Setup Examples
### OpenAI SDK (Python/JS)
```python
client = OpenAI(base_url="https://api.routstr.com/v1", api_key="sk-...") # or any provider's URL
```
### Claude Code
```bash
export ANTHROPIC_BASE_URL=https://api.routstr.com/v1
export ANTHROPIC_AUTH_TOKEN=sk-...
```
### Any OpenAI-compatible app
Look for "Custom API endpoint", "Base URL", or "OpenAI-compatible" in settings. Paste the URL and key.
---
## Detailed Examples
### Python (Official SDK)
```python
from openai import OpenAI
# 1. Initialize with Routstr URL and your funded key
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="sk-7f8e9d..."
)
# 2. Call the API normally
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
### Node.js
```javascript
import OpenAI from 'openai';
// You can use a session key OR a raw Cashu token directly
const openai = new OpenAI({
baseURL: 'https://api.routstr.com/v1',
apiKey: 'cashuA3s8jKx9...', // or 'sk-7f8e9d...'
});
async function main() {
const completion = await openai.chat.completions.create({
messages: [{ role: 'user', content: 'Say this is a test' }],
model: 'gpt-3.5-turbo',
});
console.log(completion.choices[0]);
}
main();
```
### cURL
```bash
# Works with session key or raw Cashu token
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer cashuA3s8jKx9..." \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
---
## Error Handling
### Insufficient Balance (402 Payment Required)
If your session runs out of funds, the API will return a `402` error.
```json
{
"error": {
"message": "Insufficient balance. Current: 1000 msat, Required: 5000 msat",
"type": "insufficient_balance",
"code": 402
}
}
```
**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
### Rate Limiting
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
---
## Advanced: Tor Access
If the node is running as a hidden service, use a SOCKS5 proxy (like `127.0.0.1:9050`).
**Python:**
```python
import httpx
from openai import OpenAI
proxy_mounts = {
"http://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
"https://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
}
client = OpenAI(
base_url="http://verylongonionaddress.onion/v1",
api_key="sk-...",
http_client=httpx.Client(mounts=proxy_mounts),
)
```

142
docs/client/introduction.md Normal file
View File

@@ -0,0 +1,142 @@
# Introduction to Routstr
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 (Cashu eCash)
- Creating and managing API keys (Ephemeral Sessions)
- Making API calls through Routstr
- Using the admin dashboard
## Prerequisites
### 💰 Wallet
Cashu ([cashu.me](https://cashu.me)) or Lightning ([Strike](https://strike.me), Cash App, etc.)
### 🌐 Provider
A Routstr node, e.g. `https://api.routstr.com/v1`
### 🤖 Client
OpenAI SDK, Claude Code, Cursor, or any OpenAI-compatible tool
---
## How Routstr Works
Routstr is a **Payment Proxy**. It sits between your code and the AI provider.
### Traditional API vs Routstr
| Traditional | Routstr |
|---|---|
| Credit Card Required | Bitcoin / Lightning / eCash |
| Monthly Billing | Pay-per-request (Real-time) |
| KYC / Account | No Account / Private |
| Single Provider | Aggregated Providers |
### Key Concepts
#### 1. Cashu eCash
Digital bearer tokens backed by Bitcoin. They are instant, private, and have no fees for internal transfers. Routstr uses these tokens as the "credits" for API requests.
#### 2. Ephemeral Sessions (API Keys)
Instead of a permanent account, you create a **Session**.
- You fund a session with eCash or Lightning.
- Routstr gives you an `api_key` (`sk-...`) representing that session.
- You use the `api_key` until funds run out or you finish your task.
- You can **refund** the remaining balance back to your wallet at any time.
#### 3. Millisats (msats)
Everything is priced in **millisatoshis**.
- 1 Satoshi (sat) = 1,000 msats.
- This allows for extremely precise pricing (e.g., 0.05 sats per prompt).
---
## Workflow: Zero to Intelligence
### 1. Fund a Session
You need an `api_key` with a balance.
**Easiest: Use the Web UI**
Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or [chat.routstr.com](https://chat.routstr.com) → Settings to create a key visually with Lightning.
**Option A: Lightning Invoice (CLI)**
Generate an invoice and pay it with any Lightning wallet.
```bash
curl -X POST https://api.routstr.com/lightning/invoice \
-d '{"amount_sats": 1000, "purpose": "create"}'
```
*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.*
**Option B: Cashu Token (Best for privacy & devs)**
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
- **Direct Usage**: Use the token *as* your API key in the `Authorization` header.
- **Import**: Or exchange it for a standard `sk-...` key:
```bash
curl "https://api.routstr.com/v1/balance/create?initial_balance_token=cashuA..."
```
*Returns your `api_key` immediately.*
### 2. Configure Your Client
Use the standard OpenAI SDK, just changing the `base_url` and `api_key`.
```python
from openai import OpenAI
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="sk-7f8e9d..." # The key from Step 1
)
```
### 3. Make Requests
```python
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Explain quantum computing."}]
)
```
### 4. Withdraw Change
When you are done, get your change back as a Cashu token.
```bash
curl -X POST https://api.routstr.com/v1/balance/refund \
-H "Authorization: Bearer sk-7f8e9d..."
```
*Returns a `token` that you can paste back into Nutstash or Minibits to reclaim your funds.*
---
## Supported Features
- **Responses**: `/v1/responses` (OpenAI Responses API)
- **Chat Completions**: `/v1/chat/completions` (Streaming supported)
- **Embeddings**: `/v1/embeddings`
- **Models**: `/v1/models` (List available models and prices)
## Next Steps
- **[Payment Flow](payments.md)**: Detailed breakdown of the funding lifecycle.
- **[Models & Pricing](../provider/pricing.md)**: How costs are calculated.
- **[Admin Dashboard](../provider/dashboard.md)**: Managing your node if you are the operator.

97
docs/client/payments.md Normal file
View File

@@ -0,0 +1,97 @@
# Payment Flow
Routstr uses a **Pre-paid, Ephemeral** payment model. Pay first, use the funds, withdraw the rest. No accounts, no credit cards, no trails.
```
💰 Deposit → 🤖 Use AI → 💸 Withdraw Change
```
## 1. Creating a Balance (Deposit)
To start making requests, you must create a "Balance" (represented by an API Key).
### Method A: Lightning Network (Bolt11)
**Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
1. **Request Invoice**:
`POST /lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`.
2. **Pay Invoice**: User scans and pays the QR code/bolt11 string.
3. **Receive Key**: Routstr detects the payment and issues a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats).
### Method B: Cashu Token Import
**Ideal for**: Private, instant access or automated agents.
1. **Generate Token**: User creates a token in their local wallet (e.g., 1000 sats).
2. **Import**: `GET /v1/balance/create?initial_balance_token=cashuA...`
3. **Receive Key**: Routstr claims the token and issues an API Key (`sk-...`) with that balance.
---
## 2. Consuming Funds (Inference)
Every time you make a request to `/v1/chat/completions` (or others), the cost is deducted from your balance **in real-time**.
### Cost Calculation
`Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee`
- Prices are defined per model (see `/v1/models`).
- If you stream the response, the balance is deducted incrementally or finalized at the end of the stream.
- If your balance hits 0 mid-stream, the connection is closed.
### Headers
Routstr checks the `Authorization: Bearer sk-...` header to identify which balance to charge.
---
## 3. Topping Up
If your balance runs low, you don't need a new key. You can top up the existing one.
### Via Lightning
`POST /lightning/invoice` with `{"amount_sats": 1000, "purpose": "topup", "api_key": "sk-..."}`.
*Once paid, the funds are added to your existing key.*
### Via Cashu
`POST /v1/balance/topup` with `{"cashu_token": "..."}` and `Authorization: Bearer sk-...`.
---
## 4. Refund (Withdrawal)
Don't leave large balances sitting on a node—it's a hot wallet. When you're done, get your sats back.
### Endpoint
`POST /v1/balance/refund`
**Headers**:
`Authorization: Bearer sk-...`
**Response**:
```json
{
"token": "cashuAeyJ0b2tlbiI6W3sibWludCI6...",
"msats": "450000"
}
```
You can verify the refund was successful by checking that the API Key is now invalid or has 0 balance. Copy the `token` string and paste it into your Cashu wallet to claim the Bitcoin.
---
## Summary
| Step | Action | Result |
|------|--------|--------|
| 💰 **Deposit** | Pay Lightning invoice or import Cashu | Get `sk-...` key |
| 🤖 **Use** | Make API requests | Balance decreases |
| 💸 **Refund** | Call `/v1/balance/refund` | Get Cashu token back |
That's it. No monthly bills, no surprise charges, no data harvesting.

View File

@@ -4,7 +4,7 @@ This document describes the high-level architecture of Routstr Core, helping con
## System Overview
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs.
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs and can optionally announce providers via Nostr.
```mermaid
graph TB
@@ -20,7 +20,7 @@ graph TB
Auth[Auth Module]
Payment[Payment Module]
Proxy[Proxy Module]
DB[(SQLite DB)]
DB[(SQLModel DB)]
API --> Auth
Auth --> Payment
@@ -40,57 +40,87 @@ graph TB
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
- **Lifespan Management**: Runs migrations, initializes DB, refreshes pricing/models, starts background tasks
- **Middleware**: CORS and request logging
- **Routers**: Admin, pricing/models, balance/wallet, providers discovery, proxy
- **Background Tasks**: Price refresh, model map refresh, payouts, node announcements, provider discovery refresh
### 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
- **API Key Validation**: SHA-256 hashed key lookup and persistence
- **Balance Checking**: Ensures sufficient funds before requests
- **Token Redemption**: Converts Cashu tokens 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
- **Model Pricing**: Derived from upstream providers and DB overrides
- **Currency Conversion**: BTC/USD price refresh and conversion
- **Fee Application**: Provider fee applied to upstream model pricing
### 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
- **Request Forwarding**: Forwards requests to selected upstream providers
- **Response Streaming**: Streaming and non-streaming paths
- **Usage Tracking**: Adjusts costs after upstream responses
- **Error Handling**: Maps upstream errors to consistent responses
### Database Layer
Using SQLModel in `routstr/core/db.py`:
```python
# Core models
APIKey:
- id: Primary key
- key_hash: Hashed API key
# Core tables
ApiKey:
- hashed_key: Primary key (SHA-256 of key or Cashu token)
- balance: Current balance (msats)
- created_at: Timestamp
- metadata: JSON field
- reserved_balance: Reserved balance (msats)
- refund_address: Optional LNURL for refunds
- key_expiry_time: Optional refund expiry timestamp
- total_spent: Total spent (msats)
- total_requests: Request count
- refund_mint_url: Mint URL for refunds
- refund_currency: Refund currency
Transaction:
UpstreamProviderRow:
- id: Primary key
- api_key_id: Foreign key
- amount: Transaction amount
- type: deposit/usage/withdrawal
- timestamp: When occurred
- provider_type: openai/anthropic/azure/openrouter/etc.
- base_url: Provider API base URL
- api_key: Provider API key
- api_version: Optional API version
- enabled: Provider enabled flag
- provider_fee: Provider fee multiplier
ModelRow:
- id: Model ID
- upstream_provider_id: Provider foreign key
- name: Model name
- architecture: JSON
- pricing: JSON
- sats_pricing: JSON
- per_request_limits: JSON
- top_provider: JSON
- canonical_slug: Canonical model slug
- alias_ids: Model aliases
- enabled: Model enabled flag
LightningInvoice:
- id: Primary key
- bolt11: Invoice
- amount_sats: Amount in sats
- payment_hash: Payment hash
- status: pending/paid/expired/cancelled
- api_key_hash: Optional associated API key
- purpose: create/topup
- created_at: Unix timestamp
- expires_at: Unix timestamp
- paid_at: Unix timestamp
```
## Request Flow
@@ -107,10 +137,10 @@ sequenceDiagram
C->>R: API Request + Key
R->>D: Validate Key
D-->>R: Key Info + Balance
R->>R: Check Balance
R->>R: Reserve Max Cost
R->>P: Forward Request
P-->>R: AI Response
R->>D: Deduct Cost
R->>D: Finalize Cost (adjust by usage)
R-->>C: Return Response
```
@@ -124,36 +154,20 @@ sequenceDiagram
participant M as Cashu Mint
participant D as Database
C->>R: Create Key Request + Token
R->>W: Validate Token
C->>R: Request + Cashu Token
R->>W: Redeem Token
W->>M: Verify with Mint
M-->>W: Token Valid
W-->>R: Token Amount
R->>D: Create Key + Balance
R-->>C: Return API Key
R->>D: Create/Update Key + Balance
R-->>C: Continue Request
```
## 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
```
The system is async end-to-end, with background tasks for pricing refresh, provider discovery, model map refresh, and payouts.
### 2. Modular Design
@@ -166,82 +180,46 @@ Components are loosely coupled:
### 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"
```
Exceptions are handled by FastAPI exception handlers to return consistent JSON responses with a request ID.
### 4. Database Migrations
Using Alembic for schema management:
- Auto-migrations on startup
- Version control for schema changes
- Rollback capability
- Zero-downtime updates
Alembic migrations are run on startup, and tables are created for any models not tracked by migrations.
## Security Architecture
### API Key Security
- **Storage**: SHA-256 hashed keys
- **Generation**: Cryptographically secure random
- **Validation**: Constant-time comparison
- **Rotation**: Support for key expiry
- **Generation**: Cryptographically secure random (when creating new keys)
- **Validation**: Hash lookup in DB
- **Expiry**: Optional refund flow via `key_expiry_time` and `refund_address`
### Payment Security
- **Token Validation**: Cryptographic verification
- **Double-Spend Prevention**: Mint verification
- **Balance Protection**: Atomic transactions
- **Audit Trail**: All transactions logged
- **Token Validation**: Cashu token redemption via mint
- **Balance Protection**: Atomic updates and reserved balance tracking
- **Audit Trail**: Structured logging of payments and adjustments
### 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)
```
Model and provider selections are cached in process memory and refreshed on a schedule.
### Database Optimization
- **Connection Pooling**: Reuse connections
- **Indexed Queries**: Key lookups are O(1)
- **Batch Operations**: Group updates
- **Async I/O**: Non-blocking queries
- **Atomic Updates**: Balance reservation and finalization updates
### 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)
```
Streaming responses are forwarded from upstream providers with usage tracking hooks.
## Extension Points
@@ -273,8 +251,6 @@ async def stream_response(upstream_response):
- Mock external dependencies
- Test business logic in isolation
- Fast execution (< 1 second per test)
- High coverage target (> 80%)
### Integration Tests
@@ -285,10 +261,7 @@ async def stream_response(upstream_response):
### Performance Tests
- Load testing with locust
- Memory profiling
- Database query optimization
- Response time benchmarks
- Response time benchmarks (as needed)
## Monitoring and Observability
@@ -308,41 +281,17 @@ logger.info("api_request", extra={
### Metrics Collection
Key metrics tracked:
- Request rate by endpoint
- Token usage by model
- Balance changes
- Error rates
- Response times
Structured logs are emitted for requests, pricing, and payment events.
### 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()
}
```
Use `/v1/info` for basic service metadata and configuration visibility.
## 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
```
See `core/Dockerfile` for the current container build configuration.
### Environment Configuration
@@ -352,10 +301,9 @@ FROM python:3.11-slim
### Scaling Considerations
- **Horizontal**: Multiple instances behind load balancer
- **Horizontal**: Multiple instances behind a load balancer
- **Vertical**: Async handles high concurrency
- **Database**: Consider PostgreSQL for scale
- **Caching**: Redis for distributed cache
- **Database**: Configure `DATABASE_URL` for external databases
## Future Architecture

View File

@@ -7,10 +7,13 @@ This guide provides a detailed overview of Routstr Core's codebase organization
```
routstr-core/
├── routstr/ # Main application package
│ ├── __init__.py # Package initialization, loads .env
│ ├── auth.py # Authentication and authorization
│ ├── __init__.py # Package initialization, exports FastAPI app
│ ├── algorithm.py # Model selection/mapping logic
│ ├── auth.py # Bearer/Cashu auth and payment handling
│ ├── balance.py # Balance management endpoints
│ ├── discovery.py # Nostr relay discovery
│ ├── lightning.py # Lightning invoice topups
│ ├── nip91.py # Node announcement logic
│ ├── proxy.py # Request proxying logic
│ ├── wallet.py # Cashu wallet operations
│ │
@@ -18,19 +21,23 @@ routstr-core/
│ │ ├── __init__.py
│ │ ├── admin.py # Admin dashboard and API
│ │ ├── db.py # Database models and connection
│ │ ├── exceptions.py # Custom exception classes
│ │ ├── exceptions.py # Exception handlers
│ │ ├── 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
── 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
│ └── upstream/ # Upstream provider integrations
│ ├── base.py # Base provider logic
│ ├── helpers.py # Provider init and model refresh
│ └── ... # Provider implementations
├── tests/ # Test suite
│ ├── __init__.py
@@ -45,7 +52,12 @@ routstr-core/
│ └── versions/ # Migration files
├── scripts/ # Utility scripts
── models_meta.py # Fetch model pricing
── models_meta.py # Fetch model pricing
│ └── ... # Build/update helpers
├── examples/ # Example clients
├── testing-clients/ # HTML test clients
├── ui/ # Next.js admin UI
├── docs/ # Documentation
├── logs/ # Application logs (git ignored)
@@ -71,30 +83,27 @@ routstr-core/
#### `routstr/__init__.py`
```python
# Loads environment variables
import dotenv
dotenv.load_dotenv()
# Exports FastAPI app
from .core.main import app as fastapi_app
__all__ = ["fastapi_app"]
```
#### `routstr/core/main.py`
```python
# FastAPI application setup
app = FastAPI(
title="Routstr Node",
lifespan=lifespan, # Manages startup/shutdown
)
app = FastAPI(version=__version__, lifespan=lifespan)
# Middleware registration
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(LoggingMiddleware)
# Router inclusion
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
```
@@ -102,29 +111,24 @@ app.include_router(proxy_router)
#### `routstr/auth.py`
Handles API key validation and authorization:
Handles bearer key validation and payment lifecycle (bearer or Cashu token):
```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
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> ApiKey:
"""Validate bearer API key or redeem Cashu token into a balance."""
```
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
- `validate_bearer_key()` - Validate API key or Cashu token
- `pay_for_request()` - Reserve max cost before upstream call
- `adjust_payment_for_tokens()` - Adjust final cost after response
- `revert_pay_for_request()` - Refund on upstream failure
### Payment Processing
@@ -133,56 +137,30 @@ Key functions:
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
async def calculate_cost(
response_data: dict, max_cost: int, session: AsyncSession
) -> CostData | MaxCostData | CostDataError:
"""Calculate cost in millisatoshis from response usage or model pricing."""
```
#### `routstr/payment/models.py`
Manages model pricing data:
Manages model pricing, database overrides, and pricing refresh:
```python
class ModelPrice:
class Model(BaseModel):
id: str
name: str
pricing: dict[str, float] # USD prices
context_length: int
# Global model registry
MODELS: dict[str, ModelPrice] = load_models()
pricing: Pricing
sats_pricing: Pricing | None = None
# Dynamic price updates
async def update_sats_pricing():
"""Background task to update BTC prices"""
"""Periodic task to update sats pricing for providers and overrides."""
```
#### `routstr/payment/x_cashu.py`
#### `routstr/proxy.py` + `routstr/upstream/*`
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
```
The `x-cashu` header is handled by the proxy route and delegated to upstream providers.
### Request Proxying
@@ -191,17 +169,11 @@ class XCashuHandler:
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
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
) -> Response | StreamingResponse:
"""Forward requests to upstream provider and charge usage."""
```
Key features:
@@ -215,27 +187,29 @@ Key features:
#### `routstr/core/db.py`
SQLModel definitions:
SQLModel definitions (selected):
```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
class ApiKey(SQLModel, table=True):
hashed_key: str = Field(primary_key=True)
balance: int
reserved_balance: int = 0
refund_address: str | None = None
key_expiry_time: int | None = None
total_spent: int = 0
created_at: datetime
expires_at: datetime | None = None
metadata: dict = Field(default_factory=dict, sa_column=Column(JSON))
total_requests: int = 0
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
class LightningInvoice(SQLModel, table=True):
id: str = Field(primary_key=True)
bolt11: str
amount_sats: int
status: str
class UpstreamProviderRow(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
provider_type: str
base_url: str
api_key: str
```
### Admin Interface
@@ -245,18 +219,17 @@ class Transaction(SQLModel, table=True):
Web dashboard and admin API:
```python
@admin_router.get("/admin/")
@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")
@admin_router.post("/admin/withdraw")
async def withdraw_balance(
api_key: str,
amount: int | None = None
) -> WithdrawalResponse:
request: Request, withdraw_request: WithdrawRequest
) -> dict[str, str]:
"""Generate eCash token for withdrawal"""
```
@@ -271,25 +244,14 @@ Features:
#### `routstr/wallet.py`
Cashu wallet operations:
Cashu wallet operations (function-based):
```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"""
async def recieve_token(token: str) -> tuple[int, str, str]:
"""Redeem eCash token and return amount/unit/mint."""
async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str:
"""Create eCash token for withdrawal."""
```
### Utility Modules
@@ -301,12 +263,9 @@ 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"""
class RequestIdFilter(logging.Filter):
"""Attach request ID to log records."""
```
#### `routstr/core/middleware.py`
@@ -316,27 +275,18 @@ HTTP middleware components:
```python
class LoggingMiddleware:
"""Log all HTTP requests/responses"""
class ErrorHandlingMiddleware:
"""Consistent error responses"""
```
#### `routstr/core/exceptions.py`
Custom exception hierarchy:
Exception handlers:
```python
class RoustrError(Exception):
"""Base exception with error details"""
status_code: int
error_type: str
detail: str
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""HTTP exception handler with request ID"""
class PaymentError(RoustrError):
"""Payment-related errors"""
class UpstreamError(RoustrError):
"""Upstream API errors"""
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Fallback exception handler with request ID"""
```
## Configuration Files
@@ -348,7 +298,7 @@ Project metadata and dependencies:
```toml
[project]
name = "routstr"
version = "0.2.0"
version = "0.2.2"
dependencies = [
"fastapi[standard]>=0.115",
"sqlmodel>=0.0.24",
@@ -409,13 +359,13 @@ Using FastAPI's DI system:
```python
# Define dependency
async def get_db() -> AsyncSession:
async with async_session() as session:
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
# Use in routes
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
async def get_items(db: AsyncSession = Depends(get_session)):
result = await db.execute(select(Item))
return result.scalars().all()
```

View File

@@ -22,22 +22,7 @@ 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
### 2. Set Up Environment
Run the setup command:
@@ -47,13 +32,13 @@ make setup
This will:
- ✅ Install uv if not present
- ✅ Install [uv](https://github.com/astral-sh/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
### 3. Configure Environment
Create your environment file:
@@ -71,7 +56,7 @@ ADMIN_PASSWORD=development-password
DATABASE_URL=sqlite+aiosqlite:///dev.db
```
### 5. Verify Installation
### 4. Verify Installation
Run these commands to verify your setup:
@@ -168,42 +153,92 @@ Understanding the codebase:
```
routstr-core/
├── routstr/ # Main package
│ ├── __init__.py # Package initialization
│ ├── __init__.py
│ ├── algorithm.py # Provider selection algorithms
│ ├── auth.py # Authentication logic
│ ├── balance.py # Balance management
│ ├── balance.py # Balance management API
│ ├── discovery.py # Nostr discovery
│ ├── lightning.py # Lightning invoice handling
│ ├── nip91.py # Node announcement implementation
│ ├── proxy.py # Request proxying
│ ├── wallet.py # Cashu wallet integration
│ │
│ ├── core/ # Core modules
│ │ ├── admin.py # Admin dashboard
│ │ ├── db.py # Database models
│ │ ├── admin.py # Admin dashboard API
│ │ ├── db.py # Database models (SQLModel)
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── log_manager.py # Log management
│ │ ├── logging.py # Logging setup
│ │ ├── main.py # FastAPI app
│ │ ── middleware.py # HTTP middleware
│ │ ├── main.py # FastAPI app entry
│ │ ── middleware.py # HTTP middleware
│ │ └── settings.py # Configuration
│ │
── 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
── payment/ # Payment processing
├── cost_calculation.py
├── helpers.py
├── lnurl.py # LNURL support
├── models.py # Model pricing
── price.py # BTC/USD rates
│ └── upstream/ # Upstream providers
│ ├── base.py # Base provider class
│ ├── helpers.py # Shared utilities
│ ├── openai.py # OpenAI
│ ├── anthropic.py # Anthropic
│ ├── gemini.py # Google Gemini
│ ├── openrouter.py # OpenRouter
│ └── ... # More providers
├── ui/ # Admin dashboard (Next.js)
│ ├── app/ # Next.js app router
│ │ ├── page.tsx # Landing page
│ │ ├── balances/ # Balance management
│ │ ├── logs/ # Request logs viewer
│ │ ├── model/ # Model configuration
│ │ ├── providers/ # Upstream providers
│ │ ├── settings/ # Node settings
│ │ └── transactions/ # Transaction history
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── landing/ # Landing page components
│ │ └── settings/ # Settings components
│ └── lib/ # Utilities & API client
│ ├── api/ # Backend API client
│ ├── auth/ # Auth context
│ └── hooks/ # React hooks
├── tests/ # Test suite
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── migrations/ # Alembic migrations
├── scripts/ # Utility scripts
├── docs/ # Documentation
├── examples/ # Usage examples
├── migrations/ # Database migrations
├── scripts/ # Utility scripts
── docs/ # Documentation
├── Makefile # Dev commands
├── pyproject.toml # Project config
└── compose.yml # Docker setup
├── Makefile # Dev commands
├── pyproject.toml # Project config
── compose.yml # Docker setup
```
### Admin Dashboard (UI)
The admin dashboard is a Next.js app using:
- **Next.js 14** with App Router
- **shadcn/ui** for components
- **Tailwind CSS** for styling
- **pnpm** for package management
```bash
# Development
cd ui
pnpm install
pnpm dev # http://localhost:3000
# Build for production
pnpm build
```
The UI is served by the FastAPI backend at `/admin/` when built. Use `make build-ui` to build and copy to the backend.
## Common Tasks
### Adding a New Endpoint
@@ -383,7 +418,7 @@ rm -rf test_*.db
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
2. Check [open issues](https://github.com/routstr/routstr-core/issues)
3. Start with a small contribution
Happy coding! 🚀

View File

@@ -6,576 +6,228 @@ This guide covers testing practices, patterns, and tools used in Routstr Core de
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 behavior, not implementation
- Fast feedback
- Reliable tests
- Clear failures
## 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
├── integration/
├── conftest.py
├── utils.py
│ ├── test_wallet_topup.py
│ ├── test_wallet_refund.py
│ ├── test_wallet_information.py
│ ├── test_proxy_get_endpoints.py
── test_proxy_post_endpoints.py
│ └── ... more integration tests
├── unit/
├── test_algorithm.py
├── test_fee_consistency.py
── test_image_tokens.py
│ ├── test_logging_securityfilter.py
│ ├── test_payment_helpers.py
│ ├── test_settings.py
│ ├── test_wallet.py
│ └── ... more unit tests
└── run_integration.py
```
## Running Tests
### Quick Test Commands
### Make Targets
```bash
# Run all tests (unit + integration with mocks)
make test
# Unit tests only
make test-unit
# Integration tests with mocks (fast)
make test-integration
# Integration tests with Docker services
make test-integration-docker
# Fast tests only (skip slow and Docker tests)
make test-fast
# Performance tests
make test-performance
# Coverage
make test-coverage
```
### Direct pytest Commands
```bash
# Run all tests
make test
pytest
# Run unit tests only (fast)
make test-unit
# Run a specific test file
pytest tests/unit/test_wallet.py -v
# Run integration tests
make test-integration
# Run a specific test
pytest tests/unit/test_wallet.py::test_get_balance -v
# 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
# Run tests matching a pattern
pytest -k "wallet" -v
```
### Test Markers
## Test Modes (Integration)
Use markers to categorize tests:
Integration tests support two execution modes:
```python
@pytest.mark.slow
async def test_heavy_computation():
pass
- Mock mode (default): uses in-memory mocks, no Docker required
- Docker mode: uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
@pytest.mark.requires_docker
async def test_real_services():
pass
Use the runner script for Docker mode:
# Run without slow tests
pytest -m "not slow"
```bash
./tests/run_integration.py
```
Or manually:
```bash
docker-compose -f compose.testing.yml up -d
USE_LOCAL_SERVICES=1 pytest tests/integration/ -v
docker-compose -f compose.testing.yml down -v
```
## Test Markers
Markers are defined in `pyproject.toml`:
- `integration`
- `unit`
- `slow`
- `requires_docker`
- `requires_real_mint`
- `performance`
- `asyncio`
Examples:
```bash
# Skip slow tests
pytest -m "not slow" -v
# Run only integration tests
pytest -m "integration"
pytest -m "integration" -v
# Run performance tests
pytest -m "performance" -v
```
## Fixtures and Utilities
### Core Integration Fixtures
Defined in `tests/integration/conftest.py`:
- `integration_client` - Async HTTP client for the FastAPI app
- `authenticated_client` - Client with a pre-created API key
- `testmint_wallet` - Test wallet for generating Cashu tokens
- `db_snapshot` - Database state snapshot/diff helper
- `create_api_key` - Helper to create API keys for tests
- `integration_engine`, `integration_session` - Async DB engine/session
- `background_tasks_controller` - Control background tasks in tests
- `mock_upstream_server` - Mock upstream API responses
### Integration Utilities
Defined in `tests/integration/utils.py`:
- `CashuTokenGenerator`
- `ResponseValidator`
- `PerformanceValidator`
- `ConcurrencyTester`
- `DatabaseStateValidator`
- `MockServiceBuilder`
- `TestDataBuilder`
## Writing Tests
### Unit Test Example
```python
# tests/unit/test_auth.py
import pytest
from routstr.auth import create_api_key, validate_api_key
from routstr.algorithm import calculate_model_cost_score
from routstr.payment.models import Architecture, Model, Pricing
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")
def test_calculate_model_cost_score_basic() -> None:
model = Model(
id="test-model",
name="Test test-model",
created=1234567890,
description="Test model",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="gpt",
instruct_type=None,
),
pricing=Pricing(
prompt=0.001,
completion=0.002,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
)
assert calculate_model_cost_score(model) == 0.002
```
### 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.integration
@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
async def test_wallet_topup(
authenticated_client: AsyncClient,
testmint_wallet: object,
db_snapshot: object,
) -> None:
await db_snapshot.capture()
token = await testmint_wallet.mint_tokens(1000)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["modified"]) == 1
```
### 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
## Debugging Tips
```bash
# Generate coverage report
make test-coverage
# Show print output
pytest -s tests/unit/test_wallet.py
# 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"
```
- Docker mode failures: check `docker ps` and `docker-compose -f compose.testing.yml logs`
- Connection errors: make sure ports 3338, 3000, 8000, and 8088 are free
- Slow tests: use `pytest -m "not slow"` or `make test-fast`
## Next Steps
- See [Architecture](architecture.md) for system design
- Read [Setup Guide](setup.md) for environment setup
- See [Architecture](architecture.md)
- Read [Setup Guide](setup.md)

View File

@@ -1,260 +0,0 @@
# 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

@@ -1,337 +0,0 @@
# 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

@@ -1,156 +0,0 @@
# 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

@@ -1,226 +0,0 @@
# 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.2.0",
"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

View File

@@ -1,53 +0,0 @@
# UI Configuration
This guide explains how to configure the Routstr UI for different environments.
## Environment Variables
The UI uses Next.js environment variables to configure API endpoints and authentication.
### Centralized Configuration
This project uses a centralized configuration approach with a single `.env` file in the project root. This file contains both backend and frontend configuration variables.
Create or update your `.env` file in the project root:
```bash
# .env (in project root)
# UI Configuration (NEXT_PUBLIC_ variables are exposed to the browser)
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```
### Development vs Production
The same `.env` file is used for both development and production. Simply change the values:
**Development:**
```bash
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```
**Production:**
```bash
NEXT_PUBLIC_API_URL=https://api.yourroutstr.com
```
## Building the UI
The build process automatically reads configuration from the root `.env` file:
```bash
# From the project root
make ui-build
# or
./scripts/build-ui.sh
```
The build script will automatically:
- Load `NEXT_PUBLIC_*` variables from the root `.env` file
- Use them during the Next.js build process
- Display warnings if the `.env` file is missing

View File

@@ -1,83 +1,44 @@
# 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.
**Routstr** is a decentralized protocol for permissionless AI inference. It enables an open marketplace where anyone can buy and sell compute using **Bitcoin eCash (Cashu)**.
## What is Routstr Core?
---
Routstr Core is a payment proxy that sits between API clients and OpenAI-compatible services. It enables:
## 🐣 For Clients (Users & Builders)
- **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
If you want to use AI models in your application without accounts or KYC.
### Key Features
- **[Introduction](client/introduction.md)**: How the ecosystem works.
- **[Payment Flow](client/payments.md)**: Funding sessions, topping up, and refunds.
- **[Integration Guide](client/integration.md)**: Code examples for Python, JS, and cURL.
- 🪙 **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
## 🦁 For Providers (Node Operators)
## How It Works
If you want to run a node, resell API access, or monetize hardware.
```mermaid
sequenceDiagram
participant Client
participant Routstr as Routstr Proxy
participant DB as Database
participant Upstream as AI Provider
participant Wallet as Cashu Wallet
- **[Quick Start](provider/quickstart.md)**: Deploy a node in 5 minutes.
- **[Deployment](provider/deployment.md)**: Production Docker setup.
- **[Configuration](provider/configuration.md)**: Environment variables and settings.
- **[Dashboard](provider/dashboard.md)**: Managing your node visually.
- **[Pricing Strategy](provider/pricing.md)**: Setting margins and fees.
- **[Discovery](provider/discovery.md)**: Announcing your node on Nostr.
- **[Tor Support](provider/tor.md)**: Running an anonymous hidden service.
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
## 🔌 API Reference
<div class="grid cards" markdown>
- **[Overview](api/overview.md)**: Base URL, headers, and standards.
- **[Endpoints](api/endpoints.md)**: Full list of REST endpoints.
- **[Authentication](api/authentication.md)**: Handling API keys and tokens.
- **[Errors](api/errors.md)**: Status codes and debugging.
- :rocket: **[Quick Start](getting-started/quickstart.md)**
## 🛠️ Contributing
Get up and running with Docker in minutes
- **[Architecture](contributing/architecture.md)**: System design.
- **[Setup](contributing/setup.md)**: Development environment.
- **[Testing](contributing/testing.md)**: Running tests.
- :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.
*Powered by [Cashu](https://cashu.space) and [Nostr](https://nostr.com).*

76
docs/overview.md Normal file
View File

@@ -0,0 +1,76 @@
# Overview
Routstr is a decentralized protocol for **permissionless, private, and censorship-resistant AI inference**. It creates an open marketplace where anyone can sell llm-tokens and anyone can buy them using privacy-preserving micropayments.
By combining **Nostr** (for censorship-resistant discovery and communication) and **Cashu** (for private, instant Bitcoin eCash payments), Routstr effectively removes the "middleman" from the AI ecosystem.
## How it Works
The network consists of independent **Providers** (Sellers) and **Clients** (Buyers). There is no central server, no login, and no credit card required.
1. **Discovery (Nostr)**: Providers announce their availability, models (e.g., `gpt-4o`, `deepseek-r1`), and prices on the Nostr network.
2. **Payment (Cashu)**: Clients pay providers directly using Bitcoin eCash (Cashu tokens). These payments are untraceable and settle instantly.
3. **Inference (Proxy)**: The Provider acts as a gateway (or runs local hardware), executing the AI model and returning the result to the Client.
## Who is this for?
The documentation is split into two paths depending on your goal:
### 🐣 I want to BUILD on Routstr (Client)
You are a developer building an AI agent, a chat app, or a script, and you want access to AI models without API keys, subscriptions, or KYC.
* **No Accounts**: Just get a wallet.
* **Privacy**: Your requests are mixed with thousands of others; providers can't profile you.
* **Choice**: Switch between hundreds of providers instantly for the best price/performance.
👉 **[Go to Client Guide](client/introduction.md)**
### 🦁 I want to RUN a Node (Provider)
You have API credits (OpenAI, Anthropic, etc.) or GPU capacity and want to earn Bitcoin by selling AI access to the network.
* **Monetize API Keys**: Connect your OpenAI/Anthropic/OpenRouter accounts and earn sats on every request.
* **Monetize Hardware**: Run local models (via vLLM, Ollama) and sell access.
* **Permissionless**: No approval needed. Start the container, configure via dashboard, start earning.
!!! note "Coming Soon"
Future versions will support node-to-node routing—run a gateway without needing your own AI provider credentials.
👉 **[Go to Provider Guide](provider/quickstart.md)**
---
## Architecture
Routstr is built on a modular stack defined by the [Routstr Improvement Protocols (RIPs)](https://github.com/routstr/rips).
```mermaid
flowchart LR
subgraph Client
A[App / Agent]
end
subgraph Provider
B[Routstr Node<br/>Proxy + Auth + Billing]
end
subgraph Upstream
C[OpenAI / Anthropic<br/>vLLM / Ollama / ...]
end
A -- "Request +<br/>Cashu Token" --> B
B -- "Forward<br/>Request" --> C
C -- "Response +<br/>Usage" --> B
B -- "Response +<br/>Refund Token" --> A
```
## Why Routstr?
| Feature | Closed AI | Routstr |
| :--- | :--- | :--- |
| **Access** | Account, KYC, Credit Card | Permissionless, Bitcoin-native |
| **Privacy** | Full Logging & Tracking | Blinded Payments, Ephemeral Sessions |
| **Resilience** | Single Point of Failure | Decentralized Network |
| **Pricing** | Fixed, Monopolistic | Dynamic, Market-driven |
| **Global** | Geofenced | Borderless (Tor/I2P supported) |

View File

@@ -0,0 +1,114 @@
# Advanced Pricing
Advanced pricing strategies for fine-tuned control over your revenue model.
---
## Default Behavior
By default, Routstr:
1. **Fetches costs** from your upstream provider
2. **Applies markup** using your fee settings
3. **Converts to sats** using real-time BTC price
**Formula**: `Price = Upstream Cost × Exchange Fee × Upstream Fee`
---
## Strategy 1: Fixed Per-Request
Charge a flat fee regardless of model or tokens used.
**Configure in Dashboard****Settings****Pricing**:
- Enable **Fixed Pricing**
- Set **Fixed Cost Per Request** (in sats)
**Use cases**:
- Internal tools with predictable usage
- Simple "pay once, get response" APIs
- Subscription-like tiers
---
## Strategy 2: Fixed Per-Token
Override dynamic pricing with global per-token rates.
**Configure in Dashboard****Settings****Pricing**:
| Setting | Description |
|---------|-------------|
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
When set to non-zero values, these override model-specific pricing for all models.
---
## Strategy 3: Per-Model Custom Pricing
Set specific prices for individual models, overriding both upstream cost and global fees.
**Configure in Dashboard****Models**:
1. Click on a model (e.g., `gpt-4`)
2. Enter **Prompt Price** and **Completion Price** (USD per 1M tokens)
3. Save
**Example**: OpenAI charges $30/1M for GPT-4. Set your price to $35/1M to lock in a margin regardless of fee settings.
---
## Minimum Charge
Prevent dust transactions and spam:
| Setting | Description | Default |
|---------|-------------|---------|
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
If a request's calculated cost falls below this (e.g., very short prompts), the client pays the minimum.
---
## Combining Strategies
Strategies apply in order of specificity:
1. **Per-model override** (highest priority)
2. **Fixed per-token rates**
3. **Dynamic pricing with fees** (default)
4. **Fixed per-request** (overrides all above if enabled)
**Example setup**:
- Dynamic pricing as default (10% markup)
- GPT-4 locked at $35/1M (premium model)
- Claude Haiku at 5 sats/1K tokens (budget option)
- Minimum 1 sat per request
---
## Pricing for Profit
### High-Volume Strategy
Lower margins, more clients:
- Exchange Fee: 1.002 (0.2%)
- Upstream Fee: 1.05 (5%)
### Premium Strategy
Higher margins, fewer clients:
- Exchange Fee: 1.01 (1%)
- Upstream Fee: 1.25 (25%)
### Mixed Strategy
- Cheap models (GPT-3.5, Haiku): Low margin to attract volume
- Premium models (GPT-4, Opus): High margin for profit

View File

@@ -0,0 +1,122 @@
# Configuration
Routstr is configured primarily through the **Admin Dashboard**. All settings persist in the database and take effect immediately—no restarts required.
For automated deployments, you can optionally pre-configure settings via environment variables.
---
## Admin Dashboard (Primary)
Access the dashboard at `/admin/` on your node.
### Upstream Providers
Connect to your AI provider(s):
| Setting | Description |
|---------|-------------|
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's API key |
### Node Identity
How your node appears to clients:
| Setting | Description |
|---------|-------------|
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
| **Description** | Brief description of your service |
### Pricing
Control your profit margins:
| Setting | Description | Default |
|---------|-------------|---------|
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
See [Pricing](pricing.md) for detailed strategies.
### Cashu Mints
Which mints to accept payments from:
| Setting | Description |
|---------|-------------|
| **Mints** | List of trusted Cashu mint URLs |
### Lightning Withdrawals
Automatic profit withdrawal:
| Setting | Description |
|---------|-------------|
| **Lightning Address** | Your LN address for withdrawals |
### Security
| Setting | Description |
|---------|-------------|
| **Admin Password** | Password for dashboard access |
### Nostr Discovery
Announce your node on the network:
| Setting | Description |
|---------|-------------|
| **Npub** | Your Nostr public key |
| **Nsec** | Your Nostr private key (for signing) |
| **Relays** | Relays to publish announcements |
See [Discovery](discovery.md) for details.
---
## Environment Variables (Optional)
Use environment variables for:
- **Automated deployments** (CI/CD, infrastructure-as-code)
- **Secrets management** (external secret stores)
- **Initial bootstrap** (set once, manage via dashboard later)
### All Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
| `UPSTREAM_API_KEY` | Upstream API key | — |
| `ADMIN_PASSWORD` | Dashboard password | (none) |
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
| `NAME` | Node display name | `ARoutstrNode` |
| `DESCRIPTION` | Node description | `A Routstr Node` |
| `NPUB` | Nostr public key (bech32) | — |
| `NSEC` | Nostr private key | — |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
### Priority
Environment variables are read on startup. Dashboard settings override them and persist in the database. Once you change a setting in the dashboard, the env var is ignored for that setting.
---
## Models
Manage which AI models you offer:
1. Go to **Models** in the dashboard
2. Models are auto-discovered from your upstream
3. For each model, you can:
- **Enable/Disable** — hide expensive models you don't want to serve
- **Override pricing** — set custom per-token rates
- **Create aliases** — friendly names for models
See [Pricing](pricing.md) for per-model pricing strategies.

208
docs/provider/dashboard.md Normal file
View File

@@ -0,0 +1,208 @@
# Admin Dashboard
The Admin Dashboard is your command center for managing your Routstr provider node. Configure providers, monitor earnings, manage models, and withdraw profits—all from a web interface.
**URL**: `http://your-node:8000/admin/`
---
## Overview Tab
The main dashboard view shows your node's financial status at a glance.
### Wallet Summary
| Metric | Description |
|--------|-------------|
| **Total Wallet** | All Bitcoin currently held by your node |
| **User Balances** | Funds belonging to active client sessions |
| **Your Balance** | Your profit: `Total - User Balances` |
### Mint Status
Shows connected Cashu mints and their balances. Each mint displays:
- Connection status
- Balance in sats/msats
- Unit type
<!-- TODO: Screenshot of Overview tab -->
---
## Sessions Tab
View and manage active client sessions (API keys).
### Session List
| Column | Description |
|--------|-------------|
| **Hashed Key** | Privacy-preserving identifier (not the actual key) |
| **Balance** | Remaining funds in the session |
| **Spent** | Total amount spent by this session |
| **Requests** | Number of API calls made |
| **Created** | When the session was created |
| **Expires** | Auto-expiry time (if set) |
### Actions
- **View Details** — See full session history
- **Revoke** — Terminate a session (remaining balance returns to your wallet)
<!-- TODO: Screenshot of Sessions tab -->
---
## Models Tab
Manage which AI models you offer to clients.
### Model List
Shows all models available from your upstream provider(s):
| Column | Description |
|--------|-------------|
| **Model ID** | The model identifier (e.g., `gpt-4o`) |
| **Enabled** | Whether clients can use this model |
| **Input Price** | Cost per 1M input tokens (USD) |
| **Output Price** | Cost per 1M output tokens (USD) |
| **Custom** | Whether pricing is overridden |
### Actions
- **Import Models** — Fetch latest model list from upstream
- **Enable/Disable** — Toggle model availability
- **Edit Pricing** — Override default pricing for a model
- **Create Alias** — Map a friendly name to a model
### Editing a Model
Click on any model to configure:
| Field | Description |
|-------|-------------|
| **Enabled** | Show this model to clients |
| **Prompt Price** | Custom price per 1M input tokens (USD) |
| **Completion Price** | Custom price per 1M output tokens (USD) |
| **Alias** | Alternative name for this model |
<!-- TODO: Screenshot of Models tab -->
<!-- TODO: Screenshot of Model edit modal -->
---
## Settings Tab
Configure all node settings. Changes take effect immediately.
### Upstream
Connect to your AI provider:
| Field | Description |
|-------|-------------|
| **Base URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's secret key |
<!-- TODO: Screenshot of Upstream settings -->
### Node Identity
| Field | Description |
|-------|-------------|
| **Name** | Public display name |
| **Description** | Brief description of your service |
| **Npub** | Nostr public key for discovery |
### Pricing
| Field | Description |
|-------|-------------|
| **Fixed Pricing** | Toggle flat-rate vs. per-token pricing |
| **Fixed Cost** | Sats per request (when fixed pricing enabled) |
| **Exchange Fee** | Multiplier for BTC volatility buffer |
| **Upstream Fee** | Your profit margin multiplier |
**Example**: With Exchange Fee `1.005` and Upstream Fee `1.10`:
- Upstream cost: $30/1M tokens
- Your price: $30 × 1.005 × 1.10 = $33.17/1M tokens
### Cashu Mints
Manage which mints you accept payments from:
- **Add Mint** — Enter a mint URL
- **Remove Mint** — Stop accepting from a mint
- **Test Connection** — Verify mint is reachable
<!-- TODO: Screenshot of Mints settings -->
### Lightning
| Field | Description |
|-------|-------------|
| **Lightning Address** | Your LN address for automatic withdrawals |
### Nostr Discovery
| Field | Description |
|-------|-------------|
| **Nsec** | Private key for signing announcements |
| **Relays** | Where to publish your node advertisement |
### Security
| Field | Description |
|-------|-------------|
| **Admin Password** | Password for dashboard access |
!!! warning "Set a Password"
The dashboard has no password by default. Always set one for production nodes.
<!-- TODO: Screenshot of Security settings -->
---
## Withdraw Tab
Withdraw your profits to a Lightning wallet.
### Steps
1. **Select Mint** — Choose which mint to withdraw from
2. **Enter Amount** — How many sats to withdraw
3. **Generate Token** — Creates a Cashu token
4. **Redeem** — Paste the token into your Cashu wallet and melt to Lightning
<!-- TODO: Screenshot of Withdraw tab -->
### Alternative: Lightning Address
If you've configured a Lightning Address in Settings, profits can be automatically swept to your wallet (coming soon).
---
## Logs Tab
View node logs for debugging without SSH access.
### Features
- **Filter by Level** — Error, Warning, Info, Debug
- **Search** — Find specific entries
- **Time Range** — View logs from specific periods
- **Auto-refresh** — Watch logs in real-time
### Common Log Entries
| Entry | Meaning |
|-------|---------|
| `Upstream request failed` | Problem connecting to your AI provider |
| `Invalid token` | Client sent an invalid Cashu token |
| `Session expired` | API key reached its time limit |
| `Insufficient balance` | Client ran out of funds mid-request |
<!-- TODO: Screenshot of Logs tab -->

196
docs/provider/deployment.md Normal file
View File

@@ -0,0 +1,196 @@
# Deployment
Production deployment guide for Routstr Provider nodes.
## Docker Compose (Recommended)
For production, use Docker Compose with persistent storage and optional Tor support.
### Basic Setup
Create a `compose.yml`:
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
```
Start the node:
```bash
docker compose up -d
```
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
---
## With Tor (Anonymous Access)
Add Tor to serve your node as a hidden service—no port forwarding needed.
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TOR_PROXY_URL=socks5://tor:9050
depends_on:
- tor
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
container_name: tor
restart: unless-stopped
volumes:
- ./tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
```
After starting, find your `.onion` address:
```bash
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
See [Tor Support](tor.md) for details.
---
## Pre-Configuration (Optional)
While everything can be configured via the dashboard, you can pre-configure settings with environment variables for automated deployments.
### Using Environment Variables
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
environment:
# Pre-configure upstream (optional)
- UPSTREAM_BASE_URL=https://api.openai.com/v1
- UPSTREAM_API_KEY=sk-proj-...
# Secure the dashboard (recommended)
- ADMIN_PASSWORD=your-secure-password
# Node identity
- NAME=My Provider Node
- DESCRIPTION=Fast GPT-4 access via Lightning
# Lightning withdrawals
- RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
volumes:
- ./data:/app/data
```
### Using an .env File
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
env_file:
- .env
volumes:
- ./data:/app/data
```
Example `.env`:
```bash
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-proj-...
ADMIN_PASSWORD=change-me
NAME=My Provider Node
RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
```
See [Configuration](configuration.md) for all available options.
---
## Persistence
Routstr stores all data in `/app/data`:
| Path | Contents |
|------|----------|
| `keys.db` | SQLite database (settings, API keys, sessions) |
| `.wallet/` | Cashu wallet data (your Bitcoin!) |
!!! warning "Back Up Your Data"
The `./data` volume contains your wallet. Losing it means losing funds. Back up regularly.
---
## Reverse Proxy (Optional)
For custom domains and SSL, use a reverse proxy like Caddy or nginx.
### Caddy Example
```
api.yournode.com {
reverse_proxy localhost:8000
}
```
### nginx Example
```nginx
server {
listen 443 ssl;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
---
## Updates
Pull the latest image and restart:
```bash
docker compose pull
docker compose up -d
```
---
## Building from Source
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -t routstr-local .
```

View File

@@ -0,0 +1,95 @@
# Discovery
Routstr uses **Nostr** as a decentralized directory for service discovery. Your node announces its presence, models, and pricing on Nostr relays, allowing clients to find you without a central server.
---
## How It Works
1. **Provider Advertisement (Kind 38421)**: Your node periodically publishes an event with its URL, models, and pricing
2. **Client Discovery**: Clients query relays for these events to find suitable providers
---
## Configuration
Configure discovery in **Dashboard****Settings****Nostr**.
### Required Settings
| Field | Description |
|-------|-------------|
| **Npub** | Your node's public identity (clients use this to verify your node) |
| **Nsec** | Your node's private key (used to sign advertisements) |
| **Relays** | Where to publish your announcements |
### Default Relays
If not configured, Routstr publishes to:
- `wss://relay.damus.io`
- `wss://relay.nostr.band`
- `wss://nos.lol`
---
## Advertisement Format
Your node publishes events like:
```json
{
"kind": 38421,
"content": {
"name": "My Routstr Node",
"description": "Fast GPT-4 access via Lightning",
"endpoints": {
"http": "https://api.mynode.com",
"onion": "http://xyz...onion"
},
"models": ["gpt-4", "claude-3-opus"],
"pricing": { ... }
},
"tags": [
["d", "routstr-provider"],
["g", "US"]
]
}
```
---
## Tor Integration
If you're running with Tor (see [Tor Support](tor.md)), your `.onion` address is automatically included in announcements. This allows clients to connect anonymously.
---
## Verify Your Announcements
Check if your node is broadcasting:
1. Copy your `Npub`
2. Search on [Nostr.band](https://nostr.band) or [Primal](https://primal.net)
3. Look for Kind 38421 events
---
## Generating Keys
If you don't have a Nostr identity:
1. Use any Nostr client (e.g., [Primal](https://primal.net), [Damus](https://damus.io))
2. Create an account
3. Export your keys (npub and nsec)
4. Enter them in the dashboard
Or generate keys programmatically:
```python
from nostr_sdk import Keys
keys = Keys.generate()
print(f"npub: {keys.public_key().to_bech32()}")
print(f"nsec: {keys.secret_key().to_bech32()}")
```

91
docs/provider/pricing.md Normal file
View File

@@ -0,0 +1,91 @@
# Pricing
Routstr's pricing engine lets you act as a retailer of AI compute. You pay upstream providers (OpenAI, Anthropic, etc.) at their rates and sell to clients with your markup.
---
## Pricing Strategies
Configure these in **Dashboard****Settings****Pricing**.
### Dynamic Pricing (Default)
Passes through upstream costs plus your percentage markup.
**Formula**: `Client Price = Upstream Cost × Exchange Fee × Upstream Fee`
| Setting | Description | Default |
|---------|-------------|---------|
| **Exchange Fee** | Buffer for BTC price volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit margin | 1.10 (10%) |
**Example**: GPT-4 costs $30/1M tokens from OpenAI. With default settings:
- Price: $30 × 1.005 × 1.10 = $33.17/1M tokens
- At $60k BTC: ~55,000 sats/1M tokens
### Fixed Pricing
Charge a flat rate per request, regardless of model or token count.
| Setting | Description |
|---------|-------------|
| **Fixed Pricing** | Enable flat-rate mode |
| **Fixed Cost** | Sats per request |
**Best for**: Simple proxies, internal tools, or subscription-like access.
---
## Per-Model Pricing
Override pricing for specific models in **Dashboard****Models**.
1. Click on a model
2. Enter custom **Prompt Price** and **Completion Price** (USD per 1M tokens)
3. Save
This overrides both the upstream cost and your global markup for that model.
**Example**: Lock GPT-4 at $35/1M tokens regardless of OpenAI's actual rate or your fee settings.
---
## Token-Based Overrides
Set global fixed rates per token (overrides dynamic pricing for all models):
| Setting | Description |
|---------|-------------|
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
---
## Minimum Charge
Prevent spam with a minimum cost per request:
| Setting | Description | Default |
|---------|-------------|---------|
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
If a request's calculated cost is lower than this, the client pays the minimum instead.
---
## Cost Tracking
Routstr tracks balances in **millisats (msats)** for precision with cheap models.
- 1 sat = 1,000 msats
- API responses include cost in msats
- Lightning withdrawals round down to whole sats
### Client Verification (RIP-05)
Clients can verify charges:
1. Fetch `/v1/models` for your advertised rates
2. Calculate expected cost from token counts
3. Compare to `x-routstr-cost` response header

View File

@@ -0,0 +1,99 @@
# Quick Start
Start earning Bitcoin by selling AI access in under 5 minutes.
## What You'll Build
A **Routstr Provider Node** acts as a gateway that:
1. **Connects** to upstream AI providers (OpenAI, Anthropic, OpenRouter, etc.)
2. **Accepts** Bitcoin payments via Cashu eCash
3. **Serves** AI requests to clients on the network
You bring the API keys, Routstr handles the billing, payments, and client management.
!!! tip "Future: Node-to-Node Routing"
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
---
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) installed
- API credentials from at least one AI provider (OpenAI, Anthropic, OpenRouter, etc.)
---
## 1. Start the Node
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
Verify it's running:
```bash
curl http://localhost:8000/v1/info
```
---
## 2. Configure via Dashboard
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
!!! note "Default Access"
The dashboard has no password by default. Set one immediately in Settings for production use.
### Connect Your AI Providers
1. Navigate to **Settings****Upstream**
2. Enter your upstream URL (e.g., `https://api.openai.com/v1`)
3. Enter your API key
4. Save
### Set Your Profit Margin
1. Go to **Settings****Pricing**
2. Configure your markup (default is 10%)
3. Optionally set a fixed price per request instead
### Secure the Dashboard
1. Go to **Settings****Admin**
2. Set a strong password
3. Save and re-login
---
## 3. Start Earning
Once configured, your node is live. Clients pay you in Bitcoin (via Cashu tokens) for every AI request.
### Monitor Your Earnings
The dashboard shows:
- **Total Wallet**: All Bitcoin held by your node
- **User Balances**: Funds belonging to active client sessions
- **Your Balance**: Your profit (`Total - User Balances`)
### Withdraw Profits
1. Go to **Withdraw** in the dashboard
2. Select amount and mint
3. Generate a Cashu token
4. Redeem to your Lightning wallet
---
## Next Steps
- **[Deployment](deployment.md)**: Production setup with Docker Compose and Tor
- **[Dashboard Guide](dashboard.md)**: Full reference for all dashboard features
- **[Pricing](pricing.md)**: Configure pricing strategies and per-model overrides
- **[Discovery](discovery.md)**: Announce your node on Nostr for clients to find you

50
docs/provider/tor.md Normal file
View File

@@ -0,0 +1,50 @@
# Tor Support
Running Routstr as a **Tor Hidden Service** allows you to offer API access anonymously and bypass NAT/firewalls without port forwarding.
## Automatic Setup (Docker)
The standard `compose.yml` includes a Tor container pre-configured to serve your node.
1. **Start the stack**: `docker compose up -d`
2. **Wait**: Tor takes about 30 seconds to generate keys and bootstrap.
3. **Find your address**:
```bash
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
Output: `v2xyz...longaddress.onion`
Routstr will automatically detect this address (via the `discover_onion_url_from_tor` logic) and include it in:
- The `/v1/info` endpoint.
- Nostr announcements (RIP-02).
## Manual Setup
If you are running outside Docker or managing Tor yourself:
1. **Install Tor**: `sudo apt install tor`
2. **Edit `torrc`**:
```
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
```
3. **Restart Tor**: `sudo systemctl restart tor`
4. **Get Address**: `sudo cat /var/lib/tor/routstr/hostname`
5. **Configure Routstr**:
Set `ONION_URL=http://youraddress.onion` in your `.env` file so the node knows its own address.
## Client Usage
Clients connecting to your `.onion` address must route traffic through SOCKS5.
**Python Example:**
```python
import httpx
from openai import OpenAI
client = OpenAI(
base_url="http://youraddress.onion/v1",
api_key="sk-...",
http_client=httpx.Client(proxy="socks5://127.0.0.1:9050")
)
```

View File

@@ -1,216 +0,0 @@
# Admin Dashboard
The Routstr admin dashboard is a modern web interface for managing your node, monitoring wallet balances, configuring AI models and providers, and handling Bitcoin Lightning payments through Cashu eCash.
## Accessing the Dashboard
### Authentication
The dashboard is protected by password authentication:
1. Navigate to `/admin/` in your browser
2. Enter the admin password
3. Optional: Configure custom base URL if not pre-configured
4. Click "Login"
The interface supports both environment-configured URLs and manual URL entry for deployment flexibility.
## Dashboard Overview
The main dashboard consists of four primary sections accessible through a collapsible sidebar:
- **Dashboard** - Wallet balance monitoring and fund management
- **Models** - AI model management and testing
- **Providers** - Upstream provider configuration
- **Settings** - Node configuration and admin preferences
### Navigation
## Dashboard Page
### Wallet Balance Management
#### Balance Display Options
Switch between display units using the toggle buttons:
- **msat** - Millisatoshis (highest precision)
- **sat** - Satoshis (standard Bitcoin unit)
- **usd** - US Dollar equivalent (when exchange rate available)
#### Balance Overview
The dashboard displays three key metrics:
- **Your Balance (Total)** - Available funds for node operator
- **Total Wallet** - Combined balance across all Cashu mints
- **User Balance** - Funds held for API key holders
#### Detailed Balance Breakdown
View balances by mint with the following information:
| Column | Description |
| ----------- | ------------------------------------- |
| Mint / Unit | Cashu mint URL and currency unit |
| Wallet | Total funds in this mint |
| Users | Funds belonging to API key holders |
| Owner | Your available funds (Wallet - Users) |
### Temporary Balances
Monitor API key activity with:
- **Summary Cards** - Total balance, total spent, total requests
- **Search Functionality** - Filter by key hash or refund address
- **Detailed Table** - Individual key balances with expiry times
- **Auto-refresh** - Updates every 60 seconds
### Fund Management
#### Withdrawing Funds
To withdraw your available balance:
1. Click the **Withdraw** button
2. Select which mint to withdraw from
3. Specify the amount (or withdraw full balance)
4. Click **Generate Token**
5. Copy the generated eCash token
6. Import the token into your Cashu wallet
#### Real-time Updates
- Balances refresh automatically every 30 seconds
- Manual refresh option available
- Live Bitcoin/USD exchange rate integration
- Error handling for mint connectivity issues
## Models Management Page
### Model Organization
Models are organized by provider groups with tabs:
- **All Models** - Combined view of all available models
- **Provider-specific tabs** - Individual providers (OpenRouter, Azure, etc.)
- Badge indicators showing active/total model counts
### Model Management Features
#### Individual Model Operations
For each model you can:
- **Toggle Enable/Disable** - Control model availability
- **View Details** - Context length, pricing, description
- **Edit Configuration** - Model-specific settings
- **Status Indicators** - Green badges for enabled, gray for disabled
#### Bulk Operations
- **Select All/Deselect All** - Quick selection controls
- **Bulk Enable/Disable** - Mass model management
- **Bulk Delete** - Remove model overrides
- **Provider-level Actions** - Apply settings to all models in a provider
#### Model Information Display
- **Model Types** - Text, embedding, image, audio, multimodal indicators
- **Pricing Information** - Per-million-token costs for input/output
- **Context Length** - Maximum tokens supported
- **API Key Status** - Whether credentials are configured
- **Free Model Indicators** - No-cost models clearly marked
## Providers Management Page
### Upstream Provider Configuration
Manage AI provider connections and credentials:
#### Provider Types Supported
- **OpenRouter** - Multi-model aggregator
- **Azure OpenAI** - Microsoft's OpenAI service
- **OpenAI** - Direct OpenAI integration
- **Custom Providers** - Any OpenAI-compatible API
#### Adding New Providers
1. Click **Add Provider**
2. Select **Provider Type** from dropdown
3. Enter **Base URL** (auto-populated for known providers)
4. Add **API Key** for authentication
5. Set **API Version** (required for Azure)
6. Toggle **Enabled** status
7. Click **Create**
#### Provider Management
**Provider Cards Display:**
- Provider type and status (Enabled/Disabled)
- Base URL configuration
- Action buttons (Models, Edit, Delete)
**Available Actions:**
- **Edit** - Modify provider configuration
- **Delete** - Remove provider (with confirmation)
- **View Models** - Expand model discovery interface
- **Enable/Disable** - Toggle provider availability
#### Model Discovery
Each provider shows two types of models:
**Provided Models Tab:**
- Auto-discovered from provider's catalog
- Read-only model information
- Real-time availability updates
**Custom Models Tab:**
- Manually configured model overrides
- Extend or override provider catalog
- Individual enable/disable controls
## Settings Page
### Node Configuration
Configure core node settings and preferences:
#### Basic Information
- **Node Name** - Identifier for your node
- **Node Description** - Descriptive text for your service
- **HTTP URL** - Public HTTP endpoint
- **Onion URL** - Tor hidden service address
#### Nostr Integration
- **Public Key (npub)** - Your Nostr public identity
- **Private Key (nsec)** - Nostr private key with show/hide toggle
- **Nostr Relays** - Configure relays for provider announcements
#### Cashu Mint Management
- **Add Mint URLs** - Configure multiple Cashu mint endpoints
- **Remove Mints** - Delete unused mint configurations
- **Mint Validation** - Verify mint endpoint connectivity
#### Settings Features
- **Real-time Save** - Changes apply immediately
- **Validation** - Form validation with error feedback
- **Secure Fields** - Password masking with reveal toggles
- **Reload Functionality** - Refresh configuration from server
## Next Steps
- [Payment Flow](payment-flow.md) - Understanding Bitcoin payment processing
- [Using the API](using-api.md) - Making API requests to your node
- [Models & Pricing](models-pricing.md) - Configuring model pricing and fees
- [API Reference](../api/overview.md) - Complete API documentation

View File

@@ -1,245 +0,0 @@
# 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

@@ -1,466 +0,0 @@
# 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

@@ -1,373 +0,0 @@
# 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

@@ -1,545 +0,0 @@
# 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

@@ -1,37 +0,0 @@
import os
import openai
client = openai.OpenAI(
api_key=os.environ["CASHU_TOKEN"],
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
# client=httpx.AsyncClient(
# proxies={"http": "socks5://localhost:9050"},
# ), # to use onion proxy (tor)
)
history: list = []
def chat() -> None:
while True:
user_msg = {"role": "user", "content": input("\nYou: ")}
history.append(user_msg)
ai_msg = {"role": "assistant", "content": ""}
for chunk in client.chat.completions.create(
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
messages=history,
stream=True,
):
if len(chunk.choices) > 0:
content = chunk.choices[0].delta.content
if content is not None:
ai_msg["content"] += content
print(content, end="", flush=True)
print()
history.append(ai_msg)
if __name__ == "__main__":
chat()

View File

@@ -0,0 +1,11 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token,
# cashu token is hashed on the server and acts as an Temporary API key
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
print(resp.json())

View File

@@ -0,0 +1,15 @@
import os
import httpx
# Send a Cashu token to the /create endpoint to get a persistent API key
token = os.environ.get("TOKEN")
if not token:
print("Please set TOKEN environment variable with a Cashu token")
exit(1)
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
print(resp.json())

View File

@@ -0,0 +1,12 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
print("Refund successful!")
print(resp.json())

View File

@@ -0,0 +1,16 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
# The Cashu token to top up with
cashu_token = input("Enter Cashu token to top up: ")
resp = httpx.post(
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
)
print(resp.json())

View File

@@ -0,0 +1,15 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5-nano"),
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)

19
examples/list_models.py Normal file
View File

@@ -0,0 +1,19 @@
import os
import httpx
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN", ""),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
for model in client.models.list():
print(model.id)
# OR
models = httpx.get(
f"{client.base_url}/v1/models",
headers={"Authorization": f"Bearer {client.api_key}"},
).json()

View File

@@ -0,0 +1,31 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
conversation = [] # type: ignore
# First turn
response1 = client.responses.create( # type: ignore
model="o4-mini",
input="Hi, my name is Alice.",
conversation=conversation,
)
print("Response 1:", response1.output)
# Note: The 'conversation' parameter might need to be constructed differently
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
# Assuming the SDK manages or returns a conversation object/ID:
# conversation.append(response1)
# Second turn - demonstrating intent, actual implementation depends on strict API spec
# response2 = client.responses.create(
# model="openai/gpt-4o-mini",
# input="What is my name?",
# conversation=conversation,
# )
# print("Response 2:", response2.output)

View File

@@ -0,0 +1,17 @@
import os
from openai import OpenAI
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
# and the base_url points to a compatible proxy like Routstr.
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="Tell me a three sentence bedtime story about a unicorn.",
)
print(response.output)

View File

@@ -0,0 +1,20 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
stream = client.responses.create(
model="claude-4.5-sonnet",
input="Write a short poem about rust.",
stream=True,
)
for event in stream:
# Note: Depending on the SDK version and response structure,
# you might access event.output_delta or similar fields
print(event, end="", flush=True)
print()

View File

@@ -0,0 +1,16 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="What is the latest news about AI?",
tools=[{"type": "web_search"}], # type: ignore
)
print(response.output)

28
examples/streaming.py Normal file
View File

@@ -0,0 +1,28 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
messages = []
while True:
messages.append({"role": "user", "content": input("\nYou: ")})
stream = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5.1-mini"),
messages=messages, # type: ignore
stream=True,
)
print("AI: ", end="")
response_content = ""
for chunk in stream:
if content := chunk.choices[0].delta.content: # type: ignore
print(content, end="", flush=True)
response_content += content
print()
messages.append({"role": "assistant", "content": response_content})

20
examples/tor.py Normal file
View File

@@ -0,0 +1,20 @@
import os
import httpx
from openai import OpenAI
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
http_client=httpx.Client(proxies="socks5://localhost:9050"),
)
print(
client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello from Tor!"}],
)
.choices[0]
.message.content
)

View File

@@ -0,0 +1,37 @@
"""alias-ids
Revision ID: b9667ffc5701
Revises: lightning_invoices
Create Date: 2025-12-25 19:30:44.673350
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "b9667ffc5701"
down_revision = "lightning_invoices"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic ###
op.add_column(
"models",
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
op.add_column(
"models",
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("models", "alias_ids")
op.drop_column("models", "canonical_slug")
# ### end Alembic commands ###

View File

@@ -77,29 +77,27 @@ extra:
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
- Overview: overview.md
- Client Guide:
- Introduction: client/introduction.md
- Payment Flow: client/payments.md
- Integration: client/integration.md
- Provider Guide:
- Quick Start: provider/quickstart.md
- Dashboard: provider/dashboard.md
- Deployment: provider/deployment.md
- Configuration: provider/configuration.md
- Pricing: provider/pricing.md
- Advanced Pricing: provider/advanced-pricing.md
- Discovery: provider/discovery.md
- Tor Support: provider/tor.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
- Contributing:
- Setup Development: contributing/setup.md
- Architecture: contributing/architecture.md
- Code Structure: contributing/code-structure.md
- Testing: contributing/testing.md

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.1"
version = "0.2.2"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -73,6 +73,7 @@ packages = ["routstr"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
exclude = ["examples"]
[tool.mypy]
python_version = "3.11"

View File

@@ -206,19 +206,20 @@ def create_model_mappings(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""Set alias to model/provider if not set or if new model is preferred."""
existing_model = model_instances.get(alias)
alias_lower = alias.lower()
existing_model = model_instances.get(alias_lower)
if not existing_model:
# No existing mapping, set it
model_instances[alias] = model
provider_map[alias] = provider
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
else:
# Check if candidate should replace existing
existing_provider = provider_map[alias]
existing_provider = provider_map[alias_lower]
if should_prefer_model(
model, provider, existing_model, existing_provider, alias
):
model_instances[alias] = model
provider_map[alias] = provider
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
@@ -282,12 +283,8 @@ def create_model_mappings(
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
"Created model mappings",
extra={
"unique_model_count": len(unique_models),
"total_alias_count": len(model_instances),
"provider_distribution": provider_counts,
},
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
extra={"provider_distribution": provider_counts},
)
return model_instances, provider_map, unique_models

View File

@@ -441,6 +441,29 @@ async def adjust_payment_for_tokens(
},
)
async def release_reservation_only() -> None:
"""Fallback to release reservation without charging when main update fails."""
try:
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
)
await session.exec(release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
except Exception as e:
logger.error(
"Failed to release reservation in fallback",
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
)
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
@@ -465,7 +488,7 @@ async def adjust_payment_for_tokens(
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - insufficient reserved balance",
"Failed to finalize max-cost payment - retrying reservation release",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
@@ -474,6 +497,7 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
await release_reservation_only()
else:
await session.refresh(key)
logger.info(
@@ -568,13 +592,14 @@ async def adjust_payment_for_tokens(
)
else:
logger.warning(
"Failed to finalize additional charge (concurrent operation)",
"Failed to finalize additional charge - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
await release_reservation_only()
else:
# Refund some of the base cost
refund = abs(cost_difference)
@@ -603,7 +628,7 @@ async def adjust_payment_for_tokens(
if result.rowcount == 0:
logger.error(
"Failed to finalize payment - insufficient reserved balance",
"Failed to finalize payment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
@@ -612,28 +637,27 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
# Still return the cost data even if we couldn't properly finalize
# The reservation was already made, so the user has paid
await release_reservation_only()
else:
cost.total_msats = total_cost_msats
await session.refresh(key)
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment",
"Cost calculation error during payment adjustment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
@@ -641,6 +665,7 @@ async def adjust_payment_for_tokens(
"error_code": error.code,
},
)
await release_reservation_only()
raise HTTPException(
status_code=400,
@@ -652,7 +677,12 @@ async def adjust_payment_for_tokens(
}
},
)
# Fallback return to satisfy type checker; execution should not reach here
# Fallback: should not reach here, but release reservation just in case
logger.error(
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await release_reservation_only()
return {
"base_msats": deducted_max_cost,
"input_msats": 0,

View File

@@ -154,7 +154,8 @@ async def refund_wallet_endpoint(
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
remaining_balance_msats: int = key.balance
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000

View File

@@ -1493,20 +1493,8 @@ class ModelCreate(BaseModel):
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
enabled: bool = True
class ModelUpdate(BaseModel):
id: str
name: str
description: str
created: int
context_length: int
architecture: dict[str, object]
pricing: dict[str, object]
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
enabled: bool = True
@@ -2416,44 +2404,80 @@ async def admin_upstream_providers(request: Request) -> str:
"/api/upstream-providers/{provider_id}/models",
dependencies=[Depends(require_admin_api)],
)
async def create_provider_model(
async def upsert_provider_model(
provider_id: int, payload: ModelCreate
) -> dict[str, object]:
print(payload)
logger.info(
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
exists = await session.get(ModelRow, (payload.id, provider_id))
if exists:
raise HTTPException(
status_code=409,
detail="Model with this ID already exists for this provider",
)
# Try to get existing model
existing_row = await session.get(ModelRow, (payload.id, provider_id))
row = ModelRow(
id=payload.id,
name=payload.name,
description=payload.description,
created=int(payload.created),
context_length=int(payload.context_length),
architecture=json.dumps(payload.architecture),
pricing=json.dumps(payload.pricing),
sats_pricing=None,
per_request_limits=(
if existing_row:
# Update existing model
logger.info(f"Updating existing model: {payload.id}")
existing_row.name = payload.name
existing_row.description = payload.description
existing_row.created = int(payload.created)
existing_row.context_length = int(payload.context_length)
existing_row.architecture = json.dumps(payload.architecture)
existing_row.pricing = json.dumps(payload.pricing)
existing_row.sats_pricing = None
existing_row.per_request_limits = (
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
),
top_provider=(
)
existing_row.top_provider = (
json.dumps(payload.top_provider) if payload.top_provider else None
),
upstream_provider_id=provider_id,
enabled=payload.enabled,
)
session.add(row)
await session.commit()
await session.refresh(row)
)
existing_row.canonical_slug = payload.canonical_slug
existing_row.alias_ids = (
json.dumps(payload.alias_ids) if payload.alias_ids else None
)
existing_row.enabled = payload.enabled
session.add(existing_row)
await session.commit()
await session.refresh(existing_row)
row = existing_row
else:
# Create new model
logger.info(f"Creating new model: {payload.id}")
row = ModelRow(
id=payload.id,
name=payload.name,
description=payload.description,
created=int(payload.created),
context_length=int(payload.context_length),
architecture=json.dumps(payload.architecture),
pricing=json.dumps(payload.pricing),
sats_pricing=None,
per_request_limits=(
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
),
top_provider=(
json.dumps(payload.top_provider) if payload.top_provider else None
),
canonical_slug=payload.canonical_slug,
alias_ids=(
json.dumps(payload.alias_ids) if payload.alias_ids else None
),
upstream_provider_id=provider_id,
enabled=payload.enabled,
)
session.add(row)
await session.commit()
await session.refresh(row)
await refresh_model_maps()
return _row_to_model(
@@ -2461,6 +2485,20 @@ async def create_provider_model(
).dict() # type: ignore
@admin_router.patch(
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
dependencies=[Depends(require_admin_api)],
)
async def update_provider_model_legacy(
provider_id: int, model_id: str, payload: ModelCreate
) -> dict[str, object]:
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
logger.info(
f"LEGACY_PATCH_UPDATE called: provider_id={provider_id}, model_id={model_id}"
)
return await upsert_provider_model(provider_id, payload)
@admin_router.get(
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
dependencies=[Depends(require_admin_api)],
@@ -2481,76 +2519,6 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
).dict() # type: ignore
@admin_router.patch(
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
dependencies=[Depends(require_admin_api)],
)
async def update_provider_model(
provider_id: int, model_id: str, payload: ModelUpdate
) -> dict[str, object]:
if payload.id != model_id:
raise HTTPException(status_code=400, detail="Path id does not match payload id")
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
row = await session.get(ModelRow, (model_id, provider_id))
if not row:
raise HTTPException(
status_code=404, detail="Model not found for this provider"
)
row.name = payload.name
row.description = payload.description
row.created = int(payload.created)
row.context_length = int(payload.context_length)
row.architecture = json.dumps(payload.architecture)
row.pricing = json.dumps(payload.pricing)
row.sats_pricing = None
row.per_request_limits = (
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
)
row.top_provider = (
json.dumps(payload.top_provider) if payload.top_provider else None
)
was_disabled = not row.enabled
row.enabled = payload.enabled
session.add(row)
await session.commit()
await session.refresh(row)
if was_disabled and payload.enabled:
from ..payment.models import _cleanup_enabled_models_once
try:
await _cleanup_enabled_models_once()
except Exception as e:
logger.warning(
f"Failed to run model cleanup after enabling: {e}",
extra={"model_id": model_id, "error": str(e)},
)
await refresh_model_maps()
return _row_to_model(
row, apply_provider_fee=True, provider_fee=provider.provider_fee
).dict() # type: ignore
@admin_router.put(
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
dependencies=[Depends(require_admin_api)],
)
async def update_provider_model_put(
provider_id: int, model_id: str, payload: ModelUpdate
) -> dict[str, object]:
return await update_provider_model(provider_id, model_id, payload)
@admin_router.delete(
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
dependencies=[Depends(require_admin_api)],
@@ -3112,6 +3080,9 @@ async def get_logs_api(
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
status_codes: str | None = Query(None, description="Comma-separated status codes"),
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
limit: int = 100,
) -> dict[str, object]:
"""
@@ -3122,16 +3093,32 @@ async def get_logs_api(
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
status_codes: Comma-separated list of HTTP status codes
methods: Comma-separated list of HTTP methods
endpoints: Comma-separated list of endpoints
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
status_code_list = None
if status_codes:
try:
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
except ValueError:
pass
method_list = [m.strip() for m in methods.split(",")] if methods else None
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
log_entries = log_manager.search_logs(
date=date,
level=level,
request_id=request_id,
search_text=search,
status_codes=status_code_list,
methods=method_list,
endpoints=endpoint_list,
limit=limit,
)
@@ -3142,6 +3129,9 @@ async def get_logs_api(
"level": level,
"request_id": request_id,
"search": search,
"status_codes": status_codes,
"methods": methods,
"endpoints": endpoints,
"limit": limit,
}

View File

@@ -6,7 +6,7 @@ from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
@@ -53,6 +53,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
return self.balance - self.reserved_balance
async def reset_all_reserved_balances(session: AsyncSession) -> None:
logger.info("Resetting all reserved balances to 0")
stmt = update(ApiKey).values(reserved_balance=0)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
logger.info("Reserved balances reset successfully")
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
id: str = Field(primary_key=True)
@@ -68,6 +76,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
sats_pricing: str | None = Field(default=None)
per_request_limits: str | None = Field(default=None)
top_provider: str | None = Field(default=None)
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
alias_ids: str | None = Field(
default=None, description="JSON array of model alias IDs"
)
enabled: bool = Field(default=True, description="Whether this model is enabled")
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")

View File

@@ -105,6 +105,9 @@ class LogManager:
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
@@ -134,7 +137,13 @@ class LogManager:
for log_data in iterator:
if not self._matches_filters(
log_data, level, request_id, search_text_lower
log_data,
level,
request_id,
search_text_lower,
status_codes,
methods,
endpoints,
):
continue
@@ -153,6 +162,9 @@ class LogManager:
level: str | None,
request_id: str | None,
search_text_lower: str | None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
@@ -160,6 +172,36 @@ class LogManager:
if request_id and log_data.get("request_id") != request_id:
return False
if status_codes:
entry_status = log_data.get("status_code")
if entry_status is not None:
try:
if int(entry_status) not in status_codes:
return False
except (ValueError, TypeError):
return False
else:
return False
if methods:
entry_method = log_data.get("method", "").upper()
if entry_method not in [m.upper() for m in methods]:
return False
if endpoints:
entry_path = log_data.get("path", "")
matched = False
for endpoint in endpoints:
clean_endpoint = endpoint.lstrip("/")
if entry_path.startswith(clean_endpoint):
matched = True
break
if clean_endpoint in entry_path:
matched = True
break
if not matched:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()

View File

@@ -14,7 +14,6 @@ from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import (
cleanup_enabled_models_periodically,
models_router,
update_sats_pricing,
)
@@ -34,9 +33,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.1"
__version__ = "0.2.2"
@asynccontextmanager
@@ -49,7 +48,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
nip91_task = None
providers_task = None
models_refresh_task = None
models_cleanup_task = None
model_maps_refresh_task = None
try:
@@ -63,6 +61,15 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
if s.reset_reserved_balance_on_startup:
from .db import reset_all_reserved_balances
await reset_all_reserved_balances(session)
if not s.admin_password:
logger.warning(
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
)
# Apply app metadata from settings
try:
@@ -80,13 +87,17 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
@@ -94,11 +105,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except asyncio.CancelledError:
@@ -125,8 +131,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if models_cleanup_task is not None:
models_cleanup_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
@@ -144,8 +148,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if models_cleanup_task is not None:
tasks_to_wait.append(models_cleanup_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)

View File

@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"headers": {
k: v
for k, v in request.headers.items()
if k.lower() not in ["authorization", "x-cashu", "cookie"]
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},

View File

@@ -54,12 +54,15 @@ class Settings(BaseSettings):
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")
reset_reserved_balance_on_startup: bool = Field(
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
) # deactivate in horizontal scaling setups
# 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"
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
)
pricing_refresh_interval_seconds: int = Field(
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"

View File

@@ -6,7 +6,7 @@ from typing import Any
import httpx
import websockets
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from .core.logging import get_logger
from .core.settings import settings
@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
logger.debug(f"Found provider announcement: {event['id']}")
logger.debug(
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
)
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")
@@ -387,6 +389,9 @@ async def get_providers(
Return cached providers. If include_json, return provider+health; otherwise provider only.
Optional filter by pubkey.
"""
if settings.providers_refresh_interval_seconds == 0:
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
cache = await get_cache()
if not cache:
await refresh_providers_cache(pubkey=pubkey)

View File

@@ -215,7 +215,7 @@ async def query_nip91_events(
continue
events_out.append(ev_dict)
logger.debug(
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
)
if drained:
last_event_ts = time.time()

View File

@@ -5,6 +5,7 @@ from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.db import AsyncSession
from ..core.settings import settings
from .price import sats_usd_price
logger = get_logger(__name__)
@@ -47,13 +48,6 @@ async def calculate_cost( # todo: can be sync
},
)
cost_data = MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
if "usage" not in response_data or response_data["usage"] is None:
logger.warning(
"No usage data in response, using base cost only",
@@ -62,7 +56,62 @@ async def calculate_cost( # todo: can be sync
"model": response_data.get("model", "unknown"),
},
)
return cost_data
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
)
usage_data = response_data["usage"]
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
if "cost_details" in usage_data:
usd_cost = float(
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
try:
usd_cost = float(usage_data.get("cost", 0) or 0)
except Exception:
pass
if usd_cost > 0:
try:
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
logger.info(
"Using cost from usage data/details",
extra={
"usd_cost": usd_cost,
"cost_in_sats": cost_in_sats,
"cost_in_msats": cost_in_msats,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
)
except Exception as e:
logger.warning(
"Error calculating cost from usage data",
extra={
"error": str(e),
"usd_cost": usd_cost,
"model": response_data.get("model", "unknown"),
},
)
# Fall through to token-based calculation
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
@@ -127,12 +176,38 @@ async def calculate_cost( # todo: can be sync
"model": response_data.get("model", "unknown"),
},
)
return cost_data
return MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)

View File

@@ -283,7 +283,7 @@ async def raw_send_to_lnurl(
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
)
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
estimated_fees_msat = estimated_fees_sat * 1000
final_amount = amount_msat - estimated_fees_msat

View File

@@ -1,16 +1,13 @@
import asyncio
import json
import random
from pathlib import Path
from urllib.request import urlopen
import httpx
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.db import ModelRow, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
@@ -89,53 +86,38 @@ def _has_valid_pricing(model: dict) -> bool:
return True
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
with urlopen(f"{base_url}/models") as response:
data = json.loads(response.read().decode("utf-8"))
models_data: list[dict] = []
for model in data.get("data", []):
model_id = model.get("id", "")
if source_filter:
source_prefix = f"{source_filter}/"
if not model_id.startswith(source_prefix):
continue
model = dict(model)
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
continue
models_data.append(model)
return models_data
except Exception as e:
logger.error(f"Error fetching models from OpenRouter API: {e}")
return []
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Asynchronously fetch model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/models", timeout=30)
response.raise_for_status()
data = response.json()
models_response, embeddings_response = await asyncio.gather(
client.get(f"{base_url}/models", timeout=30),
client.get(f"{base_url}/embeddings/models", timeout=30),
return_exceptions=True,
)
def process_models_response(
response: httpx.Response | BaseException,
) -> list[dict]:
if not isinstance(response, BaseException):
response.raise_for_status()
data = response.json()
return [
model
for model in data.get("data", [])
if ":free" not in model.get("id", "").lower()
]
return []
models_data: list[dict] = []
for model in data.get("data", []):
models_data.extend(process_models_response(models_response))
models_data.extend(process_models_response(embeddings_response))
# Apply source filter and exclusions
filtered_models = []
for model in models_data:
model_id = model.get("id", "")
if source_filter:
@@ -153,9 +135,9 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if not _has_valid_pricing(model):
continue
models_data.append(model)
filtered_models.append(model)
return models_data
return filtered_models
except Exception as e:
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
return []
@@ -169,69 +151,6 @@ def is_openrouter_upstream() -> bool:
return base.lower() == "https://openrouter.ai/api/v1"
def load_models() -> list[Model]:
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
The file path can be specified via the ``MODELS_PATH`` environment variable.
If a user-provided models.json exists, it will be used. Otherwise, models are
automatically fetched from OpenRouter API in memory. If the example file exists
and no user file is provided, it will be used as a fallback.
"""
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():
logger.info(f"Loading models from user-provided file: {models_path}")
try:
with models_path.open("r") as f:
data = json.load(f)
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
# Only auto-generate from OpenRouter when upstream is OpenRouter
if not is_openrouter_upstream():
logger.info(
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
)
return []
logger.info("Auto-generating models from OpenRouter API")
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)
if not models_data:
logger.error("Failed to fetch models from OpenRouter API")
return []
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
valid_models = []
for model_data in models_data:
try:
model = Model(**model_data) # type: ignore
valid_models.append(model)
except Exception as e:
model_id = model_data.get("id", "unknown")
logger.warning(f"Skipping model {model_id} - validation failed: {e}")
if len(valid_models) != len(models_data):
logger.warning(
f"Filtered out {len(models_data) - len(valid_models)} models with incomplete data"
)
return valid_models
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -265,6 +184,7 @@ def _row_to_model(
enabled=row.enabled,
upstream_provider_id=row.upstream_provider_id,
canonical_slug=getattr(row, "canonical_slug", None),
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
)
if apply_provider_fee:
@@ -333,6 +253,11 @@ async def list_models(
else 1.01,
)
for r in rows
if include_disabled
or (
r.upstream_provider_id in providers_by_id
and providers_by_id[r.upstream_provider_id].enabled
)
]
@@ -345,6 +270,8 @@ async def get_model_by_id(
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider or not provider.enabled:
return None
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
@@ -460,60 +387,9 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
return model
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 and is_openrouter_upstream():
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)
elif not models_to_insert:
logger.info(
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
)
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_once() -> None:
"""Update sats pricing once for all provider models (in-memory only)."""
from ..proxy import get_upstreams
from ..proxy import get_upstreams, refresh_model_maps
upstreams = get_upstreams()
sats_to_usd = sats_usd_price()
@@ -530,6 +406,7 @@ async def _update_sats_pricing_once() -> None:
if updated_count > 0:
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
await refresh_model_maps()
async def update_sats_pricing() -> None:
@@ -540,7 +417,13 @@ async def update_sats_pricing() -> None:
except Exception:
pass
await _update_sats_pricing_once()
try:
await _update_sats_pricing_once()
except Exception as e:
logger.warning(
"Initial sats pricing update failed (will retry in loop)",
extra={"error": str(e)},
)
while True:
try:
@@ -564,184 +447,6 @@ async def update_sats_pricing() -> None:
logger.error(f"Error updating sats pricing: {e}")
async def cleanup_enabled_models_periodically() -> None:
"""Background task to clean up enabled models that match upstream pricing.
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
Keep it in DB only if pricing differs from upstream or if it's disabled.
"""
interval = getattr(
settings, "models_cleanup_interval_seconds", 300
) # 5 minutes default
if not interval or interval <= 0:
return
while True:
try:
await _cleanup_enabled_models_once()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(
"Error during enabled models cleanup",
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
async def _cleanup_enabled_models_once() -> None:
"""Clean up enabled models that match upstream pricing."""
from ..proxy import get_upstreams
async with create_session() as session:
# Get all enabled models from DB
result = await session.exec(
select(ModelRow).where(
ModelRow.enabled, # Only enabled models
)
)
db_models = result.all()
if not db_models:
return
upstreams = get_upstreams()
models_to_remove = []
for db_model in db_models:
# Find corresponding upstream model
upstream_model = None
for upstream in upstreams:
upstream_model = upstream.get_cached_model_by_id(db_model.id)
if upstream_model:
break
if not upstream_model:
continue
# Compare pricing to see if they match
db_pricing = json.loads(db_model.pricing)
upstream_pricing = upstream_model.pricing.dict()
# Check if pricing matches (with small tolerance for float comparison)
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
if pricing_matches:
models_to_remove.append(db_model)
logger.info(
f"Removing enabled model {db_model.id} - matches upstream pricing",
extra={"model_id": db_model.id},
)
# Remove models that match upstream pricing
for model in models_to_remove:
await session.delete(model)
if models_to_remove:
await session.commit()
logger.info(
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
)
def _pricing_matches(
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
) -> bool:
"""Check if pricing dictionaries match within tolerance."""
keys_to_compare = [
"prompt",
"completion",
"request",
"image",
"web_search",
"internal_reasoning",
]
for key in keys_to_compare:
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
if abs(db_val - upstream_val) > tolerance:
return False
return True
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
# Only refresh from OpenRouter when upstream is OpenRouter
if not is_openrouter_upstream():
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
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(session: AsyncSession = Depends(get_session)) -> dict:

View File

@@ -79,15 +79,29 @@ async def _fetch_btc_usd_price() -> float:
"""Fetch the lowest BTC/USD price from multiple exchanges."""
async with httpx.AsyncClient(timeout=30.0) as client:
try:
prices = await asyncio.gather(
_kraken_btc_usd(client),
_coinbase_btc_usd(client),
_binance_btc_usdt(client),
)
valid_prices = [price for price in prices if price is not None]
tasks = [
asyncio.create_task(_kraken_btc_usd(client)),
asyncio.create_task(_coinbase_btc_usd(client)),
asyncio.create_task(_binance_btc_usdt(client)),
]
valid_prices: list[float] = []
for future in asyncio.as_completed(tasks):
price = await future
if price is not None:
valid_prices.append(price)
if len(valid_prices) >= 2:
break
for task in tasks:
if not task.done():
task.cancel()
if not valid_prices:
logger.error("No valid BTC prices obtained from any exchange")
raise ValueError("Unable to fetch BTC price from any exchange")
return min(valid_prices)
except Exception as e:
logger.error(

View File

@@ -65,12 +65,12 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
def get_model_instance(model_id: str) -> Model | None:
"""Get Model instance by ID from global cache."""
return _model_instances.get(model_id)
return _model_instances.get(model_id.lower())
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
return _provider_map.get(model_id)
return _provider_map.get(model_id.lower())
def get_unique_models() -> list[Model]:
@@ -80,31 +80,29 @@ def get_unique_models() -> list[Model]:
async def refresh_model_maps() -> None:
"""Refresh global model and provider maps using the cost-based algorithm."""
from sqlalchemy.orm import selectinload
global _model_instances, _provider_map, _unique_models
# Gather database overrides and disabled models
async with create_session() as session:
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
override_rows = result.all()
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
row.id: (
row,
providers_by_id[row.upstream_provider_id].provider_fee
if row.upstream_provider_id in providers_by_id
else 1.01,
)
for row in override_rows
if row.upstream_provider_id is not None
}
disabled_result = await session.exec(
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
# Fetch all providers with their models in a single logical operation
query = select(UpstreamProviderRow).options(
selectinload(UpstreamProviderRow.models) # type: ignore
)
disabled_model_ids = {row for row in disabled_result.all()}
result = await session.exec(query)
provider_rows = result.all()
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
disabled_model_ids: set[str] = set()
for provider in provider_rows:
if not provider.enabled:
continue
for model in provider.models:
if model.enabled:
overrides_by_id[model.id] = (model, provider.provider_fee)
else:
disabled_model_ids.add(model.id)
_model_instances, _provider_map, _unique_models = create_model_mappings(
upstreams=_upstreams,
@@ -141,20 +139,14 @@ async def proxy(
"unauthorized", "Unauthorized", 401, request=request
)
logger.info( # TODO: move to middleware, async
"Received proxy request",
extra={
"method": request.method,
"path": path,
"client_host": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent", "unknown")[:100],
},
)
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
request_body = await request.body()
request_body_dict = parse_request_body_json(request_body, path)
model_id = request_body_dict.get("model", "unknown")
if is_responses_api:
model_id = extract_model_from_responses_request(request_body_dict)
else:
model_id = request_body_dict.get("model", "unknown")
model_obj = get_model_instance(model_id)
if not model_obj:
@@ -180,9 +172,14 @@ async def proxy(
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
if is_responses_api:
return await upstream.handle_x_cashu_responses(
request, x_cashu, path, max_cost_for_model, model_obj
)
else:
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
@@ -197,28 +194,50 @@ async def proxy(
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
# TODO: why is this needed? can we remove it?
headers = upstream.prepare_headers(dict(request.headers))
return await upstream.forward_get_request(request, path, headers)
# Only pay for request if we have request body data (for completions endpoints)
if request_body_dict:
await pay_for_request(key, max_cost_for_model, session)
# Prepare headers for upstream
headers = upstream.prepare_headers(dict(request.headers))
# Forward to upstream and handle response
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
except Exception as e:
logger.error(
"Upstream request failed, ensuring payment is reverted",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
raise
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
@@ -321,6 +340,24 @@ async def get_bearer_token_key(
raise
def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> str:
if model := request_body_dict.get("model"):
return model
if input_data := request_body_dict.get("input"):
if isinstance(input_data, dict) and (model := input_data.get("model")):
return model
if request_body_dict.get("messages"):
return "unknown"
logger.warning(
"No model found in Responses API request",
extra={"body_keys": list(request_body_dict.keys())},
)
return "unknown"
def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
request_body_dict = {}
if request_body:

File diff suppressed because it is too large Load Diff

View File

@@ -187,11 +187,44 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
payment_finalized = False
async def finalize_payment() -> None:
nonlocal payment_finalized
if payment_finalized:
return
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
await adjust_payment_for_tokens(
fresh_key,
{
"model": model_obj.id,
"usage": final_usage_data,
},
new_session,
max_cost_for_model,
)
payment_finalized = True
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment in fallback",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
@@ -202,6 +235,9 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
},
)
raise
finally:
if not payment_finalized:
await finalize_payment()
return StreamingResponse(
stream_with_cost(),

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import os
import re
from typing import TYPE_CHECKING
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..core.settings import Settings
from sqlmodel import select
from ..core import get_logger
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
@@ -100,6 +102,8 @@ async def get_all_models_with_overrides(
)
for row in override_rows
if row.upstream_provider_id is not None
and row.upstream_provider_id in providers_by_id
and providers_by_id[row.upstream_provider_id].enabled
}
all_models: dict[str, Model] = {}
@@ -145,6 +149,17 @@ async def refresh_upstreams_models_periodically(
f"Error refreshing models for {upstream.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
from ..payment.models import _update_sats_pricing_once
await _update_sats_pricing_once()
except Exception as e:
logger.warning(f"Failed to update pricing after model refresh: {e}")
from ..proxy import refresh_model_maps
await refresh_model_maps()
except asyncio.CancelledError:
break
except Exception as e:
@@ -166,8 +181,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
Seeds database with providers from settings if empty, then loads and instantiates
provider instances from database records, and refreshes their models cache.
"""
from sqlmodel import select
from ..core.settings import settings
async with create_session() as session:
@@ -183,16 +196,16 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
upstreams: list[BaseUpstreamProvider] = []
for provider_row in existing_providers:
async def _init_single_provider(
provider_row: UpstreamProviderRow,
) -> BaseUpstreamProvider | None:
if not provider_row.enabled:
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
continue
return None
provider = _instantiate_provider(provider_row)
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
@@ -200,6 +213,12 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
"models_cached": len(provider.get_cached_models()),
},
)
return provider
return None
tasks = [_init_single_provider(row) for row in existing_providers]
results = await asyncio.gather(*tasks)
upstreams = [p for p in results if p is not None]
return upstreams

View File

@@ -50,7 +50,13 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
models = [Model(**model) for model in models_data] # type: ignore
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
for model in models:
if model.id == "openai/text-embedding-ada-002":
model.alias_ids = ["text-embedding-ada-002-v2"]
break
return models
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.

View File

@@ -81,7 +81,7 @@ async def swap_to_primary_mint(
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)

View File

@@ -0,0 +1,101 @@
import asyncio
import httpx
BASE_URL = input("Enter routstr URL: ")
API_KEY = input("Enter key or token: ")
async def get_balance(client: httpx.AsyncClient) -> int:
response = await client.get("/v1/balance/info")
response.raise_for_status()
data = response.json()
print(f"Current Balance Info: {data}")
return data.get("reserved", 0)
async def reproduce() -> None:
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
async with httpx.AsyncClient(
base_url=BASE_URL, headers=headers, timeout=30.0
) as client:
print("Checking initial balance...")
try:
initial_reserved = await get_balance(client)
except Exception as e:
print(f"Failed to get balance: {e}")
return
print("\nStarting streaming request...")
try:
# Create a separate client for the stream so we can close it independently if needed,
# but usually just breaking the loop and exiting the context manager is enough.
# However, to be sure we simulate a harsh disconnect, we can just cancel the task or close the client.
async with client.stream(
"POST",
"/v1/chat/completions",
json={
"model": "gpt-5-nano",
"messages": [
{
"role": "user",
"content": "Write a long poem about the ocean.",
}
],
"stream": True,
},
) as response:
print(f"Stream status: {response.status_code}")
if response.status_code != 200:
err_bytes = await response.aread()
try:
err_str = err_bytes.decode()
except Exception:
err_str = repr(err_bytes)
print(f"Error: {err_str}")
return
print("Stream started. Reading a few chunks...")
count = 0
async for chunk in response.aiter_bytes():
print(f"Received chunk: {len(chunk)} bytes")
count += 1
if count >= 3:
print("Simulating client disconnect (breaking stream)...")
break
except Exception as e:
print(f"Stream interrupted (expected): {e}")
# Wait a bit for the server to realize we disconnected (though with asyncio it might be immediate or depend on keepalive)
print("\nWaiting for server to process disconnect...")
await asyncio.sleep(21)
print("\nChecking final balance...")
try:
final_reserved = await get_balance(client)
except Exception:
# Retry once if connection was closed
async with httpx.AsyncClient(
base_url=BASE_URL, headers=headers, timeout=30.0
) as new_client:
final_reserved = await get_balance(new_client)
if final_reserved > initial_reserved:
print(
f"\n[FAIL] Bug reproduced! Reserved balance increased: {initial_reserved} -> {final_reserved}"
)
print(f"Accumulated reserved balance: {final_reserved - initial_reserved}")
else:
print(
f"\n[PASS] Reserved balance released correctly: {initial_reserved} -> {final_reserved}"
)
if __name__ == "__main__":
try:
asyncio.run(reproduce())
except KeyboardInterrupt:
pass

View File

@@ -0,0 +1,97 @@
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
"""Test the embeddings endpoint proxy functionality"""
test_payload = {
"model": "text-embedding-ada-002",
"input": "The quick brown fox",
}
mock_response_data = {
"object": "list",
"data": [
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
],
"model": "text-embedding-ada-002",
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
with patch("httpx.AsyncClient.send") as mock_send:
# Create a proper async generator for iter_bytes
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
# Use MagicMock for synchronous .json() method
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
# Make POST request to embeddings endpoint
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
assert response.status_code == 200
response_data = response.json()
assert response_data["object"] == "list"
assert len(response_data["data"]) == 1
assert response_data["data"][0]["object"] == "embedding"
# Verify request was forwarded
mock_send.assert_called_once()
forwarded_request = mock_send.call_args[0][0]
# Verify the path ends with embeddings
# Note: forwarded path might be full URL
assert str(forwarded_request.url).endswith("embeddings")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
"""Test that model lookups are case insensitive"""
# We'll use a mixed-case model ID that should match the lowercase one in the system
# We assume 'gpt-3.5-turbo' is available in the mock env/database
test_payload = {
"model": "GPT-3.5-TURBO",
"messages": [{"role": "user", "content": "Hello"}],
}
with patch("httpx.AsyncClient.send") as mock_send:
mock_response_data = {
"id": "chatcmpl-123",
"object": "chat.completion",
"choices": [{"message": {"content": "Hi"}}],
"usage": {"total_tokens": 10},
}
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
response = await authenticated_client.post(
"/v1/chat/completions", json=test_payload
)
assert response.status_code == 200

View File

@@ -10,7 +10,6 @@ from httpx import AsyncClient
from .utils import (
CashuTokenGenerator,
PerformanceValidator,
ResponseValidator,
)
@@ -159,30 +158,7 @@ async def test_error_handling(
assert response.status_code == 401
@pytest.mark.integration
@pytest.mark.asyncio
async def test_performance_requirements(integration_client: AsyncClient) -> None:
"""Test that endpoints meet performance requirements"""
validator = PerformanceValidator()
# Test info endpoint performance
for i in range(50):
start = validator.start_timing("info_endpoint")
response = await integration_client.get("/")
validator.end_timing("info_endpoint", start)
assert response.status_code == 200
# Validate 95th percentile is under 500ms
result = validator.validate_response_time(
"info_endpoint", max_duration=0.5, percentile=0.95
)
assert result["valid"], (
f"Performance requirement failed: "
f"95th percentile was {result['percentile_time']:.3f}s "
f"(required < {result['max_allowed']}s)"
)
@pytest.mark.integration

View File

@@ -9,6 +9,7 @@ import gc
import statistics
import time
from typing import Any, Dict, List
from unittest.mock import patch
import psutil
import pytest
@@ -105,42 +106,46 @@ class TestPerformanceBaseline:
("GET", "/v1/wallet/info", authenticated_client, None),
]
# Warm up
for _ in range(10):
await integration_client.get("/")
# Enable provider discovery for this test
with patch(
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
):
# Warm up
for _ in range(10):
await integration_client.get("/")
# Test each endpoint
for method, path, client, data in endpoints:
response_times = []
# Test each endpoint
for method, path, client, data in endpoints:
response_times = []
for i in range(100):
start = time.time()
for i in range(100):
start = time.time()
if method == "GET":
response = await client.get(path)
else:
response = await client.post(path, json=data)
if method == "GET":
response = await client.get(path)
else:
response = await client.post(path, json=data)
duration = time.time() - start
response_times.append(duration * 1000) # Convert to ms
duration = time.time() - start
response_times.append(duration * 1000) # Convert to ms
assert response.status_code in [200, 201]
assert response.status_code in [200, 201]
if i % 10 == 0:
metrics.record_system_metrics()
if i % 10 == 0:
metrics.record_system_metrics()
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
print(f" P95: {p95:.2f}ms")
print(
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
print(f" P95: {p95:.2f}ms")
print(
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.integration

View File

@@ -3,7 +3,7 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
from typing import Any
from typing import Any, Generator
from unittest.mock import patch
import pytest
@@ -11,7 +11,7 @@ from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from .utils import PerformanceValidator, ResponseValidator
from .utils import ResponseValidator
@pytest.fixture(autouse=True)
@@ -19,6 +19,15 @@ def _clear_providers_cache() -> None:
_PROVIDERS_CACHE.clear()
@pytest.fixture(autouse=True)
def _enable_provider_discovery() -> Generator[None, Any, Any]:
"""Enable provider discovery for all tests in this module"""
with patch(
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
):
yield
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_default_response(
@@ -518,46 +527,6 @@ async def test_providers_endpoint_response_format(
assert isinstance(data_json["providers"], list)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_performance(integration_client: AsyncClient) -> None:
"""Test providers endpoint meets performance requirements"""
# Mock quick responses to avoid network delays
mock_events: list[dict[str, Any]] = [
{
"id": f"event{i}",
"content": f"Provider: http://provider{i}.onion",
"created_at": 1234567890 + i,
}
for i in range(5)
]
validator = PerformanceValidator()
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test multiple requests
for i in range(10):
start = validator.start_timing("providers_endpoint")
response = await integration_client.get("/v1/providers/")
validator.end_timing("providers_endpoint", start)
assert response.status_code == 200
# Validate performance (should be fast with mocked dependencies)
perf_result = validator.validate_response_time(
"providers_endpoint",
max_duration=2.0, # Allow more time since it involves multiple operations
percentile=0.95,
)
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_concurrent_requests(

View File

@@ -18,7 +18,6 @@ from routstr.core.db import ApiKey
from .utils import (
ConcurrencyTester,
PerformanceValidator,
)
@@ -551,39 +550,7 @@ async def test_proxy_get_concurrent_requests(
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_get_performance_requirements(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test that GET proxy requests meet performance requirements"""
validator = PerformanceValidator()
with patch("httpx.AsyncClient.request") as mock_request:
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.json = MagicMock(return_value={"performance": "test"})
mock_response.text = '{"performance": "test"}'
mock_response.iter_bytes = AsyncMock(return_value=[b'{"performance": "test"}'])
mock_request.return_value = mock_response
# Test multiple requests for performance measurement
for i in range(20):
start = validator.start_timing("proxy_get")
response = await authenticated_client.get(f"/v1/perf-test-{i}")
validator.end_timing("proxy_get", start)
assert response.status_code == 200
# Validate performance requirements
perf_result = validator.validate_response_time(
"proxy_get",
max_duration=1.0, # Should complete within 1 second
percentile=0.95,
)
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
@pytest.mark.integration

View File

@@ -15,7 +15,6 @@ from httpx import ASGITransport, AsyncClient
from .utils import (
ConcurrencyTester,
PerformanceValidator,
)
@@ -290,55 +289,7 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
assert response.status_code in [400, 401]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_post_performance(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test POST endpoint performance requirements"""
test_payload = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Performance test"}],
}
validator = PerformanceValidator()
with patch("httpx.AsyncClient.send") as mock_send:
# Mock fast responses
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield b'{"choices": [{"message": {"content": "Fast"}}], "usage": {"total_tokens": 5}}'
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
response_data = {
"choices": [{"message": {"content": "Fast"}}],
"usage": {"total_tokens": 5},
}
mock_response.text = json.dumps(response_data)
mock_response.json = AsyncMock(return_value=response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
# Run multiple requests for performance measurement
for i in range(20):
start = validator.start_timing("proxy_post")
response = await authenticated_client.post(
"/v1/chat/completions", json=test_payload
)
validator.end_timing("proxy_post", start)
assert response.status_code == 200
# Validate performance
perf_result = validator.validate_response_time(
"proxy_post",
max_duration=1.5, # Allow slightly more time for POST
percentile=0.95,
)
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
@pytest.mark.integration

View File

@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
Tests POST /v1/wallet/topup endpoint and authorization header validation.
"""
import hashlib
from datetime import datetime, timedelta
from typing import Any
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
# The expiry time and refund address functionality is tested elsewhere
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_token_submissions(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test concurrent submissions of different tokens"""
# Generate multiple unique tokens with known amounts
num_tokens = 10
tokens = []
expected_balances = {}
for i in range(num_tokens):
amount = 100 + i * 10
token = await testmint_wallet.mint_tokens(amount)
tokens.append(token)
# Store expected balance by token hash
hashed_key = hashlib.sha256(token.encode()).hexdigest()
expected_balances[hashed_key] = amount * 1000 # msats
# Create concurrent requests
requests = [
{
"method": "GET",
"url": "/v1/wallet/info",
"headers": {"Authorization": f"Bearer {token}"},
}
for token in tokens
]
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=5
)
# All should succeed
assert len(responses) == num_tokens
api_keys = set()
for response in responses:
assert response.status_code == 200
data = response.json()
api_key = data["api_key"]
api_keys.add(api_key)
# Verify balance matches the expected amount
hashed_key = api_key[3:] # Remove "sk-" prefix
assert data["balance"] == expected_balances[hashed_key]
# Should have created unique API keys
assert len(api_keys) == num_tokens
# Verify all keys exist in database
for api_key in api_keys:
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.balance == expected_balances[hashed_key]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_authorization_with_cashu_token_directly(
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_api_key_consistency_under_load(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test API key generation consistency under concurrent load"""
# Generate a single token
token = await testmint_wallet.mint_tokens(1000)
# First request to create the API key
integration_client.headers["Authorization"] = f"Bearer {token}"
initial_response = await integration_client.get("/v1/wallet/info")
assert initial_response.status_code == 200
expected_api_key = initial_response.json()["api_key"]
expected_balance = initial_response.json()["balance"]
# Try to use the same token concurrently multiple times
# All should return the same API key since it's already created
requests = [
{
"method": "GET",
"url": "/v1/wallet/info",
"headers": {"Authorization": f"Bearer {token}"},
}
for _ in range(20) # 20 concurrent attempts
]
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=10
)
# All should succeed and return the same API key
for response in responses:
assert response.status_code == 200
data = response.json()
assert data["api_key"] == expected_api_key
assert data["balance"] == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_database_timestamp_accuracy(

View File

@@ -3,7 +3,7 @@ Integration tests for wallet information retrieval endpoints.
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
"""
import time
from datetime import datetime, timedelta
from typing import Any
@@ -13,7 +13,7 @@ from sqlmodel import select, update
from routstr.core.db import ApiKey
from .utils import ConcurrencyTester, ResponseValidator
from .utils import ResponseValidator
@pytest.mark.integration
@@ -204,45 +204,6 @@ async def test_expired_api_key_behavior(
assert db_key.refund_address == "test@lightning.address"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_access_same_api_key(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test concurrent access with the same API key"""
# Get the API key from authenticated client
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
initial_balance = response.json()["balance"]
# Create multiple concurrent requests
requests = []
for i in range(20):
# Alternate between both endpoints
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
requests.append(
{
"method": "GET",
"url": endpoint,
"headers": {"Authorization": f"Bearer {api_key}"},
}
)
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=10
)
# All should succeed with consistent data
for response in responses:
assert response.status_code == 200
data = response.json()
assert data["api_key"] == api_key
assert data["balance"] == initial_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_data_consistency(
@@ -406,30 +367,4 @@ async def test_wallet_info_with_special_characters_in_headers(
# Note: Current implementation doesn't return refund_address in response
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_wallet_endpoints_performance(authenticated_client: AsyncClient) -> None:
"""Test wallet endpoints meet performance requirements"""
# Warm up
await authenticated_client.get("/v1/wallet/")
# Measure response times
response_times = []
for _ in range(50):
start_time = time.time()
response = await authenticated_client.get("/v1/wallet/")
end_time = time.time()
assert response.status_code == 200
response_times.append(end_time - start_time)
# Calculate statistics
avg_time = sum(response_times) / len(response_times)
max_time = max(response_times)
# Performance assertions
assert avg_time < 0.1 # Average should be under 100ms
assert max_time < 0.5 # No request should take more than 500ms

View File

@@ -537,42 +537,4 @@ async def test_refund_with_expired_key(
assert response.json()["recipient"] == "expired@ln.address"
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_refund_performance(
integration_client: AsyncClient, testmint_wallet: Any
) -> None:
"""Test refund endpoint performance"""
import time
# Create multiple API keys
api_keys = []
for i in range(10):
token = await testmint_wallet.mint_tokens(100 + i)
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_keys.append(response.json()["api_key"])
# Measure refund times
refund_times = []
for api_key in api_keys:
integration_client.headers["Authorization"] = f"Bearer {api_key}"
start_time = time.time()
response = await integration_client.post("/v1/wallet/refund")
end_time = time.time()
assert response.status_code == 200
refund_times.append(end_time - start_time)
# Performance assertions
avg_time = sum(refund_times) / len(refund_times)
max_time = max(refund_times)
assert avg_time < 0.5 # Average under 500ms
assert max_time < 1.0 # No refund takes more than 1 second

View File

@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
assert response.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test concurrent top-ups to the same API key"""
# Get API key
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
initial_balance = response.json()["balance"]
# Generate multiple unique tokens
num_tokens = 10
tokens = []
total_amount = 0
for i in range(num_tokens):
amount = 100 + i * 10 # Different amounts
token = await testmint_wallet.mint_tokens(amount)
tokens.append(token)
total_amount += amount
# Create concurrent top-up requests
requests = [
{
"method": "POST",
"url": "/v1/wallet/topup",
"params": {"cashu_token": token},
"headers": {"Authorization": f"Bearer {api_key}"},
}
for token in tokens
]
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=5
)
# All should succeed
for response in responses:
assert response.status_code == 200
assert "msats" in response.json()
# Verify final balance is correct
final_response = await authenticated_client.get("/v1/wallet/")
final_balance = final_response.json()["balance"]
expected_balance = initial_balance + (total_amount * 1000)
assert final_balance == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]

View File

@@ -4,17 +4,22 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN npm i
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
RUN pnpm run build
FROM base AS runner
WORKDIR /app

View File

@@ -6,12 +6,14 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN npm ci
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
# Build the UI
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -27,7 +29,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN npm run build && \
RUN pnpm run build && \
echo "UI build completed at $(date)"
# Use the builder stage as the final stage

View File

@@ -21,7 +21,17 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { CalendarIcon, Filter, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
@@ -31,11 +41,17 @@ interface LogFiltersProps {
selectedLevel: string;
requestId: string;
searchText: string;
selectedStatusCodes: string[];
selectedMethods: string[];
selectedEndpoints: string[];
limit: number;
onDateChange: (date: string) => void;
onLevelChange: (level: string) => void;
onRequestIdChange: (requestId: string) => void;
onSearchTextChange: (searchText: string) => void;
onStatusCodesChange: (statusCodes: string[]) => void;
onMethodsChange: (methods: string[]) => void;
onEndpointsChange: (endpoints: string[]) => void;
onLimitChange: (limit: number) => void;
onClearFilters: () => void;
}
@@ -43,16 +59,87 @@ interface LogFiltersProps {
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
const STATUS_CODE_OPTIONS = [
'200',
'201',
'204',
'400',
'401',
'402',
'403',
'404',
'422',
'429',
'500',
'502',
'503',
'504',
];
const METHOD_OPTIONS = [
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'OPTIONS',
'HEAD',
];
const ENDPOINT_OPTIONS = [
'/chat/completions',
'/v1/chat/completions',
'/models',
'/v1/models',
'/responses',
'/v1/responses',
'v1/embeddings/models',
'/embeddings/models',
];
interface FilterBadgeProps {
value: string;
onRemove: (value: string) => void;
}
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(value);
}}
className='hover:bg-muted-foreground/20 rounded-full'
>
<X className='h-3 w-3' />
</button>
</Badge>
);
}
export function LogFilters({
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
onDateChange,
onLevelChange,
onRequestIdChange,
onSearchTextChange,
onStatusCodesChange,
onMethodsChange,
onEndpointsChange,
onLimitChange,
onClearFilters,
}: LogFiltersProps) {
@@ -68,6 +155,10 @@ export function LogFilters({
: undefined
);
const [statusSearch, setStatusSearch] = useState('');
const [methodSearch, setMethodSearch] = useState('');
const [endpointSearch, setEndpointSearch] = useState('');
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
@@ -129,6 +220,31 @@ export function LogFilters({
}
};
const toggleSelection = (
current: string[],
value: string,
onChange: (val: string[]) => void
) => {
if (current.includes(value)) {
onChange(current.filter((v) => v !== value));
} else {
onChange([...current, value]);
}
};
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
const newSelection = new Set([...selectedStatusCodes]);
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
if (allIncluded) {
codes.forEach((c) => newSelection.delete(c));
} else {
codes.forEach((c) => newSelection.add(c));
}
onStatusCodesChange(Array.from(newSelection));
};
return (
<Card className='mb-6'>
<CardHeader>
@@ -137,7 +253,8 @@ export function LogFilters({
Filters
</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, and limit
Filter logs by date, level, request ID, text search, status code,
method, endpoint and limit
</CardDescription>
</CardHeader>
<CardContent>
@@ -197,6 +314,337 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Quick Filters'>
<CommandItem
onSelect={() => handleQuickStatusCode('4xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('4')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
4xx Errors
</CommandItem>
<CommandItem
onSelect={() => handleQuickStatusCode('5xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('5')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
5xx Errors
</CommandItem>
</CommandGroup>
<CommandGroup heading='Common Codes'>
{STATUS_CODE_OPTIONS.filter(
(code) => !selectedStatusCodes.includes(code)
).map((code) => (
<CommandItem
key={code}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label>HTTP Methods</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedMethods.length > 0 ? (
selectedMethods.map((method) => (
<FilterBadge
key={method}
value={method}
onRemove={(val) =>
toggleSelection(
selectedMethods,
val,
onMethodsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All methods</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add method...'
value={methodSearch}
onValueChange={setMethodSearch}
/>
<CommandList>
{selectedMethods.length > 0 && (
<CommandGroup heading='Selected'>
{selectedMethods.map((method) => (
<CommandItem
key={`selected-${method}`}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
)}
{methodSearch &&
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
!selectedMethods.includes(methodSearch.toUpperCase()) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedMethods,
methodSearch.toUpperCase(),
onMethodsChange
);
setMethodSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{methodSearch.toUpperCase()}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{METHOD_OPTIONS.filter(
(method) => !selectedMethods.includes(method)
).map((method) => (
<CommandItem
key={method}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label>Endpoints</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedEndpoints.length > 0 ? (
selectedEndpoints.map((endpoint) => (
<FilterBadge
key={endpoint}
value={endpoint}
onRemove={(val) =>
toggleSelection(
selectedEndpoints,
val,
onEndpointsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>
All endpoints
</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-80 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add endpoint pattern...'
value={endpointSearch}
onValueChange={setEndpointSearch}
/>
<CommandList>
{selectedEndpoints.length > 0 && (
<CommandGroup heading='Selected'>
{selectedEndpoints.map((endpoint) => (
<CommandItem
key={`selected-${endpoint}`}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
)}
{endpointSearch &&
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
!selectedEndpoints.includes(endpointSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedEndpoints,
endpointSearch,
onEndpointsChange
);
setEndpointSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{endpointSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Common Endpoints'>
{ENDPOINT_OPTIONS.filter(
(endpoint) => !selectedEndpoints.includes(endpoint)
).map((endpoint) => (
<CommandItem
key={endpoint}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
<Input

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
@@ -22,15 +22,66 @@ import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
import { LogDetailsDialog } from './log-details-dialog';
const STORAGE_KEY = 'routstr-log-filters';
export default function LogsPage() {
const [selectedDate, setSelectedDate] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [requestId, setRequestId] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
const [limit, setLimit] = useState<number>(100);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
// Load filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.selectedDate) setSelectedDate(parsed.selectedDate);
if (parsed.selectedLevel) setSelectedLevel(parsed.selectedLevel);
if (parsed.requestId) setRequestId(parsed.requestId);
if (parsed.searchText) setSearchText(parsed.searchText);
if (parsed.selectedStatusCodes)
setSelectedStatusCodes(parsed.selectedStatusCodes);
if (parsed.selectedMethods) setSelectedMethods(parsed.selectedMethods);
if (parsed.selectedEndpoints)
setSelectedEndpoints(parsed.selectedEndpoints);
if (parsed.limit) setLimit(parsed.limit);
} catch (e) {
console.error('Failed to load filters from localStorage', e);
}
}
}, []);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
}, [
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
]);
const {
data: logsData,
refetch: refetchLogs,
@@ -42,6 +93,9 @@ export default function LogsPage() {
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
],
queryFn: () =>
@@ -50,6 +104,16 @@ export default function LogsPage() {
level: selectedLevel === 'all' ? undefined : selectedLevel,
request_id: requestId || undefined,
search: searchText || undefined,
status_codes:
selectedStatusCodes.length > 0
? selectedStatusCodes.join(',')
: undefined,
methods:
selectedMethods.length > 0 ? selectedMethods.join(',') : undefined,
endpoints:
selectedEndpoints.length > 0
? selectedEndpoints.join(',')
: undefined,
limit: limit,
}),
refetchInterval: 30000,
@@ -60,6 +124,9 @@ export default function LogsPage() {
setSelectedLevel('all');
setRequestId('');
setSearchText('');
setSelectedStatusCodes([]);
setSelectedMethods([]);
setSelectedEndpoints([]);
setLimit(100);
};
@@ -100,11 +167,17 @@ export default function LogsPage() {
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
@@ -122,13 +195,22 @@ export default function LogsPage() {
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText) && (
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
)}
</CardHeader>

View File

@@ -17,6 +17,9 @@ export interface LogsResponse {
level: string | null;
request_id: string | null;
search: string | null;
status_codes: string | null;
methods: string | null;
endpoints: string | null;
limit: number;
}

View File

@@ -208,7 +208,12 @@ function ProviderBalance({
return <Skeleton className='h-9 w-24' />;
}
if (error || !balanceData?.ok || !balanceData.balance_data) {
if (
error ||
!balanceData?.ok ||
balanceData.balance_data === undefined ||
balanceData.balance_data === null
) {
return null;
}

View File

@@ -41,6 +41,10 @@ export function ModelSearchFilter({
model.name.toLowerCase().includes(query) ||
model.full_name.toLowerCase().includes(query) ||
model.provider.toLowerCase().includes(query) ||
(model.alias_ids &&
model.alias_ids.some((alias) =>
alias.toLowerCase().includes(query)
)) ||
(model.description &&
model.description.toLowerCase().includes(query)) ||
model.modelType.toLowerCase().includes(query)

View File

@@ -542,6 +542,7 @@ export function ModelSelector({
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
alias_ids: model.alias_ids || null,
};
setModelDialogState({
@@ -585,6 +586,7 @@ export function ModelSelector({
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
alias_ids: model.alias_ids || null,
};
setModelDialogState({

View File

@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
interface SettingsData {
name?: string;
@@ -28,9 +29,32 @@ interface SettingsData {
http_url?: string;
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
[key: string]: unknown;
}
const HANDLED_KEYS = [
'name',
'description',
'http_url',
'onion_url',
'npub',
'nsec',
'cashu_mints',
'relays',
'admin_password',
'id',
'updated_at',
];
const IGNORED_KEYS = [
'upstream_base_url',
'upstream_api_key',
'upstream_provider_fee',
'exchange_fee',
'models_path',
];
interface PasswordData {
current_password: string;
new_password: string;
@@ -44,6 +68,7 @@ export function AdminSettings() {
const [error, setError] = useState<string>('');
const [showSecrets, setShowSecrets] = useState(false);
const [newMint, setNewMint] = useState('');
const [newRelay, setNewRelay] = useState('');
const [passwordData, setPasswordData] = useState<PasswordData>({
current_password: '',
new_password: '',
@@ -129,7 +154,7 @@ export function AdminSettings() {
}
};
const handleInputChange = (field: string, value: string | boolean) => {
const handleInputChange = (field: string, value: unknown) => {
setSettings((prev) => ({
...prev,
[field]: value,
@@ -153,6 +178,23 @@ export function AdminSettings() {
}));
};
const addRelay = () => {
if (newRelay.trim()) {
setSettings((prev) => ({
...prev,
relays: [...(prev.relays || []), newRelay.trim()],
}));
setNewRelay('');
}
};
const removeRelay = (index: number) => {
setSettings((prev) => ({
...prev,
relays: prev.relays?.filter((_, i) => i !== index) || [],
}));
};
const renderSecretField = (
field: string,
label: string,
@@ -162,7 +204,7 @@ export function AdminSettings() {
const displayValue = showSecrets ? value : value ? '••••••••' : '';
return (
<div className='space-y-2'>
<div key={field} className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex gap-2'>
<Input
@@ -190,6 +232,89 @@ export function AdminSettings() {
);
};
const renderDynamicField = (key: string, value: unknown) => {
const label = key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (typeof value === 'boolean') {
return (
<div
key={key}
className='flex items-center justify-between space-y-0 py-4'
>
<Label htmlFor={key}>{label}</Label>
<Switch
id={key}
checked={value}
onCheckedChange={(checked) => handleInputChange(key, checked)}
/>
</div>
);
}
if (typeof value === 'number') {
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type='number'
value={value}
onChange={(e) => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
handleInputChange(key, val);
}}
/>
</div>
);
}
if (Array.isArray(value)) {
const strValue = value.join(', ');
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Textarea
id={key}
value={strValue}
onChange={(e) => {
const arr = e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
handleInputChange(key, arr);
}}
placeholder='Comma separated values'
rows={2}
/>
</div>
);
}
const isSecret =
key.includes('key') ||
key.includes('password') ||
key.includes('secret') ||
key.includes('nsec');
if (isSecret) {
return renderSecretField(key, label);
}
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
value={(value as string) || ''}
onChange={(e) => handleInputChange(key, e.target.value)}
/>
</div>
);
};
if (loading) {
return (
<div className='flex items-center justify-center py-8'>
@@ -337,6 +462,73 @@ export function AdminSettings() {
</CardContent>
</Card>
{/* Relays */}
<Card>
<CardHeader>
<CardTitle>Nostr Relays</CardTitle>
<CardDescription>
Configure Nostr relays for communication
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex gap-2'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button onClick={addRelay} disabled={!newRelay.trim()}>
Add Relay
</Button>
</div>
</div>
{settings.relays && settings.relays.length > 0 && (
<div className='space-y-2'>
<Label>Configured Relays</Label>
<div className='space-y-2'>
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Other Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>
Configure additional node settings
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{Object.keys(settings)
.filter(
(key) =>
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
</Card>
<Card className='mt-6'>
<CardFooter className='flex justify-between'>
<Button

View File

@@ -31,6 +31,7 @@ export const ModelSchema = z.object({
// API key type indicators
has_own_api_key: z.boolean(),
api_key_type: z.string(), // "individual" or "group"
alias_ids: z.array(z.string()).nullable().optional(),
});
// Schema for a model with additional provider-specific settings

View File

@@ -121,6 +121,7 @@ export interface AdminModelAsModel {
soft_deleted?: boolean;
has_own_api_key: boolean;
api_key_type: string;
alias_ids?: string[] | null;
}
export interface AdminModelGroup {
@@ -207,6 +208,7 @@ export class AdminService {
soft_deleted: !adminModel.enabled,
has_own_api_key: false,
api_key_type: 'group',
alias_ids: adminModel.alias_ids,
};
}
@@ -354,8 +356,9 @@ export class AdminService {
original: data.pricing,
converted: payload.pricing,
});
const model = await apiClient.patch<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
// Use the same POST endpoint for both create and update (upsert)
const model = await apiClient.post<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models`,
payload
);
return {

9257
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.10",
"@radix-ui/react-avatar": "^1.1.6",
@@ -40,17 +41,17 @@
"@radix-ui/react-toggle": "^1.1.6",
"@radix-ui/react-toggle-group": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.3",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.501.0",
"next": "15.3.1",
"lucide-react": "^0.562.0",
"next": "15.5.9",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
@@ -67,21 +68,21 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.74.4",
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query-devtools": "^5.91.2",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.25.0",
"eslint-config-next": "15.3.1",
"eslint-config-next": "15.5.9",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4",
"tailwindcss": "^4.1.18",
"typescript": "^5"
}
}

1088
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,12 @@
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

Some files were not shown because too many files have changed in this diff Show More