mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
301
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7e0b7d22 | ||
|
|
1c5e709b0a | ||
|
|
c6e401c3f6 | ||
|
|
1b3b206a20 | ||
|
|
b9b477e5eb | ||
|
|
ace8cf960c | ||
|
|
3396e0cd47 | ||
|
|
855d60b4a5 | ||
|
|
27ace348b5 | ||
|
|
73e3d34623 | ||
|
|
c8f8857f03 | ||
|
|
c9650441bb | ||
|
|
5ce9c2217f | ||
|
|
89b8488ab8 | ||
|
|
2ee917fa31 | ||
|
|
5e12a7e92d | ||
|
|
1d043cd98d | ||
|
|
39e0959fcd | ||
|
|
29be9d5b9c | ||
|
|
1ebb7d71e1 | ||
|
|
bbf1e65a5d | ||
|
|
51c3e5dcd7 | ||
|
|
788075f656 | ||
|
|
0bbcacd186 | ||
|
|
6d5b811c20 | ||
|
|
42258ae39c | ||
|
|
04d6903369 | ||
|
|
b0b2ceb1a0 | ||
|
|
bf91f401af | ||
|
|
60e0eebd05 | ||
|
|
8fff716bb2 | ||
|
|
b2ef15a406 | ||
|
|
f8fdcf3bf3 | ||
|
|
58c4d3bf5f | ||
|
|
2210e9be6c | ||
|
|
9c66789665 | ||
|
|
9dfa58d69f | ||
|
|
22ec9c3132 | ||
|
|
b72c578954 | ||
|
|
7e299180fe | ||
|
|
f290534df4 | ||
|
|
8d3c064b29 | ||
|
|
4ac96ade5f | ||
|
|
85aa8fbbc5 | ||
|
|
180a469399 | ||
|
|
701b870d63 | ||
|
|
87fbb48ca8 | ||
|
|
3465a44d0e | ||
|
|
a4259af38f | ||
|
|
0d07dd0cdb | ||
|
|
24015ebec1 | ||
|
|
db021866d8 | ||
|
|
d4339287be | ||
|
|
88bcc0edcb | ||
|
|
917a4d32b1 | ||
|
|
daf17f51ab | ||
|
|
fc042c768c | ||
|
|
3bc38937e8 | ||
|
|
9229b87b70 | ||
|
|
367265b9fe | ||
|
|
0b3ccb5fb0 | ||
|
|
dbd43f52fb | ||
|
|
39657ed64f | ||
|
|
493b4f0f1f | ||
|
|
cb36189db3 | ||
|
|
d2487f42b0 | ||
|
|
5255fce7b2 | ||
|
|
ca7e8bec71 | ||
|
|
c4cc09d61e | ||
|
|
ee668ee93b | ||
|
|
cf8b990fc7 | ||
|
|
78dd74845b | ||
|
|
e7f4c98475 | ||
|
|
fad792068e | ||
|
|
21d363f6aa | ||
|
|
5e21f6ccbc | ||
|
|
1e2d130022 | ||
|
|
1e21dce735 | ||
|
|
b7603dcf69 | ||
|
|
7dccfa745f | ||
|
|
54d5118980 | ||
|
|
7723ab4a95 | ||
|
|
86c022d8db | ||
|
|
21ae22abec | ||
|
|
3a939d0dd1 | ||
|
|
50eabafa57 | ||
|
|
d192a6a6b4 | ||
|
|
7d829af681 | ||
|
|
57bf1b68d9 | ||
|
|
00d0415518 | ||
|
|
e8585b276f | ||
|
|
4b5e911435 | ||
|
|
761aabfec3 | ||
|
|
f0c45a7ce4 | ||
|
|
fc8ccf63ba | ||
|
|
eeb70e4ee5 | ||
|
|
a3b410b467 | ||
|
|
9e9bc5bff8 | ||
|
|
bdf0e2c192 | ||
|
|
6d780ef96d | ||
|
|
b70b94b9b4 | ||
|
|
334453f934 | ||
|
|
5a4ba60072 | ||
|
|
f1fa7d094f | ||
|
|
eed5bc5b04 | ||
|
|
f4b014cb05 | ||
|
|
4418d87664 | ||
|
|
634a473f50 | ||
|
|
ea655b748b | ||
|
|
2d247ddc8b | ||
|
|
c064452aea | ||
|
|
1c6a603042 | ||
|
|
84b0007b05 | ||
|
|
525476ccfa | ||
|
|
b54812cb04 | ||
|
|
ee508cbb3a | ||
|
|
0c61fdee07 | ||
|
|
b9418db31f | ||
|
|
71e7c2171b | ||
|
|
a3e8d5fd38 | ||
|
|
41fd2e2dfc | ||
|
|
2c404c66d6 | ||
|
|
0fa3e77f9a | ||
|
|
5b8e56f590 | ||
|
|
a6d0bd1a19 | ||
|
|
9594e9fb52 | ||
|
|
b01c7b2e56 | ||
|
|
dd4ed7541f | ||
|
|
cc42534a97 | ||
|
|
d203370f01 | ||
|
|
e39742c429 | ||
|
|
301dd81215 | ||
|
|
5416cefd87 | ||
|
|
d41c214d9e | ||
|
|
ec0fcfb48b | ||
|
|
8edc3512c1 | ||
|
|
82d2627c60 | ||
|
|
43e97326e0 | ||
|
|
06770a0702 | ||
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
c11cc107c8 | ||
|
|
547365894d | ||
|
|
19b5f2889a | ||
|
|
52601f89bd | ||
|
|
72b281b815 | ||
|
|
c0176a5274 | ||
|
|
329d22363f | ||
|
|
87b1443c23 | ||
|
|
29129f8953 | ||
|
|
c97c74a2ee | ||
|
|
1e37c42ea0 | ||
|
|
4c7887fa4e | ||
|
|
2cc5063dee | ||
|
|
e4eda59e6a | ||
|
|
7f918eab6a | ||
|
|
b8c34afef8 | ||
|
|
5738b1bd99 | ||
|
|
2aee75e7d5 | ||
|
|
4d67af51ab | ||
|
|
2f841dfcbc | ||
|
|
7ca69063bd | ||
|
|
085ff75d1d | ||
|
|
21340b2de1 | ||
|
|
a150d38df7 | ||
|
|
02ca36141e | ||
|
|
20bf5e35a4 | ||
|
|
5a67e6b4d6 | ||
|
|
34d1e4b041 | ||
|
|
b88ef858ee | ||
|
|
a363c7a0b1 | ||
|
|
4ac257db0d | ||
|
|
fd5ec01a99 | ||
|
|
bc8c08c468 | ||
|
|
d6648d3337 | ||
|
|
9438bc957f | ||
|
|
5d2219880d | ||
|
|
195da0c9da | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 | ||
|
|
e7f677b315 | ||
|
|
d7611e74c3 | ||
|
|
d0790dcb22 | ||
|
|
6b4b3924a1 | ||
|
|
2b4f71cc4f | ||
|
|
80359d0854 | ||
|
|
a226a78222 | ||
|
|
42c2ddb355 | ||
|
|
65e7702f90 | ||
|
|
253f419ada | ||
|
|
83818e1097 | ||
|
|
f116a5ed38 | ||
|
|
ee185f56e5 | ||
|
|
b76598ea41 | ||
|
|
432fe48f36 | ||
|
|
7b6e00edd8 | ||
|
|
e59335db4d | ||
|
|
6f00478580 | ||
|
|
4ed4f610f4 | ||
|
|
30fd369bfa | ||
|
|
8208a870a2 | ||
|
|
5eb4a40395 | ||
|
|
95ffc612ca | ||
|
|
afa4a59bb6 | ||
|
|
a67cd2d604 | ||
|
|
d6fdf0c581 | ||
|
|
ed15f61392 | ||
|
|
684ab639ac | ||
|
|
0d698e9e75 | ||
|
|
f5be9878d8 | ||
|
|
30fc8d91f6 | ||
|
|
f7d6a0e349 | ||
|
|
2cbf7b41f3 | ||
|
|
d142ca52c7 | ||
|
|
c5b448b1ff | ||
|
|
6f4e57eeff | ||
|
|
3371b03068 | ||
|
|
64b45a75c7 | ||
|
|
dcfd398008 | ||
|
|
85962aa45a | ||
|
|
d0aa91aa51 | ||
|
|
f13638387f | ||
|
|
e154f65e16 | ||
|
|
86b1ba0228 | ||
|
|
eb83b3c51a | ||
|
|
2438f3231f | ||
|
|
13640d13d1 | ||
|
|
ec60dbe568 | ||
|
|
12515cb0e3 | ||
|
|
0f23335de9 | ||
|
|
edd7bd40c5 | ||
|
|
dacabaa5d4 | ||
|
|
6d6f66d6d0 | ||
|
|
8a70e19430 | ||
|
|
19575eb1b9 | ||
|
|
e71baeb6d6 | ||
|
|
83a49a3c4a | ||
|
|
071444f9d0 | ||
|
|
177ea25723 | ||
|
|
028e73951e | ||
|
|
1a2b52ab90 | ||
|
|
f45ff16674 | ||
|
|
bd0764ee0d | ||
|
|
c5c032bd2c | ||
|
|
b717c9739a | ||
|
|
0bcf7bb948 | ||
|
|
dbffef62e6 | ||
|
|
38356d7bb3 | ||
|
|
99d98ffb2c | ||
|
|
26110a68dd | ||
|
|
23ff99d41f | ||
|
|
3e7d4c6e86 | ||
|
|
34cdbfe446 | ||
|
|
14ae4ecce3 | ||
|
|
a8b6d4866f | ||
|
|
924f93c18d | ||
|
|
7c2ac805c8 | ||
|
|
bb9b632ceb | ||
|
|
50c43e9b07 | ||
|
|
a4c092d8dc | ||
|
|
94e7b2b4d2 | ||
|
|
637f3459c5 | ||
|
|
a5ac510cd0 | ||
|
|
fe66f249f0 | ||
|
|
9a52e30470 | ||
|
|
30d62bf65c | ||
|
|
29b088c035 | ||
|
|
d16b0d5190 | ||
|
|
9b2a4a8ff8 | ||
|
|
320cfe82fd | ||
|
|
8d4691e7f6 | ||
|
|
d7c5d7ce41 | ||
|
|
2da2f96118 | ||
|
|
8cb73f4528 | ||
|
|
e3b146b83f | ||
|
|
b7e4fbf739 | ||
|
|
2c8ba93312 | ||
|
|
cfe03d6dcb | ||
|
|
80b6acbf4b | ||
|
|
bd88a84cd4 | ||
|
|
45f5ba96a8 | ||
|
|
4b935a6f4d | ||
|
|
c9f458b8ba | ||
|
|
d9d2e17e5d | ||
|
|
3d6bd65a64 | ||
|
|
8c08be9e11 | ||
|
|
c49da9bf84 | ||
|
|
c2b97f8e3b | ||
|
|
74480df47d | ||
|
|
f5c9cde852 | ||
|
|
88fbefbd18 | ||
|
|
ded82cd729 | ||
|
|
1e90b223cf | ||
|
|
b8a1d69924 | ||
|
|
33b19ba98b | ||
|
|
fe2b1846f7 | ||
|
|
859b31e2bc | ||
|
|
80a7f5d2eb | ||
|
|
fa0b2834c5 | ||
|
|
d693b559c0 |
+3
-1
@@ -8,4 +8,6 @@ compose.testing.yml
|
||||
.todo
|
||||
.github
|
||||
.vscode
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
**/node_modules
|
||||
ui/.next
|
||||
|
||||
@@ -59,21 +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
|
||||
node-version: "18"
|
||||
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: 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
|
||||
|
||||
@@ -1,255 +1,69 @@
|
||||
# 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://github.com/routstr/routstr-core/stargazers)
|
||||
[](https://github.com/routstr/routstr-core/issues)
|
||||
[](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/introduction/>
|
||||
|
||||
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 https://api.routstr.com/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
|
||||
fastapi run routstr
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+37
-1
@@ -434,6 +434,42 @@ Authorization: Bearer sk-...
|
||||
}
|
||||
```
|
||||
|
||||
### Create Child Key
|
||||
|
||||
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
|
||||
|
||||
```http
|
||||
POST /v1/balance/child-key
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_keys": ["sk-abc...", "sk-def..."],
|
||||
"count": 2,
|
||||
"cost_msats": 2000,
|
||||
"cost_sats": 2,
|
||||
"parent_balance": 98000,
|
||||
"parent_balance_sats": 98
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Discovery
|
||||
|
||||
## Admin Settings
|
||||
@@ -527,4 +563,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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+36
-89
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
```
|
||||
@@ -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`
|
||||
|
||||
### 🤖 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
+78
-43
@@ -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! 🚀
|
||||
|
||||
+170
-518
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+28
-67
@@ -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).*
|
||||
|
||||
@@ -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) |
|
||||
@@ -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 (GLM-4.7-Flash, Seed-1.6): Low margin to attract volume
|
||||
- Premium models (GPT-5-Pro, Claude-Opus): High margin for profit
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
@@ -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 .
|
||||
```
|
||||
@@ -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()}")
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
)
|
||||
```
|
||||
@@ -1,329 +0,0 @@
|
||||
# Admin Dashboard
|
||||
|
||||
The Routstr admin dashboard provides a web interface for managing your node, viewing balances, and handling withdrawals.
|
||||
|
||||
## Accessing the Dashboard
|
||||
|
||||
### URL Format
|
||||
|
||||
The admin dashboard is available at:
|
||||
|
||||
```
|
||||
https://api.routstr.com/admin/
|
||||
```
|
||||
|
||||
> **Important**: Always include the trailing slash (`/`) in the URL.
|
||||
|
||||
### Authentication
|
||||
|
||||
The dashboard is protected by a password set in the `ADMIN_PASSWORD` environment variable.
|
||||
|
||||
1. Navigate to `/admin/`
|
||||
2. Enter the admin password
|
||||
3. Click "Login"
|
||||
|
||||
The password is stored as a secure cookie for the session.
|
||||
|
||||
## Dashboard Overview
|
||||
|
||||
### Main Interface
|
||||
|
||||
The dashboard displays:
|
||||
|
||||
- **Node Information**
|
||||
- Node name and description
|
||||
- Version number
|
||||
- Public URLs (HTTP and Onion)
|
||||
- Supported Cashu mints
|
||||
|
||||
- **Statistics**
|
||||
- Total API keys
|
||||
- Active keys
|
||||
- Total balance across all keys
|
||||
- Recent activity
|
||||
|
||||
- **API Key List**
|
||||
- All keys with balances
|
||||
- Usage statistics
|
||||
- Management options
|
||||
|
||||
## Features
|
||||
|
||||
### Viewing API Keys
|
||||
|
||||
The main table shows all API keys with:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| API Key | Masked key (first/last 4 chars) |
|
||||
| Balance | Current balance in sats |
|
||||
| Created | Creation timestamp |
|
||||
| Last Used | Most recent API call |
|
||||
| Total Spent | Lifetime usage |
|
||||
| Status | Active/Expired/Disabled |
|
||||
|
||||
### Searching and Filtering
|
||||
|
||||
- **Search**: Find keys by partial match
|
||||
- **Sort**: Click column headers to sort
|
||||
- **Filter**: Show only active/expired keys
|
||||
- **Export**: Download data as CSV
|
||||
|
||||
### Key Details
|
||||
|
||||
Click on any key to view:
|
||||
|
||||
- Full API key (masked by default)
|
||||
- Complete transaction history
|
||||
- Usage graphs
|
||||
- Metadata (name, expiry, refund address)
|
||||
|
||||
## Balance Management
|
||||
|
||||
### Viewing Balances
|
||||
|
||||
Balances are displayed in multiple units:
|
||||
|
||||
- **Sats**: Standard satoshi units
|
||||
- **mSats**: Millisatoshis (internal precision)
|
||||
- **BTC**: Bitcoin decimal format
|
||||
- **USD**: Approximate USD value
|
||||
|
||||
### Balance History
|
||||
|
||||
View balance changes over time:
|
||||
|
||||
```
|
||||
Time | Type | Amount | Balance | Description
|
||||
-------------|-----------|---------|---------|-------------
|
||||
12:34:56 | Deposit | +10,000 | 10,000 | Token redemption
|
||||
12:35:12 | Usage | -154 | 9,846 | gpt-3.5-turbo call
|
||||
12:36:45 | Usage | -210 | 9,636 | gpt-4 call
|
||||
```
|
||||
|
||||
## Withdrawals
|
||||
|
||||
### Manual Withdrawal
|
||||
|
||||
To withdraw funds from an API key:
|
||||
|
||||
1. Click "Withdraw" next to the key
|
||||
2. Optionally specify amount (default: full balance)
|
||||
3. Select target Cashu mint
|
||||
4. Click "Generate Token"
|
||||
5. Copy the eCash token
|
||||
6. Redeem in your Cashu wallet
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
For multiple withdrawals:
|
||||
|
||||
1. Select keys using checkboxes
|
||||
2. Click "Bulk Actions" → "Withdraw"
|
||||
3. Tokens are generated for each key
|
||||
4. Download all tokens as text file
|
||||
|
||||
### Automatic Withdrawals
|
||||
|
||||
If configured with `RECEIVE_LN_ADDRESS`:
|
||||
|
||||
- Balances above threshold auto-convert to Lightning
|
||||
- Sent to configured Lightning address
|
||||
- View payout history in dashboard
|
||||
|
||||
## Node Configuration
|
||||
|
||||
### Viewing Settings
|
||||
|
||||
Current node configuration is displayed:
|
||||
|
||||
- Upstream provider URL
|
||||
- Enabled features
|
||||
- Pricing model
|
||||
- Fee structure
|
||||
|
||||
### Models and Pricing
|
||||
|
||||
View supported models and their pricing:
|
||||
|
||||
| Model | Input $/1K | Output $/1K | Sats/1K |
|
||||
|-------|------------|-------------|---------|
|
||||
| gpt-3.5-turbo | $0.0015 | $0.002 | 3/4 |
|
||||
| gpt-4 | $0.03 | $0.06 | 60/120 |
|
||||
| dall-e-3 | - | - | 1000/image |
|
||||
|
||||
### Updating Configuration
|
||||
|
||||
> **Note**: Configuration changes require node restart.
|
||||
|
||||
To update settings:
|
||||
|
||||
1. Modify environment variables
|
||||
2. Restart the node
|
||||
3. Verify changes in dashboard
|
||||
|
||||
## Analytics
|
||||
|
||||
### Usage Statistics
|
||||
|
||||
View comprehensive usage data:
|
||||
|
||||
- **Requests per Day**: Line graph
|
||||
- **Token Usage**: Stacked bar chart
|
||||
- **Model Distribution**: Pie chart
|
||||
- **Cost Analysis**: Breakdown by model
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
Monitor node performance:
|
||||
|
||||
- Average response time
|
||||
- Request success rate
|
||||
- Upstream API latency
|
||||
- Cache hit ratio
|
||||
|
||||
### Export Data
|
||||
|
||||
Export analytics data:
|
||||
|
||||
1. Select date range
|
||||
2. Choose metrics
|
||||
3. Click "Export"
|
||||
4. Download as CSV/JSON
|
||||
|
||||
## Security Features
|
||||
|
||||
### Access Control
|
||||
|
||||
- Password protection
|
||||
- Session timeout (configurable)
|
||||
- IP allowlisting (optional)
|
||||
- Audit logging
|
||||
|
||||
### Security Log
|
||||
|
||||
View security events:
|
||||
|
||||
```
|
||||
2024-01-15 12:34:56 | Login Success | IP: 192.168.1.1
|
||||
2024-01-15 12:35:12 | Withdrawal | Key: sk-****abcd | Amount: 5000
|
||||
2024-01-15 12:40:00 | Session Timeout | IP: 192.168.1.1
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Strong Password**: Use a long, random password
|
||||
2. **HTTPS Only**: Always access via HTTPS
|
||||
3. **Regular Monitoring**: Check logs frequently
|
||||
4. **Limited Access**: Restrict dashboard access
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot Access Dashboard
|
||||
|
||||
**Issue**: 404 Not Found
|
||||
|
||||
- Ensure trailing slash: `/admin/`
|
||||
- Check if admin routes are enabled
|
||||
|
||||
**Issue**: Unauthorized
|
||||
|
||||
- Verify `ADMIN_PASSWORD` is set
|
||||
- Clear browser cookies
|
||||
- Try incognito/private mode
|
||||
|
||||
### Display Issues
|
||||
|
||||
**Issue**: Broken Layout
|
||||
|
||||
- Clear browser cache
|
||||
- Disable ad blockers
|
||||
- Try different browser
|
||||
|
||||
**Issue**: Missing Data
|
||||
|
||||
- Check database connectivity
|
||||
- Verify node is running
|
||||
- Review error logs
|
||||
|
||||
### Withdrawal Problems
|
||||
|
||||
**Issue**: Token Generation Fails
|
||||
|
||||
- Check mint connectivity
|
||||
- Verify sufficient balance
|
||||
- Try different mint
|
||||
|
||||
**Issue**: Invalid Token
|
||||
|
||||
- Ensure complete token copy
|
||||
- Check token hasn't expired
|
||||
- Verify mint compatibility
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Branding
|
||||
|
||||
Customize dashboard appearance:
|
||||
|
||||
```bash
|
||||
# Environment variables
|
||||
ADMIN_LOGO_URL=https://example.com/logo.png
|
||||
ADMIN_THEME_COLOR=#FF6B00
|
||||
ADMIN_CUSTOM_CSS=/path/to/custom.css
|
||||
```
|
||||
|
||||
### API Access
|
||||
|
||||
Access admin functions programmatically:
|
||||
|
||||
```bash
|
||||
# Get node stats
|
||||
curl -X GET https://your-node.com/admin/api/stats \
|
||||
-H "X-Admin-Password: your-password"
|
||||
|
||||
# Export key data
|
||||
curl -X GET https://your-node.com/admin/api/keys \
|
||||
-H "X-Admin-Password: your-password" \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
### Webhooks
|
||||
|
||||
Configure notifications:
|
||||
|
||||
```bash
|
||||
ADMIN_WEBHOOK_URL=https://example.com/webhook
|
||||
ADMIN_WEBHOOK_EVENTS=withdrawal,low_balance,error
|
||||
```
|
||||
|
||||
## Dashboard Shortcuts
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- `Ctrl+K`: Quick search
|
||||
- `Ctrl+R`: Refresh data
|
||||
- `Ctrl+E`: Export current view
|
||||
- `Escape`: Close modals
|
||||
|
||||
### Quick Actions
|
||||
|
||||
- Double-click to copy API key
|
||||
- Right-click for context menu
|
||||
- Drag to reorder columns
|
||||
- Shift-click to select multiple
|
||||
|
||||
## Mobile Access
|
||||
|
||||
The dashboard is mobile-responsive:
|
||||
|
||||
- Touch-optimized controls
|
||||
- Swipe navigation
|
||||
- Compact view mode
|
||||
- Offline capability
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Models & Pricing](models-pricing.md) - Configure pricing
|
||||
- [API Reference](../api/overview.md) - Admin API endpoints
|
||||
- [Advanced Configuration](../advanced/custom-pricing.md) - Advanced settings
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
-37
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
print(f"Requesting {count} child keys from {base_url}...")
|
||||
|
||||
child_keys = []
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
child_keys.append(data["api_key"])
|
||||
print(
|
||||
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
|
||||
)
|
||||
else:
|
||||
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f" [{i + 1}] Error: {str(e)}")
|
||||
|
||||
return child_keys
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
|
||||
sys.exit(1)
|
||||
|
||||
auth_key = sys.argv[1]
|
||||
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
|
||||
|
||||
keys = create_child_keys(base_url, auth_key)
|
||||
|
||||
if keys:
|
||||
print("\nSuccessfully created child keys:")
|
||||
print(json.dumps(keys, indent=2))
|
||||
else:
|
||||
print("\nNo child keys were created.")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
|
||||
Revision ID: a86e5348850b
|
||||
Revises: b9667ffc5701
|
||||
Create Date: 2026-01-10 18:57:48.475781
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a86e5348850b"
|
||||
down_revision = "b9667ffc5701"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use batch_alter_table for SQLite compatibility
|
||||
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"parent_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
)
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_api_keys_parent_key_hash"), ["parent_key_hash"], unique=False
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_api_keys_parent_key_hash",
|
||||
"api_keys",
|
||||
["parent_key_hash"],
|
||||
["hashed_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_api_keys_parent_key_hash", type_="foreignkey")
|
||||
batch_op.drop_index(batch_op.f("ix_api_keys_parent_key_hash"))
|
||||
batch_op.drop_column("parent_key_hash")
|
||||
@@ -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 ###
|
||||
@@ -0,0 +1,118 @@
|
||||
"""make upstream provider base_url + api_key unique
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: a86e5348850b
|
||||
Create Date: 2026-01-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d3e4f5a6b7"
|
||||
down_revision = "a86e5348850b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = {
|
||||
row[0]
|
||||
for row in conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "upstream_providers_old" in existing_tables:
|
||||
if "upstream_providers" in existing_tables:
|
||||
op.drop_table("upstream_providers_old")
|
||||
else:
|
||||
op.execute(
|
||||
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||
)
|
||||
existing_tables.add("upstream_providers")
|
||||
if "upstream_providers" not in existing_tables:
|
||||
return
|
||||
|
||||
constraints = [
|
||||
sa.UniqueConstraint(
|
||||
"base_url",
|
||||
"api_key",
|
||||
name="uq_upstream_providers_base_url_api_key",
|
||||
)
|
||||
]
|
||||
if add_base_url_unique:
|
||||
constraints.append(
|
||||
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
*constraints,
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||
"FROM upstream_providers_old"
|
||||
)
|
||||
op.drop_table("upstream_providers_old")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=False)
|
||||
return
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||
name = constraint.get("name")
|
||||
if constraint.get("column_names") == ["base_url"] and name:
|
||||
op.drop_constraint(
|
||||
name,
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_base_url" in index_names:
|
||||
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
["base_url", "api_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=True)
|
||||
return
|
||||
|
||||
op.drop_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add lightning_invoices table
|
||||
|
||||
Revision ID: lightning_invoices
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-12-10 21:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
revision = "lightning_invoices"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lightning_invoices",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount_sats", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=False),
|
||||
sa.Column("paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("bolt11"),
|
||||
sa.UniqueConstraint("payment_hash"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("lightning_invoices")
|
||||
+19
-21
@@ -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
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -19,6 +19,8 @@ dependencies = [
|
||||
"websockets>=12.0",
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -71,6 +73,7 @@ packages = ["routstr"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
|
||||
+74
-124
@@ -6,7 +6,7 @@ from .core.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payment.models import Model
|
||||
from .upstream import UpstreamProvider
|
||||
from .upstream import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -59,7 +59,7 @@ def calculate_model_cost_score(model: "Model") -> float:
|
||||
return total_cost
|
||||
|
||||
|
||||
def get_provider_penalty(provider: "UpstreamProvider") -> float:
|
||||
def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
||||
"""Calculate a penalty multiplier for certain providers.
|
||||
|
||||
This allows applying policy-based adjustments beyond pure cost.
|
||||
@@ -84,105 +84,26 @@ def get_provider_penalty(provider: "UpstreamProvider") -> float:
|
||||
return penalty
|
||||
|
||||
|
||||
def should_prefer_model(
|
||||
candidate_model: "Model",
|
||||
candidate_provider: "UpstreamProvider",
|
||||
current_model: "Model",
|
||||
current_provider: "UpstreamProvider",
|
||||
alias: str,
|
||||
) -> bool:
|
||||
"""Determine if candidate model should replace current model for an alias.
|
||||
|
||||
This is the core decision function for model prioritization. It considers:
|
||||
1. Alias matching quality (exact match vs. canonical slug match)
|
||||
2. Model cost (lower is better)
|
||||
3. Provider penalties (e.g., slight preference against OpenRouter)
|
||||
|
||||
Args:
|
||||
candidate_model: The new model being considered
|
||||
candidate_provider: Provider offering the candidate model
|
||||
current_model: The currently selected model for this alias
|
||||
current_provider: Provider offering the current model
|
||||
alias: The model alias being mapped
|
||||
|
||||
Returns:
|
||||
True if candidate should replace current, False otherwise
|
||||
"""
|
||||
|
||||
def get_base_model_id(model_id: str) -> str:
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def alias_priority(model: "Model") -> int:
|
||||
"""Rank how strong the mapping of alias->model is.
|
||||
|
||||
Highest priority when alias exactly equals the model ID without provider prefix.
|
||||
Next when alias equals canonical slug without prefix. Otherwise lowest.
|
||||
"""
|
||||
model_base = get_base_model_id(model.id)
|
||||
if model_base == alias:
|
||||
return 3
|
||||
if model.canonical_slug:
|
||||
canonical_base = get_base_model_id(model.canonical_slug)
|
||||
if canonical_base == alias:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
candidate_alias_priority = alias_priority(candidate_model)
|
||||
current_alias_priority = alias_priority(current_model)
|
||||
|
||||
# If candidate has better alias match, prefer it regardless of cost
|
||||
if candidate_alias_priority > current_alias_priority:
|
||||
return True
|
||||
|
||||
# If current has better alias match, keep it regardless of cost
|
||||
if current_alias_priority > candidate_alias_priority:
|
||||
return False
|
||||
|
||||
# Same alias priority - compare costs
|
||||
candidate_cost = calculate_model_cost_score(candidate_model)
|
||||
current_cost = calculate_model_cost_score(current_model)
|
||||
|
||||
# Apply provider penalties
|
||||
candidate_adjusted = candidate_cost * get_provider_penalty(candidate_provider)
|
||||
current_adjusted = current_cost * get_provider_penalty(current_provider)
|
||||
|
||||
# Prefer lower adjusted cost
|
||||
should_replace = candidate_adjusted < current_adjusted
|
||||
|
||||
# Log provider changes when candidate wins
|
||||
if should_replace:
|
||||
candidate_provider_name = getattr(
|
||||
candidate_provider, "upstream_name", "unknown"
|
||||
)
|
||||
current_provider_name = getattr(current_provider, "upstream_name", "unknown")
|
||||
logger.debug(
|
||||
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
|
||||
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
|
||||
f"(cost: ${current_adjusted:.6f})"
|
||||
)
|
||||
|
||||
return should_replace
|
||||
|
||||
|
||||
def create_model_mappings(
|
||||
upstreams: list["UpstreamProvider"],
|
||||
upstreams: list["BaseUpstreamProvider"],
|
||||
overrides_by_id: dict[str, tuple],
|
||||
disabled_model_ids: set[str],
|
||||
) -> tuple[dict[str, "Model"], dict[str, "UpstreamProvider"], dict[str, "Model"]]:
|
||||
) -> tuple[
|
||||
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
|
||||
]:
|
||||
"""Create optimal model mappings based on cost and provider preferences.
|
||||
|
||||
This is the main entry point for the algorithm. It processes all upstream providers
|
||||
and creates three mappings based on cost optimization:
|
||||
|
||||
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
||||
2. provider_map: alias -> UpstreamProvider (which provider to use for each alias)
|
||||
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
|
||||
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
||||
|
||||
The algorithm:
|
||||
- Processes non-OpenRouter providers first (they're typically cheaper)
|
||||
- Then processes OpenRouter models (they can still win if cheaper)
|
||||
- For each model alias, uses should_prefer_model() to select the best provider
|
||||
- For each model alias, collects all candidates and sorts them by priority and cost.
|
||||
|
||||
Args:
|
||||
upstreams: List of all upstream provider instances
|
||||
@@ -193,15 +114,14 @@ def create_model_mappings(
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
"""
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream import resolve_model_alias
|
||||
from .upstream.helpers import resolve_model_alias
|
||||
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, "UpstreamProvider"] = {}
|
||||
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||
unique_models: dict[str, "Model"] = {}
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "UpstreamProvider" | None = None
|
||||
other_upstreams: list["UpstreamProvider"] = []
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
other_upstreams: list["BaseUpstreamProvider"] = []
|
||||
|
||||
for upstream in upstreams:
|
||||
base_url = getattr(upstream, "base_url", "")
|
||||
@@ -214,26 +134,17 @@ def create_model_mappings(
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def _maybe_set_alias(
|
||||
alias: str, model: "Model", provider: "UpstreamProvider"
|
||||
def _add_candidate(
|
||||
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)
|
||||
if not existing_model:
|
||||
# No existing mapping, set it
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
else:
|
||||
# Check if candidate should replace existing
|
||||
existing_provider = provider_map[alias]
|
||||
if should_prefer_model(
|
||||
model, provider, existing_model, existing_provider, alias
|
||||
):
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
"""Add candidate model/provider for an alias."""
|
||||
alias_lower = alias.lower()
|
||||
if alias_lower not in candidates:
|
||||
candidates[alias_lower] = []
|
||||
candidates[alias_lower].append((model, provider))
|
||||
|
||||
def process_provider_models(
|
||||
upstream: "UpstreamProvider", is_openrouter: bool = False
|
||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||
) -> None:
|
||||
"""Process all models from a given provider."""
|
||||
upstream_prefix = getattr(upstream, "upstream_name", None)
|
||||
@@ -254,11 +165,20 @@ def create_model_mappings(
|
||||
# Add to unique models
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_model = model_to_use.copy(update={"id": base_id})
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
# Get all aliases for this model
|
||||
aliases = resolve_model_alias(model_to_use.id, model_to_use.canonical_slug)
|
||||
aliases = resolve_model_alias(
|
||||
model_to_use.id,
|
||||
model_to_use.canonical_slug,
|
||||
alias_ids=model_to_use.alias_ids,
|
||||
)
|
||||
|
||||
# Add prefixed alias if applicable
|
||||
if upstream_prefix and "/" not in model_to_use.id:
|
||||
@@ -268,29 +188,59 @@ def create_model_mappings(
|
||||
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_maybe_set_alias(alias, model_to_use, upstream)
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
|
||||
# Process non-OpenRouter providers first (they're typically cheaper)
|
||||
# Process non-OpenRouter providers first
|
||||
for upstream in other_upstreams:
|
||||
process_provider_models(upstream, is_openrouter=False)
|
||||
|
||||
# Process OpenRouter last - models only win if they're cheaper or better matched
|
||||
# Process OpenRouter last
|
||||
if openrouter:
|
||||
process_provider_models(openrouter, is_openrouter=True)
|
||||
|
||||
# Log provider distribution
|
||||
# Sort candidates and build final maps
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
|
||||
def alias_priority(model: "Model", alias: str) -> int:
|
||||
"""Rank how strong the mapping of alias->model is."""
|
||||
model_base = get_base_model_id(model.id)
|
||||
if model_base == alias:
|
||||
return 3
|
||||
if model.canonical_slug:
|
||||
canonical_base = get_base_model_id(model.canonical_slug)
|
||||
if canonical_base == alias:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
for alias, items in candidates.items():
|
||||
# Sort key: (priority DESC, cost ASC)
|
||||
# Using negative cost for DESC sort overall to keep high priority first
|
||||
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
|
||||
model, provider = item
|
||||
priority = alias_priority(model, alias)
|
||||
cost = calculate_model_cost_score(model)
|
||||
penalty = get_provider_penalty(provider)
|
||||
adjusted_cost = cost * penalty
|
||||
return (priority, -adjusted_cost)
|
||||
|
||||
items.sort(key=sort_key, reverse=True)
|
||||
|
||||
best_model, best_provider = items[0]
|
||||
model_instances[alias] = best_model
|
||||
provider_map[alias] = [p for _, p in items]
|
||||
|
||||
# Log provider distribution (using top provider for stats)
|
||||
provider_counts: dict[str, int] = {}
|
||||
for provider in provider_map.values():
|
||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
for providers in provider_map.values():
|
||||
if providers:
|
||||
provider = providers[0]
|
||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||
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
|
||||
|
||||
+213
-57
@@ -9,7 +9,7 @@ from sqlmodel import col, update
|
||||
from .core import get_logger
|
||||
from .core.db import ApiKey, AsyncSession
|
||||
from .core.settings import settings
|
||||
from .payment.cost_caculation import (
|
||||
from .payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
@@ -286,30 +286,55 @@ async def validate_bearer_key(
|
||||
)
|
||||
|
||||
|
||||
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
|
||||
"""Returns the key that should be charged for the request."""
|
||||
if key.parent_key_hash:
|
||||
parent = await session.get(ApiKey, key.parent_key_hash)
|
||||
if parent:
|
||||
# We want to keep the total_requests and total_spent on the child key
|
||||
# but use the balance and reserved_balance of the parent.
|
||||
# However, pay_for_request updates reserved_balance and total_requests.
|
||||
# To stay simple, we charge the parent's balance and update parent's total_requests.
|
||||
return parent
|
||||
else:
|
||||
logger.error(
|
||||
"Parent key not found for child key",
|
||||
extra={
|
||||
"child_key_hash": key.hashed_key[:8] + "...",
|
||||
"parent_key_hash": key.parent_key_hash[:8] + "...",
|
||||
},
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"current_balance": billing_key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
"sufficient_balance": billing_key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.total_balance < cost_per_request:
|
||||
if billing_key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
"shortfall": cost_per_request - billing_key.total_balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -317,7 +342,7 @@ async def pay_for_request(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
@@ -328,22 +353,33 @@ async def pay_for_request(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
"balance_before": billing_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also increment total_requests on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_requests=col(ApiKey.total_requests) + 1)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
@@ -351,8 +387,9 @@ async def pay_for_request(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -361,23 +398,26 @@ async def pay_for_request(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"new_balance": billing_key.balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"total_requests": billing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -387,9 +427,11 @@ async def pay_for_request(
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
@@ -397,27 +439,40 @@ async def revert_pay_for_request(
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also decrement total_requests on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_requests=col(ApiKey.total_requests) - 1)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
@@ -428,25 +483,58 @@ async def adjust_payment_for_tokens(
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
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) == billing_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] + "...",
|
||||
"billing_key_hash": billing_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] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
@@ -454,7 +542,7 @@ async def adjust_payment_for_tokens(
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - cost.total_msats,
|
||||
@@ -462,26 +550,41 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
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] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": key.balance,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
@@ -497,6 +600,7 @@ async def adjust_payment_for_tokens(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
@@ -509,11 +613,15 @@ async def adjust_payment_for_tokens(
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -522,8 +630,20 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
@@ -533,16 +653,17 @@ async def adjust_payment_for_tokens(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"current_balance": billing_key.balance,
|
||||
"sufficient_balance": billing_key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -551,30 +672,45 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": key.balance,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
"Failed to finalize additional charge - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_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)
|
||||
@@ -582,15 +718,16 @@ async def adjust_payment_for_tokens(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -599,41 +736,54 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
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] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"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(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
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] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": billing_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 +791,7 @@ async def adjust_payment_for_tokens(
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -652,7 +803,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,
|
||||
|
||||
+109
-13
@@ -10,6 +10,7 @@ from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
|
||||
router = APIRouter()
|
||||
@@ -31,16 +32,30 @@ async def get_key_from_header(
|
||||
)
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
from .auth import get_billing_key
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
"balance": billing_key.balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
"is_child": key.parent_key_hash is not None,
|
||||
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||
"total_requests": key.total_requests,
|
||||
"total_spent": key.total_spent,
|
||||
}
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
return await get_balance_info(key, session)
|
||||
|
||||
|
||||
# TODO: Implement POST /v1/wallet/create endpoint
|
||||
# This endpoint should accept:
|
||||
# - cashu_token (required): The eCash token to deposit
|
||||
@@ -65,12 +80,11 @@ async def create_balance(
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
}
|
||||
async def wallet_info(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
return await get_balance_info(key, session)
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
@@ -84,6 +98,10 @@ async def topup_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
from .auth import get_billing_key
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
if topup_request is not None:
|
||||
cashu_token = topup_request.cashu_token
|
||||
if cashu_token is None:
|
||||
@@ -93,7 +111,7 @@ async def topup_wallet_endpoint(
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
@@ -153,7 +171,14 @@ async def refund_wallet_endpoint(
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
remaining_balance_msats: int = key.balance
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund child key. Please refund the parent key instead.",
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
@@ -226,6 +251,75 @@ async def donate(token: str, ref: str | None = None) -> str:
|
||||
return "Invalid token."
|
||||
|
||||
|
||||
class ChildKeyRequest(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
@router.post("/child-key")
|
||||
async def create_child_key(
|
||||
payload: ChildKeyRequest,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Creates one or more child API keys that use the parent's balance."""
|
||||
# Log incoming request for debugging
|
||||
logger.debug(f"Child key creation request: count={payload.count}")
|
||||
|
||||
count = payload.count
|
||||
if count < 1 or count > 50:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
|
||||
|
||||
# Check if this is already a child key
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create a child key for another child key.",
|
||||
)
|
||||
|
||||
cost_per_key = settings.child_key_cost
|
||||
total_cost = cost_per_key * count
|
||||
|
||||
if key.total_balance < total_cost:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Deduct cost from parent
|
||||
key.balance -= total_cost
|
||||
key.total_spent += total_cost
|
||||
session.add(key)
|
||||
|
||||
# Generate new keys
|
||||
import secrets
|
||||
|
||||
new_keys = []
|
||||
for _ in range(count):
|
||||
new_key_raw = secrets.token_hex(32)
|
||||
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
|
||||
|
||||
child_key = ApiKey(
|
||||
hashed_key=new_key_hash,
|
||||
balance=0,
|
||||
parent_key_hash=key.hashed_key,
|
||||
)
|
||||
session.add(child_key)
|
||||
new_keys.append("sk-" + new_key_hash)
|
||||
|
||||
await session.commit()
|
||||
|
||||
response_data = {
|
||||
"api_keys": new_keys,
|
||||
"count": count,
|
||||
"cost_msats": total_cost,
|
||||
"cost_sats": total_cost // 1000,
|
||||
"parent_balance": key.balance,
|
||||
"parent_balance_sats": key.balance // 1000,
|
||||
}
|
||||
logger.debug(f"Child key creation response: {response_data}")
|
||||
return response_data
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
@@ -238,6 +332,8 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router)
|
||||
balance_router.include_router(router)
|
||||
|
||||
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
|
||||
deprecated_wallet_router.include_router(router)
|
||||
|
||||
+417
-160
@@ -3,7 +3,7 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -18,6 +18,7 @@ from ..wallet import (
|
||||
slow_filter_spend_proofs,
|
||||
)
|
||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService, settings
|
||||
|
||||
@@ -123,7 +124,7 @@ async def partial_apikeys(request: Request) -> str:
|
||||
|
||||
rows = "".join(
|
||||
[
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||
f"<tr><td>{key.hashed_key}{' <br><small>(Child of ' + key.parent_key_hash[:8] + '...)</small>' if key.parent_key_hash else ''}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||
for key in api_keys
|
||||
]
|
||||
)
|
||||
@@ -157,6 +158,7 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object]
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
"parent_key_hash": key.parent_key_hash,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
@@ -1492,20 +1494,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
|
||||
|
||||
|
||||
@@ -2415,44 +2405,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(
|
||||
@@ -2460,6 +2486,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)],
|
||||
@@ -2476,80 +2516,10 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
return _row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
row, apply_provider_fee=False, provider_fee=provider.provider_fee
|
||||
).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)],
|
||||
@@ -2628,12 +2598,14 @@ async def create_upstream_provider(
|
||||
async with create_session() as session:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == payload.base_url
|
||||
UpstreamProviderRow.base_url == payload.base_url,
|
||||
UpstreamProviderRow.api_key == payload.api_key,
|
||||
)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Provider with this base URL already exists"
|
||||
status_code=409,
|
||||
detail="Provider with this base URL and API key already exists",
|
||||
)
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
@@ -2739,45 +2711,9 @@ async def delete_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
|
||||
async def get_provider_types() -> list[dict[str, object]]:
|
||||
"""Get metadata about available provider types including default URLs and whether they're fixed."""
|
||||
provider_types = [
|
||||
{
|
||||
"id": "openrouter",
|
||||
"name": "OpenRouter",
|
||||
"default_base_url": "https://openrouter.ai/api/v1",
|
||||
"fixed_base_url": True,
|
||||
},
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"default_base_url": "https://api.openai.com/v1",
|
||||
"fixed_base_url": True,
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"default_base_url": "https://api.anthropic.com/v1",
|
||||
"fixed_base_url": True,
|
||||
},
|
||||
{
|
||||
"id": "azure",
|
||||
"name": "Azure OpenAI",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"name": "Ollama",
|
||||
"default_base_url": "http://localhost:11434",
|
||||
"fixed_base_url": False,
|
||||
},
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
},
|
||||
]
|
||||
return provider_types
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
return [cls.get_provider_metadata() for cls in upstream_provider_classes]
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
@@ -2785,7 +2721,7 @@ async def get_provider_types() -> list[dict[str, object]]:
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
from ..upstream import _instantiate_provider
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
@@ -2793,7 +2729,10 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
db_models = await list_models(
|
||||
session=session, upstream_id=provider_id, include_disabled=True
|
||||
session=session,
|
||||
upstream_id=provider_id,
|
||||
include_disabled=True,
|
||||
apply_fees=False,
|
||||
)
|
||||
|
||||
upstream_models = []
|
||||
@@ -2801,10 +2740,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
if upstream_instance:
|
||||
try:
|
||||
raw_models = await upstream_instance.fetch_models()
|
||||
upstream_models = [
|
||||
upstream_instance._apply_provider_fee_to_model(m)
|
||||
for m in raw_models
|
||||
]
|
||||
upstream_models = raw_models
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from {provider.provider_type}: {e}"
|
||||
@@ -2826,6 +2762,186 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
provider_type: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/create-account",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_account_by_type(
|
||||
payload: CreateAccountRequest,
|
||||
) -> dict[str, object]:
|
||||
"""Create a new account with a provider by provider type (before provider exists in DB)."""
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
provider_class = next(
|
||||
(
|
||||
cls
|
||||
for cls in upstream_provider_classes
|
||||
if cls.provider_type == payload.provider_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not provider_class:
|
||||
raise HTTPException(status_code=404, detail="Provider type not found")
|
||||
|
||||
try:
|
||||
account_data = await provider_class.create_account_static()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"account_data": account_data,
|
||||
"message": "Account created successfully",
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support account creation: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create account for provider type {payload.provider_type}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def initiate_provider_topup(
|
||||
provider_id: int, payload: TopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request": topup_data.payment_request,
|
||||
"amount": topup_data.amount,
|
||||
"currency": topup_data.currency,
|
||||
"expires_at": topup_data.expires_at,
|
||||
"checkout_url": topup_data.checkout_url,
|
||||
},
|
||||
"message": "Top-up initiated successfully",
|
||||
}
|
||||
logger.info("Returning response", extra={"response": response_data})
|
||||
return response_data
|
||||
except NotImplementedError as e:
|
||||
logger.error(f"Provider does not support top-up: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initiate top-up for provider {provider_id}: {e}",
|
||||
extra={"error_type": type(e).__name__, "error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
||||
"""Check the status of a Lightning Network top-up invoice."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider does not support top-up status checking",
|
||||
)
|
||||
|
||||
try:
|
||||
paid = await upstream_instance.check_topup_status(invoice_id)
|
||||
return {"ok": True, "paid": paid}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to check top-up status for provider {provider_id}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/balance",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
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")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
balance_data = await upstream_instance.get_balance()
|
||||
return {"ok": True, "balance_data": balance_data}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support balance checking: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/openrouter-presets",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2901,3 +3017,144 @@ h1 { color: #333; }
|
||||
.no-logs { text-align: center; color: #666; padding: 40px; }
|
||||
.request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; }
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_metrics(
|
||||
request: Request,
|
||||
interval: int = Query(
|
||||
default=15, ge=1, le=1440, description="Time interval in minutes"
|
||||
),
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
) -> dict:
|
||||
"""Get usage metrics aggregated by time interval."""
|
||||
return log_manager.get_usage_metrics(interval=interval, hours=hours)
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_summary(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
) -> dict:
|
||||
"""Get summary statistics for the specified time period."""
|
||||
return log_manager.get_usage_summary(hours=hours)
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
|
||||
async def get_error_details(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
||||
),
|
||||
) -> dict:
|
||||
"""Get detailed error information."""
|
||||
return log_manager.get_error_details(hours=hours, limit=limit)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def get_revenue_by_model(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=20, ge=1, le=100, description="Maximum number of models to return"
|
||||
),
|
||||
) -> dict:
|
||||
"""
|
||||
Get revenue breakdown by model.
|
||||
"""
|
||||
return log_manager.get_revenue_by_model(hours=hours, limit=limit)
|
||||
|
||||
|
||||
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
|
||||
async def get_logs_api(
|
||||
request: Request,
|
||||
date: str | None = None,
|
||||
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]:
|
||||
"""
|
||||
Get filtered log entries.
|
||||
|
||||
Args:
|
||||
date: Filter by specific date (YYYY-MM-DD)
|
||||
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,
|
||||
)
|
||||
|
||||
return {
|
||||
"logs": log_entries,
|
||||
"total": len(log_entries),
|
||||
"date": date,
|
||||
"level": level,
|
||||
"request_id": request_id,
|
||||
"search": search,
|
||||
"status_codes": status_codes,
|
||||
"methods": methods,
|
||||
"endpoints": endpoints,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
|
||||
async def get_log_dates_api(request: Request) -> dict[str, object]:
|
||||
logs_dir = Path("logs")
|
||||
dates = []
|
||||
|
||||
if logs_dir.exists():
|
||||
log_files = sorted(
|
||||
logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True
|
||||
)
|
||||
|
||||
for log_file in log_files[:30]:
|
||||
try:
|
||||
filename = log_file.name
|
||||
date_str = filename.replace("app_", "").replace(".log", "")
|
||||
dates.append(date_str)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {"dates": dates}
|
||||
|
||||
+44
-5
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import UniqueConstraint
|
||||
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
|
||||
@@ -46,12 +48,23 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=None,
|
||||
description="Currency of the cashu-token",
|
||||
)
|
||||
parent_key_hash: str | None = Field(
|
||||
default=None, foreign_key="api_keys.hashed_key", index=True
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
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)
|
||||
@@ -67,17 +80,46 @@ 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")
|
||||
|
||||
|
||||
class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "lightning_invoices"
|
||||
|
||||
id: str = Field(primary_key=True, description="Unique invoice identifier")
|
||||
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
|
||||
amount_sats: int = Field(description="Amount in satoshis")
|
||||
description: str = Field(description="Invoice description")
|
||||
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
|
||||
status: str = Field(
|
||||
default="pending", description="pending, paid, expired, cancelled"
|
||||
)
|
||||
api_key_hash: str | None = Field(
|
||||
default=None, description="Associated API key hash for topup operations"
|
||||
)
|
||||
purpose: str = Field(description="create or topup")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()), description="Unix timestamp"
|
||||
)
|
||||
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
|
||||
)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str = Field(
|
||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||
)
|
||||
base_url: str = Field(unique=True, description="Base URL of the upstream API")
|
||||
base_url: str = Field(description="Base URL of the upstream API")
|
||||
api_key: str = Field(description="API key for the upstream provider")
|
||||
api_version: str | None = Field(
|
||||
default=None, description="API version for Azure OpenAI"
|
||||
@@ -126,8 +168,6 @@ def run_migrations() -> None:
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
logger.info("Starting database migrations")
|
||||
|
||||
# Get the path to the alembic.ini file
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
alembic_ini_path = project_root / "alembic.ini"
|
||||
@@ -144,7 +184,6 @@ def run_migrations() -> None:
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Run migrations to the latest revision
|
||||
logger.info("Running migrations to latest revision")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
logger.info("Database migrations completed successfully")
|
||||
|
||||
@@ -6,6 +6,15 @@ from .logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""Exception raised when an upstream provider fails."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 502):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle HTTP exceptions and include request ID in response."""
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LogManager:
|
||||
def __init__(self, logs_dir: Path = Path("logs")):
|
||||
self.logs_dir = logs_dir
|
||||
|
||||
def _yield_log_entries(
|
||||
self,
|
||||
hours_back: int | None = None,
|
||||
specific_date: str | None = None,
|
||||
reverse_files: bool = False,
|
||||
max_files: int | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Yields log entries from files.
|
||||
|
||||
Args:
|
||||
hours_back: specific number of hours to look back.
|
||||
specific_date: specific date string (YYYY-MM-DD) to look at.
|
||||
reverse_files: if True, process files in reverse order (newest first).
|
||||
max_files: maximum number of log files to process (most recent if reverse_files is True).
|
||||
"""
|
||||
if not self.logs_dir.exists():
|
||||
return
|
||||
|
||||
log_files = []
|
||||
cutoff_date = None
|
||||
|
||||
if specific_date:
|
||||
log_file = self.logs_dir / f"app_{specific_date}.log"
|
||||
if log_file.exists():
|
||||
log_files.append(log_file)
|
||||
else:
|
||||
log_files = sorted(self.logs_dir.glob("app_*.log"))
|
||||
if reverse_files:
|
||||
log_files.reverse()
|
||||
|
||||
# If we only care about hours back, we can optimize file selection
|
||||
if hours_back is not None:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
filtered_files = []
|
||||
for log_path in log_files:
|
||||
try:
|
||||
file_date_str = log_path.stem.split("_")[1]
|
||||
file_date = datetime.strptime(
|
||||
file_date_str, "%Y-%m-%d"
|
||||
).replace(tzinfo=timezone.utc)
|
||||
# Include file if it's from the same day or after the cutoff day
|
||||
if file_date >= cutoff_date.replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
):
|
||||
filtered_files.append(log_path)
|
||||
except Exception:
|
||||
continue
|
||||
log_files = filtered_files
|
||||
|
||||
if max_files is not None and len(log_files) > max_files:
|
||||
log_files = log_files[:max_files]
|
||||
|
||||
for log_file in log_files:
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
# For reverse search, we might want to read lines in reverse?
|
||||
# But usually logs are append-only.
|
||||
# If reverse_files is True, we iterate files newest to oldest.
|
||||
# But lines within file are still oldest to newest unless we reverse them.
|
||||
lines = f.readlines()
|
||||
if reverse_files:
|
||||
lines.reverse()
|
||||
|
||||
for line in lines:
|
||||
try:
|
||||
entry = json.loads(line.strip())
|
||||
|
||||
if cutoff_date:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
continue
|
||||
log_time = datetime.strptime(
|
||||
timestamp_str, "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
if log_time < cutoff_date:
|
||||
continue
|
||||
|
||||
yield entry
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing log file {log_file}: {e}")
|
||||
continue
|
||||
|
||||
def search_logs(
|
||||
self,
|
||||
date: str | None = None,
|
||||
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]]:
|
||||
"""
|
||||
Search through log files and return matching entries.
|
||||
"""
|
||||
log_entries: list[dict[str, Any]] = []
|
||||
|
||||
# Use reverse=True to get newest logs first by default
|
||||
# If date is specified, we only look at that file
|
||||
|
||||
search_text_lower = search_text.lower() if search_text else None
|
||||
|
||||
# We iterate efficiently
|
||||
iterator = self._yield_log_entries(
|
||||
specific_date=date,
|
||||
reverse_files=True if not date else False,
|
||||
max_files=7 if not date else None,
|
||||
)
|
||||
|
||||
# If we are searching globally (no date), we might want to limit how far back we go?
|
||||
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
|
||||
# My _yield_log_entries with reverse_files=True does all files.
|
||||
# Let's rely on limit to stop us.
|
||||
|
||||
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
|
||||
# For now, let's just iterate.
|
||||
|
||||
for log_data in iterator:
|
||||
if not self._matches_filters(
|
||||
log_data,
|
||||
level,
|
||||
request_id,
|
||||
search_text_lower,
|
||||
status_codes,
|
||||
methods,
|
||||
endpoints,
|
||||
):
|
||||
continue
|
||||
|
||||
log_entries.append(log_data)
|
||||
|
||||
if len(log_entries) >= limit:
|
||||
break
|
||||
|
||||
# Sort by time descending (newest first)
|
||||
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
|
||||
return log_entries
|
||||
|
||||
def _matches_filters(
|
||||
self,
|
||||
log_data: dict[str, Any],
|
||||
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
|
||||
|
||||
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()
|
||||
pathname = str(log_data.get("pathname", "")).lower()
|
||||
|
||||
if (
|
||||
search_text_lower not in message
|
||||
and search_text_lower not in name
|
||||
and search_text_lower not in pathname
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_usage_summary(self, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
return self._calculate_summary_stats(entries)
|
||||
|
||||
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
return self._aggregate_metrics_by_time(entries, interval, hours)
|
||||
|
||||
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
|
||||
errors: list[dict] = []
|
||||
# Iterate newest to oldest for errors?
|
||||
# yield_log_entries sorts files by name (date) ascending by default.
|
||||
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
|
||||
# For error details "last N errors", we probably want newest first.
|
||||
|
||||
# Using list() loads everything into memory, which is what PR 229 did.
|
||||
# For optimization, we could use reverse iterator.
|
||||
|
||||
# Let's just stick to PR 229 logic which filters 'ERROR' level.
|
||||
|
||||
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("levelname", "").upper() == "ERROR":
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": timestamp_str,
|
||||
"message": entry.get("message", ""),
|
||||
"error_type": entry.get("error_type", "unknown"),
|
||||
"pathname": entry.get("pathname", ""),
|
||||
"lineno": entry.get("lineno", 0),
|
||||
"request_id": entry.get("request_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort reverse time
|
||||
errors.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
return {"errors": errors[:limit], "total_count": len(errors)}
|
||||
|
||||
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
|
||||
model_stats: dict[str, dict[str, int | float]] = defaultdict(
|
||||
lambda: {
|
||||
"revenue_msats": 0,
|
||||
"refunds_msats": 0,
|
||||
"requests": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
model = entry.get("model", "unknown")
|
||||
if not isinstance(model, str):
|
||||
model = "unknown"
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
|
||||
if "received proxy request" in message:
|
||||
model_stats[model]["requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
model_stats[model]["successful"] += 1
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
model_stats[model]["revenue_msats"] += actual_cost
|
||||
|
||||
if "revert payment" in message or "upstream request failed" in message:
|
||||
model_stats[model]["failed"] += 1
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
model_stats[model]["refunds_msats"] += max_cost
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
models: list[dict[str, Any]] = []
|
||||
total_revenue = 0.0
|
||||
|
||||
for model, stats in model_stats.items():
|
||||
revenue_msats = float(stats["revenue_msats"])
|
||||
refunds_msats = float(stats["refunds_msats"])
|
||||
|
||||
revenue_sats = revenue_msats / 1000
|
||||
refunds_sats = refunds_msats / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
|
||||
total_revenue += net_revenue_sats
|
||||
|
||||
requests = int(stats["requests"])
|
||||
successful = int(stats["successful"])
|
||||
|
||||
models.append(
|
||||
{
|
||||
"model": model,
|
||||
"revenue_sats": revenue_sats,
|
||||
"refunds_sats": refunds_sats,
|
||||
"net_revenue_sats": net_revenue_sats,
|
||||
"requests": requests,
|
||||
"successful": successful,
|
||||
"failed": int(stats["failed"]),
|
||||
"avg_revenue_per_request": (
|
||||
revenue_sats / successful if successful > 0 else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
|
||||
|
||||
return {
|
||||
"models": models[:limit],
|
||||
"total_revenue_sats": total_revenue,
|
||||
"total_models": len(models),
|
||||
}
|
||||
|
||||
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
|
||||
stats: dict[str, Any] = {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models": set(),
|
||||
"error_types": defaultdict(int),
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
stats["total_entries"] += 1
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
|
||||
if level == "ERROR":
|
||||
stats["total_errors"] += 1
|
||||
if "error_type" in entry:
|
||||
stats["error_types"][str(entry["error_type"])] += 1
|
||||
elif level == "WARNING":
|
||||
stats["total_warnings"] += 1
|
||||
|
||||
if "received proxy request" in message:
|
||||
stats["total_requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
stats["successful_chat_completions"] += 1
|
||||
|
||||
if "upstream request failed" in message or "revert payment" in message:
|
||||
stats["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
stats["payment_processed"] += 1
|
||||
|
||||
if "upstream" in message and level == "ERROR":
|
||||
stats["upstream_errors"] += 1
|
||||
|
||||
if "model" in entry:
|
||||
model = entry["model"]
|
||||
if isinstance(model, str) and model != "unknown":
|
||||
stats["unique_models"].add(model)
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
stats["revenue_msats"] += float(actual_cost)
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
stats["refunds_msats"] += float(max_cost)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
revenue_sats = stats["revenue_msats"] / 1000
|
||||
refunds_sats = stats["refunds_msats"] / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
|
||||
total_requests = stats["total_requests"]
|
||||
successful = stats["successful_chat_completions"]
|
||||
|
||||
return {
|
||||
"total_entries": stats["total_entries"],
|
||||
"total_requests": total_requests,
|
||||
"successful_chat_completions": successful,
|
||||
"failed_requests": stats["failed_requests"],
|
||||
"total_errors": stats["total_errors"],
|
||||
"total_warnings": stats["total_warnings"],
|
||||
"payment_processed": stats["payment_processed"],
|
||||
"upstream_errors": stats["upstream_errors"],
|
||||
"unique_models_count": len(stats["unique_models"]),
|
||||
"unique_models": sorted(list(stats["unique_models"])),
|
||||
"error_types": dict(stats["error_types"]),
|
||||
"success_rate": (successful / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0,
|
||||
"revenue_msats": stats["revenue_msats"],
|
||||
"refunds_msats": stats["refunds_msats"],
|
||||
"revenue_sats": revenue_sats,
|
||||
"refunds_sats": refunds_sats,
|
||||
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
|
||||
"net_revenue_sats": net_revenue_sats,
|
||||
"avg_revenue_per_request_msats": (
|
||||
stats["revenue_msats"] / successful if successful > 0 else 0
|
||||
),
|
||||
"refund_rate": (
|
||||
(stats["failed_requests"] / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0
|
||||
),
|
||||
}
|
||||
|
||||
def _aggregate_metrics_by_time(
|
||||
self, entries: list[dict], interval_minutes: int, hours_back: int
|
||||
) -> dict:
|
||||
time_buckets: dict[str, dict[str, Any]] = defaultdict(
|
||||
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
continue
|
||||
|
||||
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Round down to nearest interval
|
||||
minutes = log_time.minute
|
||||
rounded_minutes = (minutes // interval_minutes) * interval_minutes
|
||||
bucket_time = log_time.replace(
|
||||
minute=rounded_minutes, second=0, microsecond=0
|
||||
)
|
||||
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
bucket = time_buckets[bucket_key]
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
|
||||
if "received proxy request" in message:
|
||||
bucket["requests"] += 1
|
||||
|
||||
if level == "ERROR":
|
||||
bucket["errors"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
bucket["revenue_msats"] += float(actual_cost)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
result = []
|
||||
for bucket_key in sorted(time_buckets.keys()):
|
||||
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
|
||||
|
||||
return {
|
||||
"metrics": result,
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours_back,
|
||||
"total_buckets": len(result),
|
||||
}
|
||||
|
||||
|
||||
log_manager = LogManager()
|
||||
+58
-8
@@ -1,3 +1,40 @@
|
||||
"""
|
||||
Logging configuration for Routstr.
|
||||
|
||||
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
|
||||
===========================================
|
||||
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
|
||||
DO NOT modify or remove these messages without updating the usage tracking logic:
|
||||
|
||||
1. "Received proxy request" (INFO) - routstr/proxy.py
|
||||
- Used to count total incoming requests
|
||||
- Includes model information in context
|
||||
|
||||
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
|
||||
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
|
||||
- Used to track successful completions and revenue
|
||||
- The 'cost_data.total_msats' field is extracted for revenue calculation
|
||||
- Must include 'cost_data' in extra dict
|
||||
|
||||
3. "Payment processed successfully" (INFO) - routstr/auth.py
|
||||
- Used to count successful payment processing events
|
||||
- Tracks payment-related metrics
|
||||
|
||||
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
|
||||
- Used to track failed requests and refunds
|
||||
- The 'max_cost_for_model' field is extracted for refund calculation
|
||||
- Must include 'max_cost_for_model' in extra dict
|
||||
|
||||
5. Any ERROR level logs with "upstream" in the message
|
||||
- Used to count upstream provider errors
|
||||
- Helps identify service reliability issues
|
||||
|
||||
If you need to modify these messages, ensure you also update the parsing logic in:
|
||||
- routstr/core/admin.py:_aggregate_metrics_by_time()
|
||||
- routstr/core/admin.py:_get_summary_stats()
|
||||
- routstr/core/admin.py:get_revenue_by_model()
|
||||
"""
|
||||
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
@@ -155,21 +192,24 @@ class SecurityFilter(logging.Filter):
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||
]
|
||||
for pattern in standalone_patterns:
|
||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||
|
||||
for key in self.SENSITIVE_KEYS:
|
||||
if key in message.lower():
|
||||
patterns = [
|
||||
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
|
||||
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
key_patterns = [
|
||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for pattern in key_patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
record.msg = message
|
||||
record.args = ()
|
||||
|
||||
@@ -298,6 +338,11 @@ def setup_logging() -> None:
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"openai": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"httpcore": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
@@ -320,6 +365,11 @@ def setup_logging() -> None:
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
|
||||
"alembic": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": log_level,
|
||||
|
||||
+11
-244
@@ -1,155 +1,30 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
models_router,
|
||||
update_sats_pricing,
|
||||
)
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from ..discovery import providers_router
|
||||
from ..payment.models import models_router
|
||||
from ..proxy import proxy_router
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
from .logging import get_logger, setup_logging
|
||||
from .middleware import LoggingMiddleware
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
from .tasks import lifespan
|
||||
from .ui import setup_ui
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.2.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.2.0"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Application startup initiated", extra={"version": __version__})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# await ensure_models_bootstrapped()
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream import refresh_upstreams_models_periodically
|
||||
|
||||
await _update_prices()
|
||||
await initialize_upstreams()
|
||||
|
||||
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())
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if models_cleanup_task is not None:
|
||||
models_cleanup_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if 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)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
@@ -182,7 +57,7 @@ async def info() -> dict:
|
||||
"mints": global_settings.cashu_mints,
|
||||
"http_url": global_settings.http_url,
|
||||
"onion_url": global_settings.onion_url,
|
||||
"models": [], # kept for back-compat; prefer /v1/models
|
||||
"child_key_cost_msats": global_settings.child_key_cost,
|
||||
}
|
||||
|
||||
|
||||
@@ -191,116 +66,8 @@ async def providers() -> RedirectResponse:
|
||||
return RedirectResponse("/v1/providers/")
|
||||
|
||||
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@app.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": __version__,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
# Setup UI serving
|
||||
setup_ui(app)
|
||||
|
||||
|
||||
app.include_router(models_router)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -52,14 +52,18 @@ class Settings(BaseSettings):
|
||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||
# 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"
|
||||
@@ -114,7 +118,7 @@ def resolve_bootstrap() -> Settings:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Map COST_PER_1K_* -> CUSTOM_PER_1K_*
|
||||
# Map COST_PER_1K_* -> FIXED_PER_1K_*
|
||||
if (
|
||||
"COST_PER_1K_INPUT_TOKENS" in os.environ
|
||||
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
|
||||
@@ -235,7 +239,7 @@ class SettingsService:
|
||||
|
||||
merged_dict: dict[str, Any] = dict(env_resolved.dict())
|
||||
merged_dict.update(
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", []) and v}
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
|
||||
)
|
||||
|
||||
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
# when other refactor is merged:
|
||||
# from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..discovery import providers_cache_refresher
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Application startup initiated", extra={"version": app.version})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
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:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# await ensure_models_bootstrapped()
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
_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())
|
||||
)
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
if global_settings.nsec:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .logging import get_logger
|
||||
from .settings import settings as global_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def setup_ui(app: FastAPI) -> None:
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@app.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@app.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@app.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@app.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@app.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@app.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=UI_DIST_PATH, check_dir=True),
|
||||
name="ui-static",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": app.version,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import get_wallet
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
lightning_router = APIRouter(prefix="/lightning")
|
||||
|
||||
|
||||
class InvoiceCreateRequest(BaseModel):
|
||||
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
|
||||
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
|
||||
api_key: str | None = Field(
|
||||
default=None, description="Required for topup operations"
|
||||
)
|
||||
|
||||
|
||||
class InvoiceCreateResponse(BaseModel):
|
||||
invoice_id: str
|
||||
bolt11: str
|
||||
amount_sats: int
|
||||
expires_at: int
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceStatusResponse(BaseModel):
|
||||
status: str
|
||||
api_key: str | None = None
|
||||
amount_sats: int
|
||||
paid_at: int | None = None
|
||||
created_at: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
class InvoiceRecoverRequest(BaseModel):
|
||||
bolt11: str = Field(description="BOLT11 invoice string")
|
||||
|
||||
|
||||
async def generate_lightning_invoice(
|
||||
amount_sats: int, description: str
|
||||
) -> tuple[str, str]:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
quote = await wallet.request_mint(amount_sats)
|
||||
return quote.request, quote.quote
|
||||
|
||||
|
||||
def generate_invoice_id() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||
async def create_invoice(
|
||||
request: InvoiceCreateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceCreateResponse:
|
||||
if request.purpose == "topup" and not request.api_key:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="api_key is required for topup operations"
|
||||
)
|
||||
|
||||
if request.purpose == "topup" and request.api_key:
|
||||
if not request.api_key.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid API key format")
|
||||
|
||||
api_key = await session.get(ApiKey, request.api_key[3:])
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
try:
|
||||
description = f"Routstr {request.purpose} {request.amount_sats} sats"
|
||||
bolt11, payment_hash = await generate_lightning_invoice(
|
||||
request.amount_sats, description
|
||||
)
|
||||
|
||||
invoice_id = generate_invoice_id()
|
||||
expires_at = int(time.time()) + 3600 # 1 hour expiry
|
||||
|
||||
invoice = LightningInvoice(
|
||||
id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
description=description,
|
||||
payment_hash=payment_hash,
|
||||
status="pending",
|
||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||
purpose=request.purpose,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
session.add(invoice)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice created",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"amount_sats": request.amount_sats,
|
||||
"purpose": request.purpose,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
return InvoiceCreateResponse(
|
||||
invoice_id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
expires_at=expires_at,
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Lightning invoice: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Lightning invoice"
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.get(
|
||||
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
||||
)
|
||||
async def get_invoice_status(
|
||||
invoice_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
invoice = await session.get(LightningInvoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
|
||||
invoice.status = "expired"
|
||||
await session.commit()
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid" and invoice.purpose == "create":
|
||||
if invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif (
|
||||
invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash
|
||||
):
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
||||
async def recover_invoice(
|
||||
request: InvoiceRecoverRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
result = await session.exec(
|
||||
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
|
||||
)
|
||||
invoice = result.first()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid":
|
||||
if invoice.purpose == "create" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_payment(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
try:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
|
||||
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
|
||||
|
||||
if mint_status.paid:
|
||||
invoice.status = "paid"
|
||||
invoice.paid_at = int(time.time())
|
||||
|
||||
if invoice.purpose == "create":
|
||||
api_key = await create_api_key_from_invoice(invoice, session)
|
||||
invoice.api_key_hash = api_key.hashed_key
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
await topup_api_key_from_invoice(invoice, session)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice paid",
|
||||
extra={
|
||||
"invoice_id": invoice.id,
|
||||
"amount_sats": invoice.amount_sats,
|
||||
"purpose": invoice.purpose,
|
||||
"api_key_hash": invoice.api_key_hash[:8] + "..."
|
||||
if invoice.api_key_hash
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check invoice payment: {e}")
|
||||
|
||||
|
||||
async def create_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> ApiKey:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
|
||||
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
|
||||
|
||||
api_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=invoice.amount_sats * 1000, # Convert to msats
|
||||
refund_currency="sat",
|
||||
refund_mint_url=settings.primary_mint,
|
||||
)
|
||||
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
async def topup_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
if not invoice.api_key_hash:
|
||||
raise ValueError("No API key associated with topup invoice")
|
||||
|
||||
api_key = await session.get(ApiKey, invoice.api_key_hash)
|
||||
if not api_key:
|
||||
raise ValueError("Associated API key not found")
|
||||
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
await session.flush()
|
||||
+1
-1
@@ -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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -14,6 +15,7 @@ class CostData(BaseModel):
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
total_usd: float = 0.0
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
@@ -25,7 +27,7 @@ class CostDataError(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
async def calculate_cost(
|
||||
async def calculate_cost( # todo: can be sync
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
@@ -47,13 +49,6 @@ async def calculate_cost(
|
||||
},
|
||||
)
|
||||
|
||||
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 +57,64 @@ async def calculate_cost(
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
return MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.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,
|
||||
total_usd=usd_cost,
|
||||
)
|
||||
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
|
||||
@@ -78,13 +130,9 @@ async def calculate_cost(
|
||||
extra={"model": response_model},
|
||||
)
|
||||
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream import get_model_with_override
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
upstreams = get_upstreams()
|
||||
model_obj = await get_model_with_override(
|
||||
response_model, upstreams, session=session
|
||||
)
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
@@ -131,14 +179,41 @@ async def calculate_cost(
|
||||
"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)
|
||||
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
@@ -148,6 +223,7 @@ async def calculate_cost(
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"total_usd": total_usd,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
@@ -157,4 +233,5 @@ async def calculate_cost(
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
)
|
||||
+185
-11
@@ -1,9 +1,13 @@
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Response
|
||||
from fastapi.requests import Request
|
||||
from PIL import Image
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core import get_logger
|
||||
@@ -104,11 +108,9 @@ async def get_max_cost_for_model(
|
||||
return max(settings.min_request_msat, default_cost_msats)
|
||||
|
||||
if not model_obj:
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream import get_model_with_override
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
upstreams = get_upstreams()
|
||||
model_obj = await get_model_with_override(model, upstreams, session)
|
||||
model_obj = get_model_instance(model)
|
||||
|
||||
if not model_obj:
|
||||
fallback_msats = settings.fixed_cost_per_request * 1000
|
||||
@@ -174,13 +176,23 @@ async def calculate_discounted_max_cost(
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = estimate_tokens(messages)
|
||||
|
||||
image_tokens = await estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats >= 0:
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
else:
|
||||
adjusted = adjusted + math.ceil(-estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
@@ -195,10 +207,8 @@ async def calculate_discounted_max_cost(
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats >= 0:
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
else:
|
||||
adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
@@ -214,7 +224,171 @@ async def calculate_discounted_max_cost(
|
||||
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
return len(str(messages)) // 3
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
|
||||
|
||||
def create_error_response(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -291,9 +291,7 @@ async def raw_send_to_lnurl(
|
||||
lnurl_data["callback_url"], final_amount
|
||||
)
|
||||
|
||||
melt_quote_resp = await wallet.melt_quote(
|
||||
invoice=bolt11_invoice, amount_msat=final_amount
|
||||
)
|
||||
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
|
||||
|
||||
if amount:
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
|
||||
+86
-344
@@ -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
|
||||
@@ -31,10 +28,12 @@ class Architecture(BaseModel):
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
request: float = 0.0
|
||||
image: float = 0.0
|
||||
web_search: float = 0.0
|
||||
internal_reasoning: float = 0.0
|
||||
input_cache_read: float = 0.0
|
||||
input_cache_write: float = 0.0
|
||||
max_prompt_cost: float = 0.0 # in sats not msats
|
||||
max_completion_cost: float = 0.0 # in sats not msats
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
@@ -58,50 +57,33 @@ class Model(BaseModel):
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
enabled: bool = True
|
||||
upstream_provider_id: int | None = None
|
||||
upstream_provider_id: int | str | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
if not pricing:
|
||||
return False
|
||||
|
||||
try:
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
prompt = float(pricing.get("prompt", 0))
|
||||
completion = float(pricing.get("completion", 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if prompt < 0 or completion < 0:
|
||||
return False
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
if prompt == 0 and completion == 0:
|
||||
return False
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id == "openrouter/auto"
|
||||
or model_id == "google/gemini-2.5-pro-exp-03-25"
|
||||
or model_id == "opengvlab/internvl3-78b"
|
||||
or model_id == "openrouter/sonoma-dusk-alpha"
|
||||
or model_id == "openrouter/sonoma-sky-alpha"
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
return True
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
@@ -110,12 +92,32 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
|
||||
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:
|
||||
@@ -127,19 +129,15 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id == "openrouter/auto"
|
||||
or model_id == "google/gemini-2.5-pro-exp-03-25"
|
||||
or model_id == "opengvlab/internvl3-78b"
|
||||
or model_id == "openrouter/sonoma-dusk-alpha"
|
||||
or model_id == "openrouter/sonoma-sky-alpha"
|
||||
):
|
||||
if "(free)" in model.get("name", ""):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
return models_data
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
@@ -153,54 +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")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
@@ -234,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:
|
||||
@@ -279,6 +230,7 @@ async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
apply_fees: bool = True,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
@@ -296,12 +248,17 @@ async def list_models(
|
||||
return [
|
||||
_row_to_model(
|
||||
r,
|
||||
apply_provider_fee=True,
|
||||
apply_provider_fee=apply_fees,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
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
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -314,6 +271,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)
|
||||
|
||||
@@ -339,28 +298,34 @@ def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
if (cl := model.top_provider.context_length) and (
|
||||
mct := model.top_provider.max_completion_tokens
|
||||
):
|
||||
if cl <= mct:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
return (
|
||||
(cl - mct) * prompt_price,
|
||||
cl * prompt_price,
|
||||
mct * completion_price,
|
||||
(cl - mct) * prompt_price + mct * completion_price,
|
||||
)
|
||||
elif cl := model.top_provider.context_length:
|
||||
return (
|
||||
cl * 0.8 * prompt_price,
|
||||
cl * 0.2 * completion_price,
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
elif mct := model.top_provider.max_completion_tokens:
|
||||
return (
|
||||
mct * 4 * prompt_price,
|
||||
mct * prompt_price,
|
||||
mct * completion_price,
|
||||
mct * completion_price,
|
||||
mct * 5 * prompt_price,
|
||||
)
|
||||
elif model.context_length:
|
||||
return (
|
||||
model.context_length * 0.8 * prompt_price,
|
||||
model.context_length * 0.2 * completion_price,
|
||||
model.context_length * prompt_price,
|
||||
model.context_length * completion_price,
|
||||
model.context_length * max(completion_price, prompt_price),
|
||||
)
|
||||
|
||||
p = prompt_price * 1_000_000
|
||||
@@ -409,6 +374,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
enabled=model.enabled,
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
alias_ids=model.alias_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -422,60 +388,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()
|
||||
@@ -492,6 +407,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:
|
||||
@@ -502,7 +418,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:
|
||||
@@ -526,186 +448,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
|
||||
print(db_model.id)
|
||||
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.1
|
||||
) -> 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 = float(db_pricing.get(key, 0.0)) * 1000000
|
||||
upstream_val = float(upstream_pricing.get(key, 0.0)) * 1000000
|
||||
print(db_val - upstream_val)
|
||||
|
||||
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:
|
||||
|
||||
+20
-10
@@ -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(
|
||||
@@ -110,10 +124,6 @@ async def _update_prices() -> None:
|
||||
return
|
||||
BTC_USD_PRICE = btc_price
|
||||
SATS_USD_PRICE = btc_price / 100_000_000
|
||||
logger.info(
|
||||
"Updated BTC/USD price",
|
||||
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
|
||||
)
|
||||
|
||||
|
||||
def btc_usd_price() -> float:
|
||||
|
||||
+237
-81
@@ -3,7 +3,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel import select
|
||||
|
||||
from .algorithm import create_model_mappings
|
||||
from .auth import pay_for_request, revert_pay_for_request, validate_bearer_key
|
||||
@@ -16,6 +16,7 @@ from .core.db import (
|
||||
create_session,
|
||||
get_session,
|
||||
)
|
||||
from .core.exceptions import UpstreamError
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
check_token_balance,
|
||||
@@ -23,14 +24,17 @@ from .payment.helpers import (
|
||||
get_max_cost_for_model,
|
||||
)
|
||||
from .payment.models import Model
|
||||
from .upstream import UpstreamProvider, init_upstreams
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.helpers import init_upstreams
|
||||
|
||||
logger = get_logger(__name__)
|
||||
proxy_router = APIRouter()
|
||||
|
||||
_upstreams: list[UpstreamProvider] = []
|
||||
_upstreams: list[BaseUpstreamProvider] = []
|
||||
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
||||
_provider_map: dict[str, UpstreamProvider] = {} # All aliases -> Provider
|
||||
_provider_map: dict[
|
||||
str, list[BaseUpstreamProvider]
|
||||
] = {} # All aliases -> List[Provider]
|
||||
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
||||
|
||||
|
||||
@@ -53,7 +57,7 @@ async def reinitialize_upstreams() -> None:
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
def get_upstreams() -> list[UpstreamProvider]:
|
||||
def get_upstreams() -> list[BaseUpstreamProvider]:
|
||||
"""Get the initialized upstream providers.
|
||||
|
||||
Returns:
|
||||
@@ -64,12 +68,12 @@ def get_upstreams() -> list[UpstreamProvider]:
|
||||
|
||||
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) -> UpstreamProvider | None:
|
||||
"""Get UpstreamProvider for model ID from global cache."""
|
||||
return _provider_map.get(model_id)
|
||||
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
||||
"""Get UpstreamProvider list for model ID from global cache."""
|
||||
return _provider_map.get(model_id.lower())
|
||||
|
||||
|
||||
def get_unique_models() -> list[Model]:
|
||||
@@ -79,33 +83,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(col(ModelRow.enabled).is_(True))
|
||||
# Fetch all providers with their models in a single logical operation
|
||||
query = select(UpstreamProviderRow).options(
|
||||
selectinload(UpstreamProviderRow.models) # type: ignore
|
||||
)
|
||||
override_rows = result.all()
|
||||
result = await session.exec(query)
|
||||
provider_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]] = {}
|
||||
disabled_model_ids: set[str] = set()
|
||||
|
||||
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(col(ModelRow.enabled).is_(False))
|
||||
)
|
||||
disabled_model_ids = {row for row in disabled_result.all()}
|
||||
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,
|
||||
@@ -142,20 +142,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:
|
||||
@@ -163,8 +157,8 @@ async def proxy(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
)
|
||||
|
||||
upstream = get_provider_for_model(model_id)
|
||||
if not upstream:
|
||||
upstreams = get_provider_for_model(model_id)
|
||||
if not upstreams:
|
||||
return create_error_response(
|
||||
"invalid_model",
|
||||
f"No provider found for model '{model_id}'",
|
||||
@@ -172,6 +166,10 @@ async def proxy(
|
||||
request=request,
|
||||
)
|
||||
|
||||
# todo figure out cost calculation since fallback provider is usually not the same price
|
||||
# Use first provider for initial checks/cost calculation
|
||||
# primary_upstream = upstreams[0]
|
||||
|
||||
_max_cost_for_model = await get_max_cost_for_model(
|
||||
model=model_id, session=session, model_obj=model_obj
|
||||
)
|
||||
@@ -181,8 +179,30 @@ 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
|
||||
last_error = None
|
||||
for i, upstream in enumerate(upstreams):
|
||||
try:
|
||||
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
|
||||
)
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
|
||||
)
|
||||
if i == len(upstreams) - 1:
|
||||
last_error = e
|
||||
continue
|
||||
|
||||
return create_error_response(
|
||||
"upstream_error",
|
||||
str(last_error) if last_error else "All upstreams failed",
|
||||
502,
|
||||
request=request,
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
@@ -198,49 +218,167 @@ 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)
|
||||
last_error_response = None
|
||||
for i, upstream in enumerate(upstreams):
|
||||
try:
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
response = await upstream.forward_get_request(request, path, headers)
|
||||
|
||||
if response.status_code in [502, 429] and i < len(upstreams) - 1:
|
||||
error_message = ""
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_bytes = response.body
|
||||
data = json.loads(body_bytes)
|
||||
if "error" in data:
|
||||
error_data = data["error"]
|
||||
if isinstance(error_data, dict):
|
||||
error_message = error_data.get("message", "")
|
||||
elif isinstance(error_data, str):
|
||||
error_message = error_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await upstream.on_upstream_error_redirect(
|
||||
response.status_code, error_message
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} returned {response.status_code} (GET), trying next provider",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"upstream": upstream.provider_type,
|
||||
},
|
||||
)
|
||||
continue
|
||||
return response
|
||||
except UpstreamError as e:
|
||||
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
|
||||
if i == len(upstreams) - 1:
|
||||
last_error_response = create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
continue
|
||||
return last_error_response or create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
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))
|
||||
for i, upstream in enumerate(upstreams):
|
||||
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:
|
||||
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:
|
||||
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
|
||||
should_retry = response.status_code in [502, 429, 400, 401, 403, 404]
|
||||
if should_retry and i < len(upstreams) - 1:
|
||||
error_message = ""
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_bytes = response.body
|
||||
data = json.loads(body_bytes)
|
||||
if "error" in data:
|
||||
error_data = data["error"]
|
||||
if isinstance(error_data, dict):
|
||||
error_message = error_data.get("message", "")
|
||||
elif isinstance(error_data, str):
|
||||
error_message = error_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await upstream.on_upstream_error_redirect(
|
||||
response.status_code, error_message
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"upstream": upstream.provider_type,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# 4xx error (user error), or other non-retryable error, or last provider failed
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} failed: {e}",
|
||||
extra={"retry": i < len(upstreams) - 1},
|
||||
)
|
||||
|
||||
# If this was the last provider
|
||||
if i == len(upstreams) - 1:
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
return create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
|
||||
# Otherwise loop continues to next provider
|
||||
continue
|
||||
|
||||
# Should not be reached given logic above
|
||||
return create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
# Return the mapped error response generated earlier rather than masking with 502
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def get_bearer_token_key(
|
||||
headers: dict, path: str, session: AsyncSession, auth: str
|
||||
@@ -322,6 +460,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:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from .anthropic import AnthropicUpstreamProvider
|
||||
from .azure import AzureUpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
from .fireworks import FireworksUpstreamProvider
|
||||
from .gemini import GeminiUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
from .groq import GroqUpstreamProvider
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GeminiUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
__all__ = [
|
||||
"BaseUpstreamProvider",
|
||||
*[cls.__name__ for cls in upstream_provider_classes],
|
||||
"upstream_provider_classes",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Anthropic API."""
|
||||
|
||||
provider_type = "anthropic"
|
||||
default_base_url = "https://api.anthropic.com/v1"
|
||||
platform_url = "https://console.anthropic.com/settings/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AnthropicUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Anthropic",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'anthropic/' prefix for Anthropic API compatibility and transform model names."""
|
||||
if model_id.startswith("anthropic/"):
|
||||
model_id = model_id[len("anthropic/") :]
|
||||
fixed_transforms = {
|
||||
"claude-haiku-4.5": "claude-haiku-4-5-20251001",
|
||||
"claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4.1": "claude-opus-4-1-20250805",
|
||||
"claude-opus-4": "claude-opus-4-20250514",
|
||||
"claude-sonnet-4": "claude-sonnet-4-20250514",
|
||||
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
|
||||
"claude-3-haiku": "claude-3-haiku-20240307",
|
||||
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-1": "claude-opus-4-1-20250805",
|
||||
"claude-3-5-haiku": "claude-3-5-haiku-20241022",
|
||||
}
|
||||
if model_id in fixed_transforms:
|
||||
model_id = fixed_transforms[model_id]
|
||||
return model_id
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch Anthropic models from OpenRouter API filtered by anthropic source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="anthropic")
|
||||
models = [Model(**model) for model in models_data] # type: ignore
|
||||
for model in models:
|
||||
model.alias_ids = [self.transform_model_name(model.id)]
|
||||
return models
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Azure OpenAI Service."""
|
||||
|
||||
provider_type = "azure"
|
||||
default_base_url = None
|
||||
platform_url = "https://portal.azure.com/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
api_version: str,
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
"""Initialize Azure provider with API key and version.
|
||||
|
||||
Args:
|
||||
base_url: Azure OpenAI endpoint base URL
|
||||
api_key: Azure OpenAI API key for authentication
|
||||
api_version: Azure OpenAI API version (e.g., "2024-02-15-preview")
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
"""
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
self.api_version = api_version
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AzureUpstreamProvider | None":
|
||||
if not provider_row.api_version:
|
||||
return None
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
api_version=provider_row.api_version,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Azure OpenAI",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def prepare_params(
|
||||
self, path: str, query_params: Mapping[str, str] | None
|
||||
) -> Mapping[str, str]:
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version.
|
||||
|
||||
Args:
|
||||
path: Request path
|
||||
query_params: Original query parameters from the client
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
"""
|
||||
params = dict(query_params or {})
|
||||
if path.endswith("chat/completions"):
|
||||
params["api-version"] = self.api_version
|
||||
return params
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .gemini import GeminiClient
|
||||
|
||||
__all__ = ["GeminiClient"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
|
||||
class BaseAPIClient(ABC):
|
||||
"""Base class for AI provider API clients."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate content non-streaming."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available models."""
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .base import BaseAPIClient
|
||||
|
||||
|
||||
class GeminiClient(BaseAPIClient):
|
||||
"""Gemini API client using OpenAI compatibility layer."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
super().__init__(api_key, base_url)
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
or "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
)
|
||||
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
return response.model_dump()
|
||||
|
||||
async def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
usage_callback = kwargs.get("usage_callback")
|
||||
completion_callback = kwargs.get("completion_callback")
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
|
||||
final_usage = None
|
||||
|
||||
async for chunk in stream:
|
||||
chunk_data = chunk.model_dump()
|
||||
|
||||
if chunk.usage:
|
||||
final_usage = chunk.usage.model_dump()
|
||||
if usage_callback:
|
||||
usage_callback(final_usage)
|
||||
|
||||
yield chunk_data
|
||||
|
||||
if completion_callback:
|
||||
await completion_callback(model, final_usage)
|
||||
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available Gemini models."""
|
||||
try:
|
||||
response = await self.client.models.list()
|
||||
return [model.model_dump() for model in response.data]
|
||||
except Exception as e:
|
||||
from ...core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Failed to list Gemini models: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class FireworksUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Fireworks.ai API."""
|
||||
|
||||
provider_type = "fireworks"
|
||||
default_base_url = "https://api.fireworks.ai/inference/v1"
|
||||
platform_url = "https://app.fireworks.ai/settings/users/api-keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "FireworksUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Fireworks",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'fireworks/' prefix for Fireworks API compatibility."""
|
||||
return model_id.split("/")[-1]
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .clients.gemini import GeminiClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "gemini"
|
||||
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||
platform_url = "https://aistudio.google.com/app/apikey"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
|
||||
api_key: str = "",
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
base_url=base_url,
|
||||
)
|
||||
self._client: GeminiClient | None = None
|
||||
|
||||
@property
|
||||
def client(self) -> GeminiClient:
|
||||
"""Get or create the Gemini API client."""
|
||||
if self._client is None:
|
||||
self._client = GeminiClient(api_key=self.api_key)
|
||||
return self._client
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GeminiUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Google Gemini",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("gemini/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
# Remove provider prefix from model ID for Gemini API
|
||||
if "/" in model_obj.id:
|
||||
model_obj.id = model_obj.id.split("/", 1)[1]
|
||||
|
||||
if not path.startswith("chat/completions"):
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if not request_body:
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
try:
|
||||
openai_data = json.loads(request_body)
|
||||
messages = openai_data.get("messages", [])
|
||||
temperature = openai_data.get("temperature")
|
||||
max_tokens = openai_data.get("max_tokens")
|
||||
top_p = openai_data.get("top_p")
|
||||
is_streaming = openai_data.get("stream", False)
|
||||
|
||||
logger.info(
|
||||
"Processing Gemini request with client abstraction",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"is_streaming": is_streaming,
|
||||
"message_count": len(messages),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
final_usage_data: dict | None = None
|
||||
|
||||
def usage_callback(usage_data: dict[str, Any]) -> None:
|
||||
"""Callback to capture usage data during streaming"""
|
||||
nonlocal final_usage_data
|
||||
final_usage_data = usage_data
|
||||
|
||||
async def completion_callback(
|
||||
model: str, usage_data: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Callback to handle payment when streaming completes"""
|
||||
nonlocal final_usage_data
|
||||
if usage_data:
|
||||
final_usage_data = usage_data
|
||||
|
||||
payment_data = {
|
||||
"model": model,
|
||||
"usage": final_usage_data,
|
||||
}
|
||||
|
||||
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:
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
payment_data,
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini streaming payment finalized",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"usage_data": final_usage_data,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
response_generator = self.client.generate_content_stream(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
usage_callback=usage_callback,
|
||||
completion_callback=completion_callback,
|
||||
)
|
||||
|
||||
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",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not payment_finalized:
|
||||
await finalize_payment()
|
||||
|
||||
return StreamingResponse(
|
||||
stream_with_cost(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
else:
|
||||
openai_format_response = await self.client.generate_content(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
)
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
openai_format_response["cost"] = cost_data
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"model": model_obj.id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(openai_format_response),
|
||||
media_type="application/json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini forward_request",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from Gemini API."""
|
||||
try:
|
||||
models_data = await self.client.list_models()
|
||||
|
||||
for model in models_data:
|
||||
if "id" in model and model["id"].startswith("models/"):
|
||||
model["id"] = model["id"].removeprefix("models/")
|
||||
|
||||
return {"data": models_data}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from Gemini API: {e}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"base_url": self.base_url,
|
||||
},
|
||||
)
|
||||
return {"data": []}
|
||||
@@ -4,9 +4,10 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from .upstream import UpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -14,9 +15,13 @@ from ..core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GenericUpstreamProvider(UpstreamProvider):
|
||||
class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Generic upstream provider that can fetch models from any OpenAI-compatible API."""
|
||||
|
||||
provider_type = "generic"
|
||||
default_base_url = "http://localhost:8888"
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
@@ -39,6 +44,26 @@ class GenericUpstreamProvider(UpstreamProvider):
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GenericUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Generic",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from upstream API using /models endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class GroqUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Groq API."""
|
||||
|
||||
provider_type = "groq"
|
||||
default_base_url = "https://api.groq.com/openai/v1"
|
||||
platform_url = "https://console.groq.com/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Groq",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'groq/' prefix for Groq API compatibility."""
|
||||
return model_id.removeprefix("groq/")
|
||||
@@ -1,30 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .core.settings import Settings
|
||||
from ..core.settings import Settings
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
from .payment.models import Model
|
||||
from .upstreams import (
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
UpstreamProvider,
|
||||
)
|
||||
from .upstreams.generic import GenericUpstreamProvider
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def resolve_model_alias(model_id: str, canonical_slug: str | None = None) -> list[str]:
|
||||
def resolve_model_alias(
|
||||
model_id: str, canonical_slug: str | None = None, alias_ids: list[str] | None = None
|
||||
) -> list[str]:
|
||||
"""Resolve model ID to all possible aliases.
|
||||
|
||||
Returns list of aliases including canonical slug and variations without provider prefix.
|
||||
@@ -66,11 +62,14 @@ def resolve_model_alias(model_id: str, canonical_slug: str | None = None) -> lis
|
||||
if canonical_base not in aliases:
|
||||
aliases.append(canonical_base)
|
||||
|
||||
if alias_ids:
|
||||
aliases.extend(alias_ids)
|
||||
|
||||
return aliases
|
||||
|
||||
|
||||
async def get_all_models_with_overrides(
|
||||
upstreams: list[UpstreamProvider],
|
||||
upstreams: list[BaseUpstreamProvider],
|
||||
) -> list[Model]:
|
||||
"""Get all models from all providers with database overrides applied.
|
||||
|
||||
@@ -85,7 +84,7 @@ async def get_all_models_with_overrides(
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from .payment.models import _row_to_model
|
||||
from ..payment.models import _row_to_model
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
@@ -103,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] = {}
|
||||
@@ -120,57 +121,8 @@ async def get_all_models_with_overrides(
|
||||
return list(all_models.values())
|
||||
|
||||
|
||||
async def get_model_with_override(
|
||||
model_id: str,
|
||||
upstreams: list[UpstreamProvider],
|
||||
session: AsyncSession,
|
||||
) -> Model | None:
|
||||
"""Get a specific model from providers with database override applied.
|
||||
|
||||
Resolves model aliases automatically (e.g., both "gpt-5-mini" and "openai/gpt-5-mini").
|
||||
|
||||
Args:
|
||||
model_id: Model identifier (with or without provider prefix)
|
||||
upstreams: List of upstream provider instances
|
||||
|
||||
Returns:
|
||||
Model object or None if not found
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from .payment.models import _row_to_model
|
||||
|
||||
aliases = resolve_model_alias(model_id)
|
||||
|
||||
for alias in aliases:
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(
|
||||
ModelRow.id == alias,
|
||||
ModelRow.upstream_provider_id.isnot(None), # type: ignore
|
||||
ModelRow.enabled,
|
||||
)
|
||||
)
|
||||
override_row = result.first()
|
||||
if override_row:
|
||||
provider = await session.get(
|
||||
UpstreamProviderRow, override_row.upstream_provider_id
|
||||
)
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
for alias in aliases:
|
||||
for upstream in upstreams:
|
||||
model = upstream.get_cached_model_by_id(alias)
|
||||
if model and model.enabled:
|
||||
return model
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def refresh_upstreams_models_periodically(
|
||||
upstreams: list[UpstreamProvider],
|
||||
upstreams: list[BaseUpstreamProvider],
|
||||
) -> None:
|
||||
"""Background task to periodically refresh models cache for all providers.
|
||||
|
||||
@@ -180,7 +132,7 @@ async def refresh_upstreams_models_periodically(
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from .core.settings import settings
|
||||
from ..core.settings import settings
|
||||
|
||||
interval = getattr(settings, "models_refresh_interval_seconds", 0)
|
||||
if not interval or interval <= 0:
|
||||
@@ -194,9 +146,20 @@ async def refresh_upstreams_models_periodically(
|
||||
await upstream.refresh_models_cache()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error refreshing models for {upstream.upstream_name or upstream.base_url}",
|
||||
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:
|
||||
@@ -212,15 +175,13 @@ async def refresh_upstreams_models_periodically(
|
||||
break
|
||||
|
||||
|
||||
async def init_upstreams() -> list[UpstreamProvider]:
|
||||
async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
"""Initialize upstream providers from database.
|
||||
|
||||
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
|
||||
from ..core.settings import settings
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
@@ -235,23 +196,29 @@ async def init_upstreams() -> list[UpstreamProvider]:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
|
||||
upstreams: list[UpstreamProvider] = []
|
||||
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.info(
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
extra={
|
||||
"base_url": provider_row.base_url,
|
||||
"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
|
||||
|
||||
@@ -266,67 +233,56 @@ async def _seed_providers_from_settings(
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from .core.settings import settings
|
||||
from . import upstream_provider_classes
|
||||
|
||||
providers_to_add: list[UpstreamProviderRow] = []
|
||||
seeded_base_urls: set[str] = set()
|
||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||
|
||||
openai_api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if openai_api_key:
|
||||
base_url = "https://api.openai.com/v1"
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openai",
|
||||
base_url=base_url,
|
||||
api_key=openai_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
for cls in upstream_provider_classes # type: ignore[attr-defined]
|
||||
}
|
||||
|
||||
anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if anthropic_api_key:
|
||||
base_url = "https://api.anthropic.com/v1"
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="anthropic",
|
||||
base_url=base_url,
|
||||
api_key=anthropic_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
env_mappings: list[tuple[str, str, str | None, str | None]] = [
|
||||
("OPENAI_API_KEY", "openai", None, None),
|
||||
("ANTHROPIC_API_KEY", "anthropic", None, None),
|
||||
("OPENROUTER_API_KEY", "openrouter", None, None),
|
||||
("GROQ_API_KEY", "groq", None, None),
|
||||
("PERPLEXITY_API_KEY", "perplexity", None, None),
|
||||
("FIREWORKS_API_KEY", "fireworks", None, None),
|
||||
("XAI_API_KEY", "xai", None, None),
|
||||
]
|
||||
|
||||
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if openrouter_api_key:
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openrouter",
|
||||
base_url=base_url,
|
||||
api_key=openrouter_api_key,
|
||||
enabled=True,
|
||||
for env_key, provider_type, _, _ in env_mappings:
|
||||
api_key = os.environ.get(env_key)
|
||||
if api_key and provider_type in provider_classes_by_type:
|
||||
provider_class = provider_classes_by_type[provider_type]
|
||||
if provider_class.default_base_url: # type: ignore[attr-defined]
|
||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type=provider_type,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||
if ollama_base_url:
|
||||
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == ollama_base_url
|
||||
UpstreamProviderRow.base_url == ollama_base_url,
|
||||
UpstreamProviderRow.api_key == ollama_api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -334,18 +290,20 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="ollama",
|
||||
base_url=ollama_base_url,
|
||||
api_key=os.environ.get("OLLAMA_API_KEY", ""),
|
||||
api_key=ollama_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(ollama_base_url)
|
||||
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
|
||||
|
||||
if settings.chat_completions_api_version and settings.upstream_base_url:
|
||||
base_url = settings.upstream_base_url
|
||||
if base_url not in seeded_base_urls:
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
@@ -353,69 +311,45 @@ async def _seed_providers_from_settings(
|
||||
UpstreamProviderRow(
|
||||
provider_type="azure",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
api_key=api_key,
|
||||
api_version=settings.chat_completions_api_version,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
if settings.upstream_base_url and settings.upstream_api_key:
|
||||
base_url = settings.upstream_base_url
|
||||
if base_url not in seeded_base_urls:
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
if "api.openai.com" in base_url.lower():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openai",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
elif "api.anthropic.com" in base_url.lower():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="anthropic",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
elif "openrouter.ai/api/v1" in base_url.lower():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="openrouter",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=settings.upstream_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_base_urls.add(base_url)
|
||||
)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
for provider in providers_to_add:
|
||||
session.add(provider)
|
||||
logger.info(
|
||||
f"Seeding {provider.provider_type} provider",
|
||||
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
||||
extra={"base_url": provider.base_url},
|
||||
)
|
||||
|
||||
|
||||
def _instantiate_provider(provider_row: UpstreamProviderRow) -> UpstreamProvider | None:
|
||||
def _instantiate_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
"""Instantiate an UpstreamProvider from a database row.
|
||||
|
||||
Args:
|
||||
@@ -424,53 +358,35 @@ def _instantiate_provider(provider_row: UpstreamProviderRow) -> UpstreamProvider
|
||||
Returns:
|
||||
Instantiated provider or None if provider type is unknown
|
||||
"""
|
||||
from . import upstream_provider_classes
|
||||
|
||||
try:
|
||||
if provider_row.provider_type == "openai":
|
||||
return OpenAIUpstreamProvider(
|
||||
provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "anthropic":
|
||||
return AnthropicUpstreamProvider(
|
||||
provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "azure":
|
||||
if not provider_row.api_version:
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
for cls in upstream_provider_classes # type: ignore[attr-defined]
|
||||
}
|
||||
|
||||
provider_class = provider_classes_by_type.get(provider_row.provider_type)
|
||||
|
||||
if provider_class:
|
||||
provider = provider_class.from_db_row(provider_row) # type: ignore[attr-defined]
|
||||
if provider is None:
|
||||
logger.error(
|
||||
"Azure provider missing api_version",
|
||||
f"Failed to instantiate {provider_row.provider_type} provider",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
return AzureUpstreamProvider(
|
||||
provider_row.base_url,
|
||||
provider_row.api_key,
|
||||
provider_row.api_version,
|
||||
provider_row.provider_fee,
|
||||
)
|
||||
elif provider_row.provider_type == "openrouter":
|
||||
return OpenRouterUpstreamProvider(
|
||||
provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "ollama":
|
||||
return OllamaUpstreamProvider(
|
||||
return provider
|
||||
|
||||
if provider_row.provider_type == "custom":
|
||||
return BaseUpstreamProvider(
|
||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
elif provider_row.provider_type == "generic":
|
||||
return GenericUpstreamProvider(
|
||||
provider_row.base_url,
|
||||
provider_row.api_key,
|
||||
provider_row.provider_fee,
|
||||
provider_row.provider_type,
|
||||
)
|
||||
elif provider_row.provider_type == "custom":
|
||||
return UpstreamProvider(
|
||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Unknown provider type: {provider_row.provider_type}",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
|
||||
logger.error(
|
||||
f"Unknown provider type: {provider_row.provider_type}",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to instantiate provider: {e}",
|
||||
@@ -6,10 +6,10 @@ import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .upstream import UpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -17,9 +17,13 @@ from ..core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OllamaUpstreamProvider(UpstreamProvider):
|
||||
class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Ollama API."""
|
||||
|
||||
provider_type = "ollama"
|
||||
default_base_url = "http://localhost:11434"
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:11434",
|
||||
@@ -33,13 +37,32 @@ class OllamaUpstreamProvider(UpstreamProvider):
|
||||
api_key: Optional API key (Ollama typically doesn't require one)
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
"""
|
||||
self.upstream_name = "ollama"
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OllamaUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Ollama",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'ollama/' prefix for Ollama API compatibility."""
|
||||
return model_id.removeprefix("ollama/")
|
||||
@@ -121,7 +144,7 @@ class OllamaUpstreamProvider(UpstreamProvider):
|
||||
models_list.append(
|
||||
Model(
|
||||
id=model_name,
|
||||
name=model_name,
|
||||
name=model_name.replace(":", " "),
|
||||
created=0,
|
||||
description=description,
|
||||
context_length=context_length,
|
||||
@@ -192,12 +215,12 @@ class OllamaUpstreamProvider(UpstreamProvider):
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
logger.info(
|
||||
f"Refreshed models cache for {self.upstream_name or self.base_url}",
|
||||
f"Refreshed models cache for {self.base_url}",
|
||||
extra={"model_count": len(models)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to refresh models cache for {self.upstream_name or self.base_url}",
|
||||
f"Failed to refresh models cache for {self.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class OpenAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for OpenAI API."""
|
||||
|
||||
provider_type = "openai"
|
||||
default_base_url = "https://api.openai.com/v1"
|
||||
platform_url = "https://platform.openai.com/api-keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OpenAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "OpenAI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'openai/' prefix for OpenAI API compatibility."""
|
||||
return model_id.removeprefix("openai/")
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch OpenAI models from OpenRouter API filtered by openai source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="openai")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for OpenRouter API."""
|
||||
|
||||
provider_type = "openrouter"
|
||||
default_base_url = "https://openrouter.ai/api/v1"
|
||||
platform_url = "https://openrouter.ai/settings/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.06):
|
||||
"""Initialize OpenRouter provider with API key.
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API key for authentication
|
||||
provider_fee: Provider fee multiplier (default 1.06 for 6% fee)
|
||||
"""
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OpenRouterUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "OpenRouter",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch all OpenRouter models."""
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Float representing the balance amount (in credits/USD), or None if unavailable.
|
||||
"""
|
||||
url = f"{self.base_url}/credits"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
credits_data = data.get("data", {})
|
||||
total_credits = float(credits_data.get("total_credits", 0.0))
|
||||
total_usage = float(credits_data.get("total_usage", 0.0))
|
||||
|
||||
return total_credits - total_usage
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class PerplexityUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Perplexity API."""
|
||||
|
||||
provider_type = "perplexity"
|
||||
default_base_url = "https://api.perplexity.ai/"
|
||||
platform_url = "https://www.perplexity.ai/account/api/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PerplexityUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Perplexity",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'perplexity/' prefix for Perplexity API compatibility."""
|
||||
return model_id.removeprefix("perplexity/")
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch Perplexity models from OpenRouter API filtered by perplexity source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="perplexity")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
@@ -0,0 +1,432 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider, TopupData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PPQAIModelPricing(BaseModel):
|
||||
ui: dict[str, float]
|
||||
api: dict[str, float]
|
||||
|
||||
|
||||
class PPQAIModel(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
name: str
|
||||
created_at: int
|
||||
context_length: int
|
||||
pricing: PPQAIModelPricing
|
||||
popular: bool
|
||||
|
||||
|
||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
|
||||
|
||||
provider_type = "ppqai"
|
||||
default_base_url = "https://api.ppq.ai"
|
||||
platform_url = "https://ppq.ai/api-docs"
|
||||
IGNORED_MODEL_IDS: list[str] = ["auto"]
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PPQAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "PPQ.AI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": True,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account without requiring an instance.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{cls.default_base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from PPQ.AI API."""
|
||||
url = f"{self.base_url}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data = data.get("data", [])
|
||||
|
||||
or_models = [
|
||||
Model(**model) # type: ignore
|
||||
for model in await async_fetch_openrouter_models()
|
||||
]
|
||||
|
||||
models = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
ppqai_model = PPQAIModel.parse_obj(model_data)
|
||||
if ppqai_model.id in self.IGNORED_MODEL_IDS:
|
||||
continue
|
||||
|
||||
or_model = next(
|
||||
(
|
||||
model
|
||||
for model in or_models
|
||||
if (model.id == ppqai_model.id)
|
||||
or (model.id.split("/")[-1] == ppqai_model.id)
|
||||
or (model.id == ppqai_model.id.split("/")[-1])
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if or_model:
|
||||
if input_price := ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
):
|
||||
or_model.pricing.prompt = input_price / 1_000_000
|
||||
if output_price := ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
):
|
||||
or_model.pricing.completion = output_price / 1_000_000
|
||||
if cl := ppqai_model.context_length:
|
||||
or_model.context_length = cl
|
||||
models.append(or_model)
|
||||
else:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=ppqai_model.id,
|
||||
name=ppqai_model.name,
|
||||
created=ppqai_model.created_at // 1000,
|
||||
description=f"{ppqai_model.provider} model",
|
||||
context_length=ppqai_model.context_length,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=input_price / 1_000_000,
|
||||
completion=output_price / 1_000_000,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse PPQ.AI model",
|
||||
extra={
|
||||
"model_id": model_data.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from PPQ.AI",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
async def on_upstream_error_redirect(
|
||||
self, status_code: int, error_message: str
|
||||
) -> None:
|
||||
if "insufficient balance" in error_message.lower():
|
||||
logger.warning(
|
||||
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||
extra={"error": error_message},
|
||||
)
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
|
||||
async with create_session() as session:
|
||||
statement = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key,
|
||||
)
|
||||
result = await session.exec(statement)
|
||||
provider = result.first()
|
||||
|
||||
if provider:
|
||||
provider.enabled = False
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
|
||||
# Trigger re-initialization of providers
|
||||
# Import here to avoid circular dependency
|
||||
from ..proxy import reinitialize_upstreams
|
||||
|
||||
await reinitialize_upstreams()
|
||||
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def create_lightning_topup(
|
||||
self, amount: int, currency: str
|
||||
) -> dict[str, object]:
|
||||
"""Create a Lightning Network top-up invoice for this account.
|
||||
|
||||
Args:
|
||||
amount: Amount to top up (in the specified currency)
|
||||
currency: Currency for the top-up (default: "USD")
|
||||
|
||||
Returns:
|
||||
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/create/btc-lightning"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"amount": amount, "currency": currency}
|
||||
|
||||
logger.info(
|
||||
"Creating Lightning top-up invoice",
|
||||
extra={"url": url, "amount": amount, "currency": currency},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created Lightning top-up invoice",
|
||||
extra={
|
||||
"invoice_id": invoice_data.get("invoice_id"),
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
},
|
||||
)
|
||||
|
||||
return invoice_data
|
||||
|
||||
async def check_topup_status(self, invoice_id: str) -> bool:
|
||||
"""Check the status of a Lightning top-up invoice.
|
||||
|
||||
Args:
|
||||
invoice_id: The invoice ID to check
|
||||
|
||||
Returns:
|
||||
True if the invoice is paid (status == "Settled"), False otherwise
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/status/{invoice_id}"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Checking Lightning top-up status",
|
||||
extra={"url": url, "invoice_id": invoice_id},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
status_data = response.json()
|
||||
|
||||
is_paid = status_data.get("status") == "Settled"
|
||||
|
||||
logger.debug(
|
||||
"Retrieved Lightning top-up status",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"status": status_data.get("status"),
|
||||
"is_paid": is_paid,
|
||||
},
|
||||
)
|
||||
|
||||
return is_paid
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the PPQ.AI account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
ppq_response = await self.create_lightning_topup(amount, "USD")
|
||||
|
||||
logger.info(
|
||||
"PPQ.AI top-up response",
|
||||
extra={
|
||||
"ppq_response": ppq_response,
|
||||
"invoice_id": ppq_response.get("invoice_id"),
|
||||
"has_lightning_invoice": "lightning_invoice" in ppq_response,
|
||||
},
|
||||
)
|
||||
|
||||
expires_at_value = ppq_response.get("expires_at")
|
||||
checkout_url_value = ppq_response.get("checkout_url")
|
||||
|
||||
topup_data = TopupData(
|
||||
invoice_id=str(ppq_response["invoice_id"]),
|
||||
payment_request=str(ppq_response["lightning_invoice"]),
|
||||
amount=int(ppq_response["amount"])
|
||||
if isinstance(ppq_response["amount"], (int, float, str))
|
||||
else 0,
|
||||
currency=str(ppq_response["currency"]),
|
||||
expires_at=int(expires_at_value)
|
||||
if isinstance(expires_at_value, (int, float, str))
|
||||
and expires_at_value is not None
|
||||
else None,
|
||||
checkout_url=str(checkout_url_value)
|
||||
if checkout_url_value is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created TopupData",
|
||||
extra={
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request_length": len(topup_data.payment_request),
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
return topup_data
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from PPQ.AI.
|
||||
|
||||
Returns:
|
||||
Float representing the balance amount (in USD), or None if unavailable.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
data = await self.check_balance()
|
||||
balance = data.get("balance")
|
||||
if isinstance(balance, (int, float)):
|
||||
return float(balance)
|
||||
return None
|
||||
|
||||
async def check_balance(self) -> dict[str, object]:
|
||||
"""Check the account balance for this PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/credits/balance"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=headers, json={})
|
||||
response.raise_for_status()
|
||||
balance_data = response.json()
|
||||
|
||||
logger.debug(
|
||||
"Retrieved PPQ.AI account balance",
|
||||
extra={"balance": balance_data.get("balance")},
|
||||
)
|
||||
|
||||
return balance_data
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class XAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for XAI API."""
|
||||
|
||||
provider_type = "x-ai"
|
||||
default_base_url = "https://api.x.ai/v1"
|
||||
platform_url = "https://console.x.ai/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "xAI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'xai/' prefix for XAI API compatibility."""
|
||||
return model_id.removeprefix("x-ai/")
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch XAI models from OpenRouter API filtered by xai source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="x-ai")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
@@ -1,17 +0,0 @@
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .upstream import (
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
UpstreamProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OllamaUpstreamProvider",
|
||||
"UpstreamProvider",
|
||||
"AnthropicUpstreamProvider",
|
||||
"AzureUpstreamProvider",
|
||||
"OpenAIUpstreamProvider",
|
||||
"OpenRouterUpstreamProvider",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user