Compare commits

...
60 Commits
Author SHA1 Message Date
9qeklajc ec80f39e64 Merge branch 'fix-x-cashu-misscalculation' into refresh-models
# Conflicts:
#	routstr/core/admin.py
2025-10-15 22:10:42 +02:00
9qeklajc 4327f542a0 test 2025-10-15 20:23:00 +02:00
9qeklajc b4eac33542 clean up 2025-10-14 00:16:16 +02:00
9qeklajc 9911ce9dcd fix different max cost calculation 2025-10-14 00:02:04 +02:00
Shroominic c56a06f3df fix tests 2025-10-04 20:49:53 +08:00
Shroominic 325abad736 ruff fmt 2025-10-04 20:49:45 +08:00
Shroominic f8090f5c35 dontations endpoint 2025-10-02 14:55:02 +08:00
shroominicandGitHub b2e5da4b46 Merge pull request #180 from Routstr/sh1ftred-patch-1
Fixed the bug where refund amount is below 1 sat for a sat mint
2025-09-23 12:40:43 +01:00
redshiftandGitHub 729f00caa2 Fixed the bug where refund amount is below 1 sat for a sat mint 2025-09-22 16:55:16 +00:00
Shroominic 54d7a5a247 fix periodic payouts 2025-09-22 17:54:28 +01:00
redshiftandGitHub ecbe3158c0 Merge pull request #179 from Routstr/503-mint-service-bug
Fixed the 503 mint service unavailable bug
2025-09-22 16:27:36 +00:00
redshiftandGitHub bac50f2150 Fixed the 503 mint service unavailable bug
This flag fetch all keysets from the db into the wallet, which is wrong given that we're initiating it for a mint. And load_mint sets the first keyset from all the keysets from the db, irrespective of the unit and the mint. 

Fixes #175
2025-09-22 16:25:02 +00:00
Shroominic 150f3084c1 remove need for ADMIN_PASSWORD, added in initial setup 2025-09-22 16:40:48 +01:00
Shroominic d4e1715613 improved upstream error message handling 2025-09-22 16:36:52 +01:00
Shroominic c2a26a3c00 v1/providers redirect to v1/providers/ 2025-09-22 16:36:35 +01:00
Shroominic 8e0ae6cbc8 filter out openrouter spam models 2025-09-22 13:46:07 +01:00
Shroominic 18a055ee2c add models.json batch paste 2025-09-22 12:58:53 +01:00
Shroominic c5289364e4 models admin dashboard 2025-09-22 12:47:39 +01:00
Shroominic 5fb583be55 v0.1.4-dev 2025-09-22 11:11:36 +01:00
shroominicandGitHub 4bf02226dc Merge pull request #172 from Routstr/fix-max-tokens-string-int
fix max-tokens coming in as string
2025-09-16 13:05:39 +01:00
Shroominic c8f4e7d3e5 fix max-tokens coming in as string 2025-09-16 13:00:38 +01:00
shroominicandGitHub 3d4a8e0e14 Merge pull request #169 from Routstr/dev
## Routstr v0.1.3 — 2025-09-15

### Highlights

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

### Breaking changes

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

### Added

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

### Changed / Improvements

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

### Fixed

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

### Upgrade notes (from v0.1.2)

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

### References

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

### Stats and credits

- 42 files changed, +1819/−832 lines.
- Merges: #170 token-discount-max-cost, #171 move-models-to-db.
- Contributors: Shroominic.
2025-09-16 12:43:54 +01:00
Shroominic 8b9d53be3c fix deps issue 2025-09-15 18:04:35 +01:00
Shroominic 11a01dae89 fix pytests 2025-09-15 18:01:01 +01:00
Shroominic 9ab269dd8f ⬆️ v0.1.3 2025-09-15 17:55:20 +01:00
Shroominic c263d38732 add reserved balance info 2025-09-15 17:38:51 +01:00
Shroominic c4f84cd1e6 Merge remote-tracking branch 'refs/remotes/origin/dev' into dev 2025-09-11 13:40:36 +01:00
shroominicandGitHub 0478b1c360 Merge pull request #171 from Routstr/move-models-to-db
Move models to db
2025-09-11 13:40:04 +01:00
Shroominic 633ae39622 optimize startup time and announcement fetching 2025-09-11 13:39:29 +01:00
Shroominic 84ff9f5dc3 update default relays 2025-09-11 13:38:04 +01:00
Shroominic 88ec3395c7 fix: missing mints balances in admin dashboard 2025-09-11 12:51:03 +01:00
Shroominic 49485435ad fix admin log search 2025-09-11 12:46:06 +01:00
Shroominic d80df28b23 rm not needed setting 2025-09-11 12:42:23 +01:00
Shroominic 4cdbf23ec0 fixes 2025-09-10 13:28:48 +01:00
Shroominic 73d3613301 move MODELS to DB 2025-09-10 13:28:43 +01:00
shroominicandGitHub 6993e6b156 Merge pull request #170 from Routstr/token-discount-max-cost
max cost discount
2025-09-09 18:30:14 +01:00
Shroominic 6d1525748d fix tests 2025-09-09 18:27:48 +01:00
Shroominic 57ac38e6dc fix test patches 2025-09-09 14:27:25 +01:00
Shroominic 2e42af61bb rm tolerance percentage args 2025-09-09 14:00:39 +01:00
Shroominic 7dfc2f4056 max cost discount 2025-09-09 13:58:52 +01:00
Shroominic 2b045c95c7 dev version indicator 2025-09-08 14:52:52 +01:00
Shroominic 665b3f9a16 lazy load db information 2025-09-08 13:43:17 +01:00
Shroominic dcbaf7413a edit settings over admin dashboard 2025-09-08 12:57:29 +01:00
Shroominic 918a083a12 cleanup admin auth 2025-09-08 12:48:27 +01:00
Shroominic 7c9265b40d feat(settings): derive NPUB from NSEC during bootstrap (similar to ONION discovery) 2025-09-08 12:29:41 +01:00
Shroominic 44030ddbe5 docs: simplify discovery config (RELAYS only); drop OpenRouter BASE_URL mentions 2025-09-08 12:26:22 +01:00
Shroominic be372c48ea refactor(settings): remove NIP-91 fields; keep relays under discovery; drop openrouter_base_url 2025-09-08 12:24:32 +01:00
Shroominic 939a991d6b chore: update version/readme; minor auth logging and settings usage 2025-09-08 10:53:41 +01:00
Shroominic bb9991e7a5 docs: update configuration/pricing docs and compose with FIXED_* vars 2025-09-08 10:53:32 +01:00
Shroominic 496900baa6 test: update tests to patch settings and use fixed pricing flags 2025-09-08 10:53:23 +01:00
Shroominic 8f22826d63 fix(balance): correct sat refund conversion from msats and use settings for TTL 2025-09-08 10:53:11 +01:00
Shroominic 15e61d1760 refactor(proxy): use settings for upstream base; integrate new pricing flow and header prep 2025-09-08 10:53:02 +01:00
Shroominic a4f28db887 refactor(wallet): use settings for cashu mints, primary mint, and payout address 2025-09-08 10:52:54 +01:00
Shroominic db2c6eb98a refactor(payment): switch to settings-based pricing and upstream config; support fixed vs model pricing 2025-09-08 10:52:47 +01:00
Shroominic 8286f4f0cc refactor(discovery,nip91): read relays and config from settings; default Tor proxy; cleanup 2025-09-08 10:52:39 +01:00
Shroominic 7761d144f6 feat(core): initialize and use SettingsService; update admin settings API; hook app metadata from settings 2025-09-08 10:52:30 +01:00
Shroominic 14718843de feat(settings): add DB-backed Settings and SettingsService with env merge 2025-09-08 10:52:21 +01:00
Shroominic a28277a0a8 rm 2025-09-06 14:57:12 +01:00
Shroominic d2b8a4e78b update env example 2025-09-06 14:57:09 +01:00
Shroominic ac24f10cb0 cleanup compose 2025-09-06 14:50:55 +01:00
44 changed files with 3137 additions and 878 deletions
+26 -30
View File
@@ -1,43 +1,39 @@
# Core Configuration
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=your-upstream-api-key
ADMIN_PASSWORD=secure-admin-password
# ADMIN_PASSWORD=secure-admin-password
# Database
DATABASE_URL=sqlite+aiosqlite:///keys.db
# DATABASE_URL=sqlite+aiosqlite:///keys.db
# Node Information
NAME=My Routstr Node
DESCRIPTION=Fast AI API access with Bitcoin payments
NPUB=npub1...
HTTP_URL=https://api.mynode.com
ONION_URL=http://mynode.onion
# NAME=My Routstr Node
# DESCRIPTION=Fast AI API access with Bitcoin payments
# NSEC=nsec1...
# HTTP_URL=https://api.mynode.com
# ONION_URL=http://mynode.onion (auto fetched from compose)
# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com"
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
# RECEIVE_LN_ADDRESS=
# Cashu Configuration
CASHU_MINTS=https://mint.minibits.cash/Bitcoin
RECEIVE_LN_ADDRESS=
# Pricing Configuration
MODEL_BASED_PRICING=true
COST_PER_REQUEST=1
COST_PER_1K_INPUT_TOKENS=0
COST_PER_1K_OUTPUT_TOKENS=0
EXCHANGE_FEE=1.005
UPSTREAM_PROVIDER_FEE=1.05
# Custom Pricing Configuration
# MODEL_BASED_PRICING=true
# COST_PER_REQUEST=1
# COST_PER_1K_INPUT_TOKENS=0
# COST_PER_1K_OUTPUT_TOKENS=0
# EXCHANGE_FEE=1.005
# UPSTREAM_PROVIDER_FEE=1.05
# Network Configuration
CORS_ORIGINS=*
TOR_PROXY_URL=socks5://127.0.0.1:9050
# CORS_ORIGINS=*
# TOR_PROXY_URL=socks5://127.0.0.1:9050
# Logging
LOG_LEVEL=INFO
ENABLE_CONSOLE_LOGGING=true
# LOG_LEVEL=INFO
# ENABLE_CONSOLE_LOGGING=true
# Model Management
MODELS_PATH=models.json
BASE_URL=https://openrouter.ai/api/v1
SOURCE=
# Optional Features
PREPAID_API_KEY=
PREPAID_BALANCE=0
# Custom Model Management
# BASE_URL=https://openrouter.ai/api/v1
# MODELS_PATH=models.json
# SOURCE=
+1 -1
View File
@@ -91,7 +91,7 @@ The most common settings are shown below. See `.env.example` for the full list.
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `MODEL_BASED_PRICING` Set to `true` to use pricing from `models.json`
- `FIXED_PRICING` Set to `true` to use a fixed per-request price; `false` (default) uses model pricing from `models.json`
- `ADMIN_PASSWORD` Password for the `/admin/` dashboard
- `CASHU_MINTS` Comma-separated list of Cashu mint URLs
- `NAME` Name of the proxy
+4 -4
View File
@@ -19,10 +19,10 @@ services:
- "ONION_URL=http://test.onion"
- "CORS_ORIGINS=*"
- "RECEIVE_LN_ADDRESS=test@routstr.com"
- "COST_PER_REQUEST=10"
- "COST_PER_1K_INPUT_TOKENS=0"
- "COST_PER_1K_OUTPUT_TOKENS=0"
- "MODEL_BASED_PRICING=true"
- "FIXED_COST_PER_REQUEST=10"
- "FIXED_PER_1K_INPUT_TOKENS=0"
- "FIXED_PER_1K_OUTPUT_TOKENS=0"
- "FIXED_PRICING=false"
- "NSEC=nsec1testkey1234567890abcdef"
- "REFUND_PROCESSING_INTERVAL=3600"
- "MINIMUM_PAYOUT=1000"
-9
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
routstr:
build: .
@@ -26,12 +24,5 @@ services:
depends_on:
- routstr
# Legacy service definition to ensure cleanup of old container
router:
image: alpine:latest
command: /bin/true
profiles:
- cleanup
volumes:
tor-data:
+7 -7
View File
@@ -14,11 +14,11 @@ Routstr supports three pricing models:
### Configuration
Enable model-based pricing:
Enable model-based pricing (default behavior):
```bash
# .env
MODEL_BASED_PRICING=true
FIXED_PRICING=false
MODELS_PATH=/app/config/models.json
EXCHANGE_FEE=1.005 # 0.5% exchange fee
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider margin
@@ -118,14 +118,14 @@ if __name__ == "__main__":
### Configuration
Set up token-based pricing:
Set up token-based pricing overrides:
```bash
# .env
MODEL_BASED_PRICING=false
COST_PER_REQUEST=1 # 1 sat base fee
COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input
COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output
FIXED_PRICING=false # use model pricing
FIXED_COST_PER_REQUEST=1 # optional base fee
FIXED_PER_1K_INPUT_TOKENS=5 # optional override
FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override
```
### Custom Token Counting
+1 -1
View File
@@ -102,7 +102,7 @@ Configure which relays to publish to:
DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nostr.mom",
"wss://relay.routstr.com",
"wss://nos.lol"
]
+21
View File
@@ -436,6 +436,27 @@ Authorization: Bearer sk-...
## Provider Discovery
## Admin Settings
These endpoints are protected by the Admin cookie (`admin_password` set to your configured admin password).
### Get Settings
```http
GET /admin/api/settings
```
Returns the current application settings (sensitive values may be redacted).
### Update Settings
```http
PATCH /admin/api/settings
Content-Type: application/json
```
Body is a partial JSON of settings fields to update. Validated and persisted to the database.
### List Providers
Get available upstream providers.
+1 -1
View File
@@ -347,7 +347,7 @@ GET /health
Response:
{
"status": "healthy",
"version": "0.1.2",
"version": "0.1.4",
"timestamp": "2024-01-01T00:00:00Z",
"checks": {
"database": "ok",
+1 -1
View File
@@ -348,7 +348,7 @@ Project metadata and dependencies:
```toml
[project]
name = "routstr"
version = "0.1.2"
version = "0.1.4"
dependencies = [
"fastapi[standard]>=0.115",
"sqlmodel>=0.0.24",
+20 -33
View File
@@ -1,6 +1,6 @@
# Configuration
Routstr Core is configured through environment variables. This guide covers all available options.
Routstr Core is configured via a single settings row in the database. Environment variables are only used on first run to seed that row (with a few computed defaults like `ONION_URL`). After that, the database is the source of truth. You can update settings at runtime via the admin API. `DATABASE_URL` is always env-only.
## Environment Variables
@@ -33,19 +33,21 @@ Routstr Core is configured through environment variables. This guide covers all
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `MODEL_BASED_PRICING` | Enable model-specific pricing from models.json | `false` | ❌ |
| `COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ |
| `COST_PER_1K_INPUT_TOKENS` | Cost per 1000 input tokens in sats | `0` | ❌ |
| `COST_PER_1K_OUTPUT_TOKENS` | Cost per 1000 output tokens in sats | `0` | ❌ |
| `FIXED_PRICING` | Force fixed per-request pricing (ignore model token pricing) | `false` | ❌ |
| `FIXED_COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ |
| `FIXED_PER_1K_INPUT_TOKENS` | Optional override: sats per 1000 input tokens | `0` | ❌ |
| `FIXED_PER_1K_OUTPUT_TOKENS` | Optional override: sats per 1000 output tokens | `0` | ❌ |
| `EXCHANGE_FEE` | Exchange rate markup (1.005 = 0.5% fee) | `1.005` | ❌ |
| `UPSTREAM_PROVIDER_FEE` | Provider fee markup (1.05 = 5% fee) | `1.05` | ❌ |
### Network Configuration
### Network & Discovery
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | `*` | ❌ |
| `TOR_PROXY_URL` | SOCKS5 proxy URL for Tor connections | `socks5://127.0.0.1:9050` | ❌ |
| `RELAYS` | Comma-separated nostr relays used for provider discovery | sane defaults | ❌ |
| `PROVIDERS_REFRESH_INTERVAL_SECONDS` | Provider cache refresh interval | `300` | ❌ |
### Logging Configuration
@@ -60,6 +62,7 @@ Routstr Core is configured through environment variables. This guide covers all
|----------|-------------|---------|----------|
| `CHAT_COMPLETIONS_API_VERSION` | Append `api-version` to `/chat/completions` (Azure OpenAI) | - | ❌ |
| `DATABASE_URL` | SQLite database connection string | `sqlite+aiosqlite:///keys.db` | ❌ |
| `REFUND_CACHE_TTL_SECONDS` | Cache TTL for refund responses (seconds) | `3600` | ❌ |
## Configuration Examples
@@ -78,7 +81,6 @@ ADMIN_PASSWORD=my-secure-password
# .env
UPSTREAM_BASE_URL=https://api.anthropic.com/v1
UPSTREAM_API_KEY=your-anthropic-key
MODEL_BASED_PRICING=true
MODELS_PATH=/app/config/anthropic-models.json
```
@@ -115,36 +117,21 @@ ONION_URL=http://lightningai.onion
CASHU_MINTS=https://mint1.com,https://mint2.com
```
## Pricing Models
## Pricing
### Fixed Pricing
- Default: pricing comes from your `models.json`.
- Force fixed per-request pricing: set `FIXED_PRICING=true` and `FIXED_COST_PER_REQUEST`.
- Optional token overrides when using model pricing: set
`FIXED_PER_1K_INPUT_TOKENS` and/or `FIXED_PER_1K_OUTPUT_TOKENS`.
- Legacy envs are still accepted and mapped automatically:
`MODEL_BASED_PRICING``!FIXED_PRICING`, `COST_PER_REQUEST``FIXED_COST_PER_REQUEST`,
`COST_PER_1K_*``FIXED_PER_1K_*`.
Simple per-request pricing:
Example fixed pricing:
```bash
MODEL_BASED_PRICING=false
COST_PER_REQUEST=10 # 10 sats per request
```
### Token-Based Pricing
Charge based on token usage:
```bash
MODEL_BASED_PRICING=false
COST_PER_REQUEST=1 # 1 sat base fee
COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1k input
COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1k output
```
### Model-Based Pricing
Use dynamic pricing from models.json:
```bash
MODEL_BASED_PRICING=true
EXCHANGE_FEE=1.01 # 1% exchange fee
UPSTREAM_PROVIDER_FEE=1.00 # No additional markup
FIXED_PRICING=true
FIXED_COST_PER_REQUEST=10
```
## Custom Models Configuration
+2 -2
View File
@@ -115,8 +115,8 @@ NPUB=npub1...
HTTP_URL=https://api.mynode.com
ONION_URL=http://mynode.onion
# Pricing
MODEL_BASED_PRICING=true
# Pricing (optional)
FIXED_PRICING=false
EXCHANGE_FEE=1.005
UPSTREAM_PROVIDER_FEE=1.05
```
+1 -1
View File
@@ -67,7 +67,7 @@ You should see:
{
"name": "ARoutstrNode",
"description": "A Routstr Node",
"version": "0.1.2",
"version": "0.1.4",
"npub": "",
"mints": ["https://mint.minibits.cash/Bitcoin"],
"models": {...}
+7 -7
View File
@@ -11,8 +11,8 @@ Routstr supports three pricing models:
Simple per-request charging:
```bash
MODEL_BASED_PRICING=false
COST_PER_REQUEST=10 # 10 sats per request
FIXED_PRICING=true
FIXED_COST_PER_REQUEST=10 # 10 sats per request
```
**Best for:**
@@ -26,10 +26,10 @@ COST_PER_REQUEST=10 # 10 sats per request
Charge based on actual token usage:
```bash
MODEL_BASED_PRICING=false
COST_PER_REQUEST=1 # 1 sat base fee
COST_PER_1K_INPUT_TOKENS=5 # 5 sats per 1K input
COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output
FIXED_PRICING=false # use model pricing
FIXED_COST_PER_REQUEST=1 # optional base fee
FIXED_PER_1K_INPUT_TOKENS=5 # optional override
FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override
```
**Best for:**
@@ -43,7 +43,7 @@ COST_PER_1K_OUTPUT_TOKENS=15 # 15 sats per 1K output
Dynamic pricing based on model costs:
```bash
MODEL_BASED_PRICING=true
FIXED_PRICING=false
EXCHANGE_FEE=1.005 # 0.5% exchange fee
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee
```
@@ -0,0 +1,35 @@
"""add settings table
Revision ID: a1b2c3d4e5f6
Revises: 042f6b77d69d
Create Date: 2025-09-06 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "042f6b77d69d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"settings",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("data", sa.Text(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=True,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
def downgrade() -> None:
op.drop_table("settings")
@@ -0,0 +1,37 @@
"""create models table
Revision ID: c0ffee123456
Revises: a1b2c3d4e5f6
Create Date: 2025-09-10 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c0ffee123456"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"models",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created", sa.Integer(), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("context_length", sa.Integer(), nullable=False),
sa.Column("architecture", sa.Text(), nullable=False),
sa.Column("pricing", sa.Text(), nullable=False),
sa.Column("sats_pricing", sa.Text(), nullable=True),
sa.Column("per_request_limits", sa.Text(), nullable=True),
sa.Column("top_provider", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_table("models")
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.1.2"
version = "0.1.4"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -18,6 +18,7 @@ dependencies = [
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
"mdurl==0.1.2",
]
[dependency-groups]
-4
View File
@@ -1,7 +1,3 @@
import dotenv
dotenv.load_dotenv()
from .core.main import app as fastapi_app # noqa
__all__ = ["fastapi_app"]
+12 -9
View File
@@ -7,18 +7,14 @@ from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.settings import settings
from .payment.cost_caculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from .wallet import (
PRIMARY_MINT_URL,
TRUSTED_MINTS,
credit_balance,
deserialize_token_from_string,
)
from .wallet import credit_balance, deserialize_token_from_string
logger = get_logger(__name__)
@@ -165,12 +161,12 @@ async def validate_bearer_key(
"has_expiry_time": bool(key_expiry_time),
},
)
if token_obj.mint in TRUSTED_MINTS:
if token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_currency = "sat"
refund_mint_url = PRIMARY_MINT_URL
refund_mint_url = settings.primary_mint
new_key = ApiKey(
hashed_key=hashed_key,
@@ -426,7 +422,7 @@ async def adjust_payment_for_tokens(
},
)
match calculate_cost(response_data, deducted_max_cost):
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
@@ -637,3 +633,10 @@ async def adjust_payment_for_tokens(
}
},
)
# Fallback return to satisfy type checker; execution should not reach here
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}
+33 -13
View File
@@ -1,6 +1,5 @@
import asyncio
import hashlib
import os
from time import monotonic
from typing import Annotated, NoReturn
@@ -9,11 +8,15 @@ from pydantic import BaseModel
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .wallet import PRIMARY_MINT_URL, credit_balance, send_to_lnurl, send_token
from .core.logging import get_logger
from .core.settings import settings
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
router = APIRouter()
balance_router = APIRouter(prefix="/v1/balance")
logger = get_logger(__name__)
async def get_key_from_header(
authorization: Annotated[str, Header(...)],
@@ -34,6 +37,7 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
}
@@ -65,6 +69,7 @@ async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
}
@@ -102,7 +107,7 @@ async def topup_wallet_endpoint(
return {"msats": amount_msats}
_REFUND_CACHE_TTL_SECONDS: int = int(os.environ.get("REFUND_CACHE_TTL_SECONDS", "3600"))
_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds
_refund_cache_lock: asyncio.Lock = asyncio.Lock()
_refund_cache: dict[str, tuple[float, dict[str, str]]] = {}
@@ -150,30 +155,32 @@ async def refund_wallet_endpoint(
key: ApiKey = await validate_bearer_key(bearer_value, session)
remaining_balance_msats: int = key.balance
if remaining_balance_msats <= 0:
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
else:
remaining_balance = remaining_balance_msats
if remaining_balance_msats > 0 and remaining_balance <= 0:
raise HTTPException(status_code=400, detail="Balance too small to refund")
elif remaining_balance <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
try:
if key.refund_address:
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats * 1000
from .core.settings import settings as global_settings
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
key.refund_mint_url or PRIMARY_MINT_URL,
key.refund_mint_url or global_settings.primary_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_amount = (
remaining_balance_msats // 1000
if key.refund_currency == "sat"
else remaining_balance_msats
)
refund_currency = key.refund_currency or "sat"
token = await send_token(
refund_amount, refund_currency, key.refund_mint_url
remaining_balance, refund_currency, key.refund_mint_url
)
result = {"token": token}
@@ -206,6 +213,19 @@ async def refund_wallet_endpoint(
return result
@router.post("/donate")
async def donate(token: str, ref: str | None = None) -> str:
try:
amount, unit, _ = await recieve_token(token)
if ref:
logger.info(
"donation received", extra={"ref": ref, "amount": amount, "unit": unit}
)
return "Thanks!"
except Exception:
return "Invalid token."
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
+1366 -325
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -52,6 +52,20 @@ class ApiKey(SQLModel, table=True): # type: ignore
return self.balance - self.reserved_balance
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
id: str = Field(primary_key=True)
name: str = Field()
created: int = Field()
description: str = Field()
context_length: int = Field()
architecture: str = Field()
pricing: str = Field()
sats_pricing: str | None = Field(default=None)
per_request_limits: str | None = Field(default=None)
top_provider: str | None = Field(default=None)
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
+16 -6
View File
@@ -181,7 +181,12 @@ class SecurityFilter(logging.Filter):
def get_log_level() -> str:
"""Get log level from environment variable."""
level = os.environ.get("LOG_LEVEL", "INFO").upper()
try:
from .settings import settings
level = settings.log_level.upper()
except Exception:
level = os.environ.get("LOG_LEVEL", "INFO").upper()
# Validate log level - if invalid, default to INFO
valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if level not in valid_levels:
@@ -191,11 +196,16 @@ def get_log_level() -> str:
def should_enable_console_logging() -> bool:
"""Check if console logging should be enabled."""
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
"true",
"1",
"yes",
)
try:
from .settings import settings
return bool(settings.enable_console_logging)
except Exception:
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
"true",
"1",
"yes",
)
def setup_logging() -> None:
+43 -20
View File
@@ -1,5 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@@ -11,20 +10,27 @@ from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..payment.models import (
ensure_models_bootstrapped,
models_router,
refresh_models_periodically,
update_sats_pricing,
)
from ..proxy import proxy_router
from ..wallet import periodic_payout
from .admin import admin_router
from .db import init_db, run_migrations
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
__version__ = "0.1.2"
__version__ = "0.1.4-dev"
@asynccontextmanager
@@ -35,6 +41,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
payout_task = None
nip91_task = None
providers_task = None
models_refresh_task = None
try:
# Run database migrations on startup
@@ -47,7 +54,20 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# This creates any tables that might not be tracked by migrations yet
await init_db()
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
# Apply app metadata from settings
try:
app.title = s.name
app.description = s.description
except Exception:
pass
await ensure_models_bootstrapped()
pricing_task = asyncio.create_task(update_sats_pricing())
models_refresh_task = asyncio.create_task(refresh_models_periodically())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
providers_task = asyncio.create_task(providers_cache_refresher())
@@ -71,6 +91,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
try:
tasks_to_wait = []
@@ -82,6 +104,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -93,18 +117,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
)
app = FastAPI(
version=__version__,
title=os.environ.get("NAME", "ARoutstrNode" + __version__),
description=os.environ.get("DESCRIPTION", "A Routstr Node"),
contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")},
lifespan=lifespan,
)
app = FastAPI(version=__version__, lifespan=lifespan)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
allow_origins=global_settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -123,14 +141,14 @@ app.add_exception_handler(Exception, general_exception_handler)
@app.get("/v1/info")
async def info() -> dict:
return {
"name": app.title,
"description": app.description,
"name": global_settings.name,
"description": global_settings.description,
"version": __version__,
"npub": os.environ.get("NPUB", ""),
"mints": os.environ.get("CASHU_MINTS", "").split(","),
"http_url": os.environ.get("HTTP_URL", ""),
"onion_url": os.environ.get("ONION_URL", ""),
"models": MODELS,
"npub": global_settings.npub,
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"models": [], # kept for back-compat; prefer /v1/models
}
@@ -139,6 +157,11 @@ async def admin_redirect() -> RedirectResponse:
return RedirectResponse("/admin/")
@app.get("/v1/providers")
async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
import asyncio
import json
import os
from datetime import datetime, timezone
from typing import Any
from pydantic.v1 import BaseModel, BaseSettings, Field
from sqlmodel.ext.asyncio.session import AsyncSession
class Settings(BaseSettings):
class Config:
case_sensitive = True
@classmethod
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
if field_name in {"cashu_mints", "cors_origins", "relays"}:
v = str(raw_value).strip()
if v == "":
return []
return [p.strip() for p in v.split(",") if p.strip()]
return raw_value
# Core
upstream_base_url: str = Field(default="", env="UPSTREAM_BASE_URL")
upstream_api_key: str = Field(default="", env="UPSTREAM_API_KEY")
admin_password: str = Field(default="", env="ADMIN_PASSWORD")
# Node info
name: str = Field(default="ARoutstrNode", env="NAME")
description: str = Field(default="A Routstr Node", env="DESCRIPTION")
npub: str = Field(default="", env="NPUB")
http_url: str = Field(default="", env="HTTP_URL")
onion_url: str = Field(default="", env="ONION_URL")
# Cashu
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
# Pricing
# Default behavior: derive pricing from MODELS
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
# If fixed_per_1k_* are set (non-zero) -> override model token pricing when model-based
fixed_pricing: bool = Field(default=False, env="FIXED_PRICING")
fixed_cost_per_request: int = Field(default=1, env="FIXED_COST_PER_REQUEST")
fixed_per_1k_input_tokens: int = Field(default=0, env="FIXED_PER_1K_INPUT_TOKENS")
fixed_per_1k_output_tokens: int = Field(default=0, env="FIXED_PER_1K_OUTPUT_TOKENS")
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field(
default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
)
pricing_refresh_interval_seconds: int = Field(
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
)
models_refresh_interval_seconds: int = Field(
default=30, env="MODELS_REFRESH_INTERVAL_SECONDS"
)
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
delete_removed_models: bool = Field(default=False, env="DELETE_REMOVED_MODELS")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
# Other
chat_completions_api_version: str = Field(
default="", env="CHAT_COMPLETIONS_API_VERSION"
)
models_path: str = Field(default="models.json", env="MODELS_PATH")
source: str = Field(default="", env="SOURCE")
# Secrets / optional runtime controls
provider_id: str = Field(default="", env="PROVIDER_ID")
nsec: str = Field(default="", env="NSEC")
# Discovery
relays: list[str] = Field(default_factory=list, env="RELAYS")
def _compute_primary_mint(cashu_mints: list[str]) -> str:
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
def resolve_bootstrap() -> Settings:
base = Settings() # Reads env with custom parse_env_var
# Back-compat env mapping
try:
# Map MODEL_BASED_PRICING -> fixed_pricing (inverted)
if "MODEL_BASED_PRICING" in os.environ and "FIXED_PRICING" not in os.environ:
mbp_raw = os.environ.get("MODEL_BASED_PRICING", "").strip().lower()
mbp = mbp_raw in {"1", "true", "yes", "on"}
base.fixed_pricing = not mbp
# Map COST_PER_REQUEST -> fixed_cost_per_request if new not provided
if (
"COST_PER_REQUEST" in os.environ
and "FIXED_COST_PER_REQUEST" not in os.environ
):
try:
base.fixed_cost_per_request = int(
os.environ["COST_PER_REQUEST"].strip()
)
except Exception:
pass
# Map COST_PER_1K_* -> CUSTOM_PER_1K_*
if (
"COST_PER_1K_INPUT_TOKENS" in os.environ
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
):
try:
base.fixed_per_1k_input_tokens = int(
os.environ["COST_PER_1K_INPUT_TOKENS"].strip()
)
except Exception:
pass
if (
"COST_PER_1K_OUTPUT_TOKENS" in os.environ
and "FIXED_PER_1K_OUTPUT_TOKENS" not in os.environ
):
try:
base.fixed_per_1k_output_tokens = int(
os.environ["COST_PER_1K_OUTPUT_TOKENS"].strip()
)
except Exception:
pass
except Exception:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:
base.onion_url = discovered
except Exception:
pass
# Derive NPUB from NSEC if not provided
if not base.npub and base.nsec:
try:
from nostr.key import PrivateKey # type: ignore
if base.nsec.startswith("nsec"):
pk = PrivateKey.from_nsec(base.nsec)
elif len(base.nsec) == 64:
pk = PrivateKey(bytes.fromhex(base.nsec))
else:
pk = None
if pk is not None:
try:
base.npub = pk.public_key.bech32()
except Exception:
# Fallback to hex if bech32 not available
base.npub = pk.public_key.hex()
except Exception:
pass
if not base.cors_origins:
base.cors_origins = ["*"]
if not base.primary_mint:
base.primary_mint = _compute_primary_mint(base.cashu_mints)
return base
class SettingsRow(BaseModel):
id: int
data: dict[str, Any]
updated_at: datetime | None = None
# Single, concrete settings instance that callers import directly
settings: Settings = resolve_bootstrap()
class SettingsService:
_current: Settings | None = None
_lock: asyncio.Lock = asyncio.Lock()
@classmethod
def get(cls) -> Settings:
if cls._current is None:
raise RuntimeError("SettingsService not initialized")
return cls._current
@classmethod
async def initialize(cls, db_session: AsyncSession) -> Settings:
async with cls._lock:
from sqlmodel import text
await db_session.exec( # type: ignore
text(
"CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)
)
row = await db_session.exec( # type: ignore
text("SELECT id, data, updated_at FROM settings WHERE id = 1")
)
row = row.first()
env_resolved = resolve_bootstrap()
if row is None:
await db_session.exec( # type: ignore
text(
"INSERT INTO settings (id, data, updated_at) VALUES (1, :data, :updated_at)"
).bindparams(
data=json.dumps(env_resolved.dict()),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
cls._current = settings
# Update the existing instance in-place for all live importers
for k, v in env_resolved.dict().items():
setattr(settings, k, v)
return cls._current
db_id, db_data, _updated_at = row
try:
db_json = (
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
)
except Exception:
db_json = {}
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "")}
)
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
if not merged_dict.get("primary_mint"):
merged_dict["primary_mint"] = _compute_primary_mint(
merged_dict.get("cashu_mints", [])
)
if any(k not in db_json for k in merged_dict.keys()):
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
).bindparams(
data=json.dumps(merged_dict),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
# Update the existing instance in-place for all live importers
for k, v in merged_dict.items():
setattr(settings, k, v)
cls._current = settings
return cls._current
@classmethod
async def update(
cls, partial: dict[str, Any], db_session: AsyncSession
) -> Settings:
async with cls._lock:
current = cls.get()
candidate_dict = {**current.dict(), **partial}
candidate = Settings(**candidate_dict)
from sqlmodel import text
# Ensure primary_mint reflects candidate mints if missing
if not candidate.primary_mint:
candidate.primary_mint = _compute_primary_mint(candidate.cashu_mints)
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
).bindparams(
data=json.dumps(candidate.dict()),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
# Update in-place
for k, v in candidate.dict().items():
setattr(settings, k, v)
cls._current = settings
return settings
@classmethod
async def reload_from_db(cls, db_session: AsyncSession) -> Settings:
async with cls._lock:
from sqlmodel import text
row = await db_session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
row = row.first()
if row is None:
raise RuntimeError("Settings row missing")
(data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
# Update in-place
for k, v in data.items():
setattr(settings, k, v)
cls._current = settings
return settings
+15 -12
View File
@@ -1,6 +1,5 @@
import asyncio
import json
import os
import random
import string
from typing import Any
@@ -10,6 +9,7 @@ import websockets
from fastapi import APIRouter
from .core.logging import get_logger
from .core.settings import settings
logger = get_logger(__name__)
@@ -196,15 +196,18 @@ async def get_cache() -> list[dict[str, Any]]:
def _get_discovery_relays() -> list[str]:
relays_env = os.getenv("RELAYS") or ""
discovery_relays = [r.strip() for r in relays_env.split(",") if r.strip()]
if not discovery_relays:
discovery_relays = [
try:
relays = settings.relays
except Exception:
relays = []
if not relays:
relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
return discovery_relays
return relays
async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]:
@@ -297,10 +300,8 @@ async def providers_cache_refresher(
) -> None:
if interval_seconds is None:
try:
interval_seconds = int(
os.getenv("PROVIDERS_REFRESH_INTERVAL_SECONDS", "300")
)
except ValueError:
interval_seconds = settings.providers_refresh_interval_seconds
except Exception:
interval_seconds = 300
await refresh_providers_cache(pubkey=pubkey)
@@ -321,8 +322,10 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
# Set up client arguments conditionally
proxies = None
if is_onion:
# Get Tor proxy URL from environment variable
tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050")
try:
tor_proxy = settings.tor_proxy_url
except Exception:
tor_proxy = "socks5://127.0.0.1:9050"
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
async with httpx.AsyncClient(
+52 -43
View File
@@ -19,6 +19,7 @@ from nostr.message_type import ClientMessageType
from nostr.relay_manager import RelayManager
from .core import get_logger
from .core.settings import settings
logger = get_logger(__name__)
@@ -286,23 +287,32 @@ def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
explicit = os.getenv("PROVIDER_ID") or os.getenv("NIP91_PROVIDER_ID")
explicit = settings.provider_id
if explicit:
logger.info(f"Using configured provider_id from env: {explicit}")
return explicit
latest_event: dict[str, Any] | None = None
latest_ts = -1
for relay_url in relay_urls:
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
try:
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
for ev in events:
ts = int(ev.get("created_at", 0))
if ts > latest_ts:
latest_event = ev
latest_ts = ts
return events
except Exception:
continue
return []
# Query all relays concurrently
all_events_lists = await asyncio.gather(
*[query_single_relay(relay_url) for relay_url in relay_urls]
)
latest_event: dict[str, Any] | None = None
latest_ts = -1
for events_list in all_events_lists:
for ev in events_list:
ts = int(ev.get("created_at", 0))
if ts > latest_ts:
latest_event = ev
latest_ts = ts
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
if existing_d:
@@ -352,7 +362,7 @@ async def announce_provider() -> None:
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment (use NSEC only)
nsec = os.getenv("NSEC")
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
return
@@ -366,38 +376,26 @@ async def announce_provider() -> None:
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# Configure relays first (RELAYS only)
relay_urls_env = os.getenv("RELAYS") or ""
logger.debug(f"Configured relays: {relay_urls_env}")
relay_urls = [url.strip() for url in relay_urls_env.split(",") if url.strip()]
if not relay_urls:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://nos.lol",
]
# Determine a stable provider_id
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
# Core settings only (no ROUTSTR_* vars)
base_url = os.getenv("HTTP_URL")
onion_url = os.getenv("ONION_URL")
# Resolve settings and determine if we can publish BEFORE touching relays
try:
base_url: str | None = settings.http_url
onion_url: str | None = settings.onion_url
provider_name = settings.name or "Routstr Proxy"
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
except Exception:
base_url = settings.http_url or None
onion_url = settings.onion_url or None
provider_name = settings.name or "Routstr Proxy"
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
if not onion_url:
discovered = discover_onion_url_from_tor()
if discovered:
onion_url = discovered
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
provider_name = os.getenv("NAME", "Routstr Proxy")
provider_about = os.getenv("DESCRIPTION", "Privacy-preserving AI proxy via Nostr")
# Mint URLs optional: include all CASHU_MINTS entries if available
cashu_mints = [
m.strip() for m in os.getenv("CASHU_MINTS", "").split(",") if m.strip()
]
mint_urls = cashu_mints if cashu_mints else None
# Build endpoint URLs (skip defaults like localhost)
endpoint_urls: list[str] = []
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
endpoint_urls.append(base_url.strip())
@@ -415,6 +413,19 @@ async def announce_provider() -> None:
)
return
# Only now configure relays and determine provider_id (may query relays)
relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()]
if not relay_urls:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
# Build metadata
metadata = {
"name": provider_name,
@@ -432,10 +443,10 @@ async def announce_provider() -> None:
metadata=metadata,
)
# Backoff configuration and state
backoff_base = float(os.getenv("NIP91_BACKOFF_BASE_SECONDS", "5"))
backoff_max = float(os.getenv("NIP91_BACKOFF_MAX_SECONDS", "900"))
backoff_jitter_ratio = float(os.getenv("NIP91_BACKOFF_JITTER_RATIO", "0.2"))
# Backoff configuration and state (sensible defaults)
backoff_base = 5.0
backoff_max = 900.0
backoff_jitter_ratio = 0.2
relay_next_allowed: dict[str, float] = {}
relay_current_delay: dict[str, float] = {}
@@ -499,9 +510,7 @@ async def announce_provider() -> None:
)
# Re-announce periodically (every 24 hours)
announcement_interval = int(
os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60))
)
announcement_interval = 24 * 60 * 60
while True:
try:
+33 -42
View File
@@ -1,34 +1,16 @@
import json
import math
import os
from pydantic.v1 import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core import get_logger
from .models import MODELS
from ..core.db import ModelRow
from ..core.settings import settings
logger = get_logger(__name__)
COST_PER_REQUEST = (
int(os.environ.get("COST_PER_REQUEST", "1")) * 1000
) # Convert to msats
COST_PER_1K_INPUT_TOKENS = (
int(os.environ.get("COST_PER_1K_INPUT_TOKENS", "0")) * 1000
) # Convert to msats
COST_PER_1K_OUTPUT_TOKENS = (
int(os.environ.get("COST_PER_1K_OUTPUT_TOKENS", "0")) * 1000
) # Convert to msats
MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true"
logger.info(
"Cost calculation initialized",
extra={
"cost_per_request_msats": COST_PER_REQUEST,
"cost_per_1k_input_tokens_msats": COST_PER_1K_INPUT_TOKENS,
"cost_per_1k_output_tokens_msats": COST_PER_1K_OUTPUT_TOKENS,
"model_based_pricing": MODEL_BASED_PRICING,
},
)
class CostData(BaseModel):
base_msats: int
@@ -46,8 +28,8 @@ class CostDataError(BaseModel):
code: str
def calculate_cost(
response_data: dict, max_cost: int
async def calculate_cost(
response_data: dict, max_cost: int, session: AsyncSession | None = None
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
@@ -85,44 +67,53 @@ def calculate_cost(
)
return cost_data
MSATS_PER_1K_INPUT_TOKENS = COST_PER_1K_INPUT_TOKENS
MSATS_PER_1K_OUTPUT_TOKENS = COST_PER_1K_OUTPUT_TOKENS
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if MODEL_BASED_PRICING and MODELS:
if not settings.fixed_pricing and session is not None:
response_model = response_data.get("model", "")
logger.debug(
"Using model-based pricing",
extra={
"model": response_model,
"available_models": [model.id for model in MODELS],
},
extra={"model": response_model},
)
if response_model not in [model.id for model in MODELS]:
result = await session.exec(select(ModelRow.id)) # type: ignore
available_ids = [
row[0] if isinstance(row, tuple) else row for row in result.all()
]
if response_model not in available_ids:
logger.error(
"Invalid model in response",
extra={
"response_model": response_model,
"available_models": [model.id for model in MODELS],
},
extra={"response_model": response_model},
)
return CostDataError(
message=f"Invalid model in response: {response_model}",
code="model_not_found",
)
model = next(model for model in MODELS if model.id == response_model)
if model.sats_pricing is None:
row = await session.get(ModelRow, response_model)
if row is None or not row.sats_pricing:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": model.id},
extra={"model": response_model, "model_id": response_model},
)
return CostDataError(
message="Model pricing not defined", code="pricing_not_found"
)
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000 # type: ignore
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000 # type: ignore
try:
sats_pricing = json.loads(row.sats_pricing)
mspp = float(sats_pricing.get("prompt", 0))
mspc = float(sats_pricing.get("completion", 0))
except Exception:
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
logger.info(
"Applied model-specific pricing",
+146 -40
View File
@@ -1,26 +1,21 @@
import json
import os
import math
from typing import Mapping
from fastapi import HTTPException, Response
from fastapi.requests import Request
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core import get_logger
from ..core.db import ModelRow
from ..core.settings import settings
from ..wallet import deserialize_token_from_string
from .cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING
from .models import MODELS
from .models import Pricing, compute_effective_max_cost_msats
logger = get_logger(__name__)
UPSTREAM_BASE_URL = os.environ.get("UPSTREAM_BASE_URL", "")
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "")
CHAT_COMPLETIONS_API_VERSION = os.environ.get("CHAT_COMPLETIONS_API_VERSION", "")
if not UPSTREAM_BASE_URL:
raise ValueError("Please set the UPSTREAM_BASE_URL environment variable")
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
if x_cashu := headers.get("x-cashu", None):
cashu_token = x_cashu
@@ -77,7 +72,8 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
)
if max_cost_for_model > amount_msat:
fee_buffer = 60
if max_cost_for_model > amount_msat + fee_buffer:
raise HTTPException(
status_code=413,
detail={
@@ -89,49 +85,157 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
)
def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int:
async def get_max_cost_for_model(
model: str, session: AsyncSession | None = None
) -> int:
"""Get the maximum cost for a specific model."""
logger.debug(
"Getting max cost for model",
extra={
"model": model,
"model_based_pricing": MODEL_BASED_PRICING,
"has_models": bool(MODELS),
"fixed_pricing": settings.fixed_pricing,
"has_models": True,
},
)
if not MODEL_BASED_PRICING or not MODELS:
# Fixed pricing: always use fixed_cost_per_request
if settings.fixed_pricing:
default_cost_msats = settings.fixed_cost_per_request * 1000
logger.debug(
"Using default cost (no model-based pricing)",
extra={"cost_msats": COST_PER_REQUEST, "model": model},
"Using fixed cost pricing",
extra={"cost_msats": default_cost_msats, "model": model},
)
return COST_PER_REQUEST
return max(settings.min_request_msat, default_cost_msats)
if model not in [model.id for model in MODELS]:
if session is None:
# Without a DB session, we can't resolve model pricing; fall back to fixed cost
fallback_msats = settings.fixed_cost_per_request * 1000
logger.warning(
"No DB session provided for model pricing; using fixed cost",
extra={"requested_model": model, "using_default_cost": fallback_msats},
)
return max(settings.min_request_msat, fallback_msats)
result = await session.exec(select(ModelRow.id)) # type: ignore
available_ids = [row[0] if isinstance(row, tuple) else row for row in result.all()]
if model not in available_ids:
# If no models or unknown model, fall back to fixed cost if provided, else minimal default
fallback_msats = settings.fixed_cost_per_request * 1000
logger.warning(
"Model not found in available models",
extra={
"requested_model": model,
"available_models": [m.id for m in MODELS],
"using_default_cost": COST_PER_REQUEST,
"available_models": available_ids,
"using_default_cost": fallback_msats,
},
)
return COST_PER_REQUEST
return max(settings.min_request_msat, fallback_msats)
for m in MODELS:
if m.id == model:
max_cost = m.sats_pricing.max_cost * 1000 * (1 - tolerance_percentage / 100) # type: ignore
logger.debug(
"Found model-specific max cost",
extra={"model": model, "max_cost_msats": max_cost},
)
return int(max_cost)
row = await session.get(ModelRow, model)
if row and row.sats_pricing:
try:
sats_dict = json.loads(row.sats_pricing)
except Exception:
sats_dict = None
if isinstance(sats_dict, dict):
effective_msats = compute_effective_max_cost_msats(sats_dict)
if effective_msats > 0:
logger.debug(
"Found model-specific max cost",
extra={"model": model, "max_cost_msats": effective_msats},
)
return effective_msats
logger.warning(
"Model pricing not found, using default",
extra={"model": model, "default_cost_msats": COST_PER_REQUEST},
"Model pricing not found, using fixed cost",
extra={
"model": model,
"default_cost_msats": settings.fixed_cost_per_request * 1000,
},
)
return COST_PER_REQUEST
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
async def calculate_discounted_max_cost(
max_cost_for_model: int, body: dict, session: AsyncSession | None = None
) -> int:
"""Calculate the discounted max cost for a request using model pricing when available."""
if settings.fixed_pricing or session is None:
return max_cost_for_model
model = body.get("model", "unknown")
model_pricing = await get_model_cost_info(model, session=session)
if not model_pricing:
return max_cost_for_model
tol = settings.tolerance_percentage
tol_factor = max(0.0, 1 - float(tol) / 100.0)
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
adjusted = max_cost_for_model
if messages := body.get("messages"):
prompt_tokens = estimate_tokens(messages)
estimated_prompt_delta_sats = (
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
)
if estimated_prompt_delta_sats >= 0:
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
else:
adjusted = adjusted + math.ceil(-estimated_prompt_delta_sats * 1000)
max_tokens_raw = body.get("max_tokens", None)
if max_tokens_raw is not None:
try:
max_tokens_int = int(max_tokens_raw)
except (TypeError, ValueError):
logger.warning(
"Invalid max_tokens; ignoring in cost adjustment",
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
)
else:
estimated_completion_delta_sats = (
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
)
if estimated_completion_delta_sats >= 0:
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
else:
adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000)
adjusted = min(max_cost_for_model, adjusted)
logger.debug(
"Discounted max cost computed",
extra={
"model": model,
"original_msats": max_cost_for_model,
"adjusted_msats": adjusted,
"tolerance_pct": tol,
},
)
return max(0, adjusted)
def estimate_tokens(messages: list) -> int:
return len(str(messages)) // 3
async def get_model_cost_info(
model_id: str, session: AsyncSession | None = None
) -> Pricing | None:
if not model_id or model_id == "unknown":
return None
if session is None:
return None
row = await session.get(ModelRow, model_id)
if row and row.sats_pricing:
try:
return Pricing(**json.loads(row.sats_pricing)) # type: ignore
except Exception:
return None
return None
def create_error_response(
@@ -161,11 +265,12 @@ def create_error_response(
def prepare_upstream_headers(request_headers: dict) -> dict:
"""Prepare headers for upstream request, removing sensitive/problematic ones."""
upstream_api_key = settings.upstream_api_key
logger.debug(
"Preparing upstream headers",
extra={
"original_headers_count": len(request_headers),
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
"has_upstream_api_key": bool(upstream_api_key),
},
)
@@ -184,8 +289,8 @@ def prepare_upstream_headers(request_headers: dict) -> dict:
removed_headers.append(header)
# Handle authorization
if UPSTREAM_API_KEY:
headers["Authorization"] = f"Bearer {UPSTREAM_API_KEY}"
if upstream_api_key:
headers["Authorization"] = f"Bearer {upstream_api_key}"
if headers.pop("authorization", None) is not None:
removed_headers.append("authorization (replaced with upstream key)")
else:
@@ -198,7 +303,7 @@ def prepare_upstream_headers(request_headers: dict) -> dict:
extra={
"final_headers_count": len(headers),
"removed_headers": removed_headers,
"added_upstream_auth": bool(UPSTREAM_API_KEY),
"added_upstream_auth": bool(upstream_api_key),
},
)
@@ -210,6 +315,7 @@ def prepare_upstream_params(
) -> dict[str, str]:
"""Prepare query params for upstream request, optionally adding api-version for chat/completions."""
params: dict[str, str] = dict(query_params or {})
if path.endswith("chat/completions") and CHAT_COMPLETIONS_API_VERSION:
params["api-version"] = CHAT_COMPLETIONS_API_VERSION
chat_api_version = settings.chat_completions_api_version
if path.endswith("chat/completions") and chat_api_version:
params["api-version"] = chat_api_version
return params
+14 -1
View File
@@ -230,7 +230,11 @@ async def get_lnurl_invoice(
async def raw_send_to_lnurl(
wallet: Wallet, proofs: list[Proof], lnurl: str, unit: str
wallet: Wallet,
proofs: list[Proof],
lnurl: str,
unit: str,
amount: int | None = None,
) -> int:
"""Send funds to an LNURL address.
@@ -255,6 +259,11 @@ async def raw_send_to_lnurl(
paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd")
"""
total_balance = sum(proof.amount for proof in proofs)
if amount and total_balance < amount:
raise ValueError("Amount to send is higher than available proofs.")
else:
assert isinstance(amount, int)
total_balance = amount
lnurl_data = await get_lnurl_data(lnurl)
if unit == "sat":
@@ -285,6 +294,10 @@ async def raw_send_to_lnurl(
melt_quote_resp = await wallet.melt_quote(
invoice=bolt11_invoice, amount_msat=final_amount
)
if amount:
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
_ = await wallet.melt(
proofs=proofs,
invoice=bolt11_invoice,
+433 -44
View File
@@ -1,13 +1,17 @@
import asyncio
import json
import os
import random
from pathlib import Path
from urllib.request import urlopen
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from pydantic.v1 import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ModelRow, create_session, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_ask_price
logger = get_logger(__name__)
@@ -30,6 +34,8 @@ class Pricing(BaseModel):
image: float
web_search: float
internal_reasoning: float
max_prompt_cost: float = 0.0 # in sats not msats
max_completion_cost: float = 0.0 # in sats not msats
max_cost: float = 0.0 # in sats not msats
@@ -52,12 +58,42 @@ class Model(BaseModel):
top_provider: TopProvider | None = None
MODELS: list[Model] = []
def compute_effective_max_cost_msats(
pricing: Pricing | dict | None,
) -> int:
if pricing is None:
try:
return max(1, int(settings.min_request_msat))
except Exception:
return 1
pricing_obj = (
pricing if isinstance(pricing, Pricing) else Pricing.parse_obj(pricing) # type: ignore[arg-type]
)
try:
tolerance = float(getattr(settings, "tolerance_percentage", 0.0))
except Exception:
tolerance = 0.0
tolerance_factor = max(0.0, 1.0 - tolerance / 100.0)
try:
min_request_msat = max(1, int(settings.min_request_msat))
except Exception:
min_request_msat = 1
base_msats = int(float(pricing_obj.max_cost or 0.0) * 1000.0 * tolerance_factor)
if base_msats <= 0:
return min_request_msat
return max(min_request_msat, base_msats)
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = os.getenv("BASE_URL", "https://openrouter.ai/api/v1")
base_url = "https://openrouter.ai/api/v1"
try:
with urlopen(f"{base_url}/models") as response:
@@ -80,6 +116,9 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"(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
@@ -91,6 +130,14 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
return []
def is_openrouter_upstream() -> bool:
try:
base = (settings.upstream_base_url or "").strip().rstrip("/")
except Exception:
return False
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.
@@ -100,7 +147,10 @@ def load_models() -> list[Model]:
and no user file is provided, it will be used as a fallback.
"""
models_path = Path(os.environ.get("MODELS_PATH", "models.json"))
try:
models_path = Path(settings.models_path)
except Exception:
models_path = Path("models.json")
# Check if user has actively provided a models.json file
if models_path.exists():
@@ -108,14 +158,23 @@ def load_models() -> list[Model]:
try:
with models_path.open("r") as f:
data = json.load(f)
return [Model(**model) for model in data.get("models", [])]
return [Model(**model) for model in data.get("models", [])] # type: ignore
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
# Fall through to auto-generation
# Auto-generate models from OpenRouter API
# 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")
source_filter = os.getenv("SOURCE")
try:
source_filter = settings.source or None
except Exception:
source_filter = None
source_filter = source_filter if source_filter and source_filter.strip() else None
models_data = fetch_openrouter_models(source_filter=source_filter)
@@ -124,58 +183,388 @@ def load_models() -> list[Model]:
return []
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
return [Model(**model) for model in models_data]
return [Model(**model) for model in models_data] # type: ignore
MODELS = load_models()
def _row_to_model(row: ModelRow) -> Model:
architecture = json.loads(row.architecture)
pricing = json.loads(row.pricing)
sats_pricing = json.loads(row.sats_pricing) if row.sats_pricing else None
per_request_limits = (
json.loads(row.per_request_limits) if row.per_request_limits else None
)
top_provider = json.loads(row.top_provider) if row.top_provider else None
# Enforce minimum per-request fee on free/zero-priced models in API output
try:
if isinstance(pricing, dict):
if float(pricing.get("request", 0.0)) <= 0.0:
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
if isinstance(sats_pricing, dict):
if float(sats_pricing.get("request", 0.0)) <= 0.0:
# Convert min_request_msat to sats for sats_pricing fields that are in sats
sats_min = max(1, int(settings.min_request_msat)) / 1000.0
sats_pricing["request"] = max(
sats_pricing.get("request", 0.0), sats_min
)
except Exception:
pass
if isinstance(sats_pricing, dict):
effective_msats = compute_effective_max_cost_msats(sats_pricing)
sats_pricing["max_cost"] = effective_msats / 1000.0
return Model(
id=row.id,
name=row.name,
created=row.created,
description=row.description,
context_length=row.context_length,
architecture=Architecture.parse_obj(architecture),
pricing=Pricing.parse_obj(pricing),
sats_pricing=Pricing.parse_obj(sats_pricing) if sats_pricing else None,
per_request_limits=per_request_limits,
top_provider=TopProvider.parse_obj(top_provider) if top_provider else None,
)
def _model_to_row_payload(model: Model) -> dict[str, str | int | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
}
async def list_models(session: AsyncSession | None = None) -> list[Model]:
if session is not None:
result = await session.exec(select(ModelRow)) # type: ignore
rows = result.all()
return [_row_to_model(r) for r in rows]
async with create_session() as s:
result = await s.exec(select(ModelRow)) # type: ignore
rows = result.all()
return [_row_to_model(r) for r in rows]
async def get_model_by_id(
model_id: str, session: AsyncSession | None = None
) -> Model | None:
if session is not None:
row = await session.get(ModelRow, model_id)
return _row_to_model(row) if row else None
async with create_session() as s:
row = await s.get(ModelRow, model_id)
return _row_to_model(row) if row else None
async def ensure_models_bootstrapped() -> None:
async with create_session() as s:
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
if existing:
return
try:
models_path = Path(settings.models_path)
except Exception:
models_path = Path("models.json")
models_to_insert: list[dict] = []
if models_path.exists():
try:
with models_path.open("r") as f:
data = json.load(f)
models_to_insert = data.get("models", [])
logger.info(
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
)
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
if not models_to_insert 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() -> None:
while True:
try:
try:
if not settings.enable_pricing_refresh:
return
except Exception:
pass
sats_to_usd = await sats_usd_ask_price()
for model in MODELS:
model.sats_pricing = Pricing(
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
)
mspp = model.sats_pricing.prompt
mspc = model.sats_pricing.completion
if (tp := model.top_provider) and (
tp.context_length or tp.max_completion_tokens
):
if (cl := model.top_provider.context_length) and (
mct := model.top_provider.max_completion_tokens
):
model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc
elif cl := model.top_provider.context_length:
model.sats_pricing.max_cost = cl * 0.8 * mspp + cl * 0.2 * mspc
elif mct := model.top_provider.max_completion_tokens:
model.sats_pricing.max_cost = mct * 4 * mspp + mct * mspc
else:
model.sats_pricing.max_cost = 1_000_000 * mspp + 32_000 * mspc
elif model.context_length:
model.sats_pricing.max_cost = (
model.sats_pricing.prompt * model.context_length * 0.8
) + (model.sats_pricing.completion * model.context_length * 0.2)
else:
p = model.sats_pricing.prompt * 1_000_000
c = model.sats_pricing.completion * 32_000
r = model.sats_pricing.request * 100_000
i = model.sats_pricing.image * 100
w = model.sats_pricing.web_search * 1000
ir = model.sats_pricing.internal_reasoning * 100
model.sats_pricing.max_cost = p + c + r + i + w + ir
async with create_session() as s:
result = await s.exec(select(ModelRow)) # type: ignore
rows = result.all()
changed = 0
for row in rows:
try:
pricing = Pricing.parse_obj(json.loads(row.pricing))
top_provider = (
TopProvider.parse_obj(json.loads(row.top_provider))
if row.top_provider
else None
)
sats = Pricing.parse_obj(
{k: v / sats_to_usd for k, v in pricing.dict().items()}
)
# Enforce minimum per-request charge floor in sats
try:
min_req_msat = max(
1, int(getattr(settings, "min_request_msat", 1))
)
except Exception:
min_req_msat = 1
min_req_sats = float(min_req_msat) / 1000.0
if sats.request <= 0.0:
sats.request = min_req_sats
mspp = sats.prompt
mspc = sats.completion
if top_provider and (
top_provider.context_length
or top_provider.max_completion_tokens
):
if (cl := top_provider.context_length) and (
mct := top_provider.max_completion_tokens
):
max_prompt_cost = (cl - mct) * mspp
max_completion_cost = mct * mspc
sats.max_prompt_cost = max_prompt_cost
sats.max_completion_cost = max_completion_cost
sats.max_cost = max_prompt_cost + max_completion_cost
elif cl := top_provider.context_length:
max_prompt_cost = cl * 0.8 * mspp
max_completion_cost = cl * 0.2 * mspc
sats.max_prompt_cost = max_prompt_cost
sats.max_completion_cost = max_completion_cost
sats.max_cost = max_prompt_cost + max_completion_cost
elif mct := top_provider.max_completion_tokens:
max_prompt_cost = mct * 4 * mspp
max_completion_cost = mct * mspc
sats.max_prompt_cost = max_prompt_cost
sats.max_completion_cost = max_completion_cost
sats.max_cost = max_prompt_cost + max_completion_cost
else:
max_prompt_cost = 1_000_000 * mspp
max_completion_cost = 32_000 * mspc
sats.max_prompt_cost = max_prompt_cost
sats.max_completion_cost = max_completion_cost
sats.max_cost = max_prompt_cost + max_completion_cost
elif row.context_length:
max_prompt_cost = mspp * row.context_length * 0.8
max_completion_cost = mspc * row.context_length * 0.2
sats.max_prompt_cost = max_prompt_cost
sats.max_completion_cost = max_completion_cost
sats.max_cost = max_prompt_cost + max_completion_cost
else:
p = mspp * 1_000_000
c = mspc * 32_000
r = sats.request * 100_000
i = sats.image * 100
w = sats.web_search * 1000
ir = sats.internal_reasoning * 100
sats.max_prompt_cost = p
sats.max_completion_cost = c
sats.max_cost = p + c + r + i + w + ir
# Ensure overall minimum per-request total cost floor
if (sats.max_cost or 0.0) < min_req_sats:
sats.max_cost = min_req_sats
new_json = json.dumps(sats.dict())
if row.sats_pricing != new_json:
row.sats_pricing = new_json
s.add(row)
changed += 1
except Exception as per_row_error:
logger.error(
"Failed to update pricing for model",
extra={
"model_id": row.id,
"error": str(per_row_error),
"error_type": type(per_row_error).__name__,
},
)
if changed:
await s.commit()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error updating sats pricing: {e}")
try:
await asyncio.sleep(10)
interval = getattr(settings, "pricing_refresh_interval_seconds", 120)
jitter = max(0.0, float(interval) * 0.1)
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
async def sync_models_with_api(
source_filter: str | None = None, delete_removed: bool = False
) -> dict[str, int]:
"""Fetch models from OpenRouter and sync with database.
Args:
source_filter: Optional source filter (e.g., 'anthropic')
delete_removed: If True, delete models that no longer exist in API
Returns:
Dict with counts: inserted, updated, deleted
"""
models = fetch_openrouter_models(source_filter=source_filter)
if not models:
return {"inserted": 0, "updated": 0, "deleted": 0}
async with create_session() as s:
result = await s.exec(select(ModelRow)) # type: ignore
existing_rows = {row.id: row for row in result.all()}
fetched_ids = set()
inserted = 0
updated = 0
for m in models:
try:
model = Model(**m) # type: ignore
except Exception:
continue
fetched_ids.add(model.id)
payload = _model_to_row_payload(model)
if model.id not in existing_rows:
try:
s.add(ModelRow(**payload)) # type: ignore
inserted += 1
except Exception:
pass
else:
existing_row = existing_rows[model.id]
changed = False
for key, value in payload.items():
if getattr(existing_row, key) != value:
setattr(existing_row, key, value)
changed = True
if changed:
s.add(existing_row)
updated += 1
deleted = 0
if delete_removed:
for existing_id in existing_rows:
if existing_id not in fetched_ids:
row_to_delete = existing_rows[existing_id]
await s.delete(row_to_delete)
deleted += 1
if inserted or updated or deleted:
await s.commit()
return {"inserted": inserted, "updated": updated, "deleted": deleted}
async def refresh_models_periodically() -> None:
"""Background task: periodically fetch OpenRouter models and sync with database.
- Respects optional SOURCE filter from settings
- Updates existing models with new information
- Optionally deletes models no longer in API (if settings.delete_removed_models)
- 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
try:
delete_removed = getattr(settings, "delete_removed_models", False)
except Exception:
delete_removed = False
counts = await sync_models_with_api(
source_filter=source_filter, delete_removed=delete_removed
)
if counts["inserted"] or counts["updated"] or counts["deleted"]:
logger.info(
"Models synced",
extra={
"inserted": counts["inserted"],
"updated": counts["updated"],
"deleted": counts["deleted"],
},
)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(
"Error during models refresh",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
jitter = max(0.0, float(interval) * 0.1)
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
@models_router.get("/v1/models")
@models_router.get("/models", include_in_schema=False)
async def models() -> dict:
return {"data": MODELS}
async def models(session: AsyncSession = Depends(get_session)) -> dict:
items = await list_models(session)
return {"data": items}
+6 -7
View File
@@ -1,17 +1,15 @@
import asyncio
import os
import httpx
from ..core import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
# artifical spread to cover conversion fees
EXCHANGE_FEE = float(os.environ.get("EXCHANGE_FEE", "1.005")) # 0.5% default
UPSTREAM_PROVIDER_FEE = float(
os.environ.get("UPSTREAM_PROVIDER_FEE", "1.05")
) # 5% default (e.g. openrouter charges 5% margin)
def _fees() -> tuple[float, float]:
return settings.exchange_fee, settings.upstream_provider_fee
async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
@@ -95,7 +93,8 @@ async def btc_usd_ask_price() -> float:
raise ValueError("Unable to fetch BTC price from any exchange")
min_price = min(valid_prices)
final_price = min_price / (EXCHANGE_FEE * UPSTREAM_PROVIDER_FEE)
exchange_fee, provider_fee = _fees()
final_price = min_price / (exchange_fee * provider_fee)
return final_price
except Exception as e:
+42 -39
View File
@@ -7,10 +7,11 @@ from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from ..core import get_logger
from ..core.db import create_session
from ..core.settings import settings
from ..wallet import recieve_token, send_token
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
from .helpers import (
UPSTREAM_BASE_URL,
create_error_response,
prepare_upstream_headers,
prepare_upstream_params,
@@ -109,7 +110,7 @@ async def forward_to_upstream(
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
url = f"{settings.upstream_base_url}/{path}"
logger.debug(
"Forwarding request to upstream",
@@ -553,43 +554,45 @@ async def get_cost(
extra={"model": model, "has_usage": "usage" in response_data},
)
match calculate_cost(response_data, max_cost_for_model):
case MaxCostData() as cost:
logger.debug(
"Using max cost pricing",
extra={"model": model, "max_cost_msats": cost.total_msats},
)
return cost
case CostData() as cost:
logger.debug(
"Using token-based pricing",
extra={
"model": model,
"total_cost_msats": cost.total_msats,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
return cost
case CostDataError() as error:
logger.error(
"Cost calculation error",
extra={
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
async with create_session() as session:
match await calculate_cost(response_data, max_cost_for_model, session):
case MaxCostData() as cost:
logger.debug(
"Using max cost pricing",
extra={"model": model, "max_cost_msats": cost.total_msats},
)
return cost
case CostData() as cost:
logger.debug(
"Using token-based pricing",
extra={
"model": model,
"total_cost_msats": cost.total_msats,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
return cost
case CostDataError() as error:
logger.error(
"Cost calculation error",
extra={
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
return None
async def send_refund(amount: int, unit: str, mint: str | None = None) -> str:
+125 -14
View File
@@ -15,8 +15,9 @@ from .auth import (
)
from .core import get_logger
from .core.db import ApiKey, AsyncSession, create_session, get_session
from .core.settings import settings
from .payment.helpers import (
UPSTREAM_BASE_URL,
calculate_discounted_max_cost,
check_token_balance,
create_error_response,
get_max_cost_for_model,
@@ -29,6 +30,104 @@ logger = get_logger(__name__)
proxy_router = APIRouter()
def _extract_upstream_error_message(body_bytes: bytes) -> tuple[str, str | None]:
"""Extract a human-friendly message and optional upstream error code from a response body."""
message: str = "Upstream request failed"
upstream_code: str | None = None
if not body_bytes:
return message, upstream_code
try:
data = json.loads(body_bytes)
if isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
raw_msg = err.get("message") or err.get("detail") or err.get("error")
if isinstance(raw_msg, (str, int, float)):
message = str(raw_msg)
upstream_code_raw = err.get("code") or err.get("type")
if isinstance(upstream_code_raw, (str, int, float)):
upstream_code = str(upstream_code_raw)
elif "message" in data and isinstance(data["message"], (str, int, float)):
message = str(data["message"]) # type: ignore[arg-type]
elif "detail" in data and isinstance(data["detail"], (str, int, float)):
message = str(data["detail"]) # type: ignore[arg-type]
except Exception:
preview = body_bytes.decode("utf-8", errors="ignore").strip()
if preview:
message = preview[:500]
return message, upstream_code
async def map_upstream_error_response(
request: Request,
path: str,
upstream_response: httpx.Response,
) -> Response:
"""Map upstream non-200 responses to standardized error responses.
- Known cases are mapped to friendly messages and appropriate status codes
- Unknown errors are converted to a generic 502
"""
status_code = upstream_response.status_code
headers = dict(upstream_response.headers)
content_type = headers.get("content-type", "")
try:
body_bytes = await upstream_response.aread()
except Exception:
body_bytes = b""
message, upstream_code = _extract_upstream_error_message(body_bytes)
lowered_message = message.lower()
lowered_code = (upstream_code or "").lower()
error_type = "upstream_error"
mapped_status = 502
# Specific mappings
if status_code in (400, 422):
error_type = "invalid_request_error"
mapped_status = 400
elif status_code in (401, 403):
error_type = "upstream_auth_error"
mapped_status = 502
elif status_code == 404:
# Many providers return 404 for unknown models or routes
if path.endswith("chat/completions"):
error_type = "invalid_model"
mapped_status = 400
if not message or message == "Upstream request failed":
message = "Requested model is not available upstream"
elif "model" in lowered_message or "model" in lowered_code:
error_type = "invalid_model"
mapped_status = 400
if not message or message == "Upstream request failed":
message = "Requested model is not available upstream"
else:
error_type = "upstream_error"
mapped_status = 502
elif status_code == 429:
error_type = "rate_limit_exceeded"
mapped_status = 429
elif status_code >= 500:
error_type = "upstream_error"
mapped_status = 502
# Include upstream content type hint in logs for diagnostics
logger.debug(
"Mapped upstream error",
extra={
"path": path,
"upstream_status": status_code,
"mapped_status": mapped_status,
"error_type": error_type,
"upstream_content_type": content_type,
"message_preview": message[:200],
},
)
return create_error_response(error_type, message, mapped_status, request=request)
async def handle_streaming_chat_completion(
response: httpx.Response, key: ApiKey, max_cost_for_model: int
) -> StreamingResponse:
@@ -307,7 +406,7 @@ async def forward_to_upstream(
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
url = f"{settings.upstream_base_url}/{path}"
logger.info(
"Forwarding request to upstream",
@@ -361,6 +460,17 @@ async def forward_to_upstream(
},
)
# Map and return errors immediately to provide clear messages
if response.status_code != 200:
try:
mapped_error = await map_upstream_error_response(
request, path, response
)
finally:
await response.aclose()
await client.aclose()
return mapped_error
# For chat completions, we need to handle token-based pricing
if path.endswith("chat/completions"):
# Check if client requested streaming
@@ -554,7 +664,10 @@ async def proxy(
)
model = request_body_dict.get("model", "unknown")
max_cost_for_model = get_max_cost_for_model(model=model)
_max_cost_for_model = await get_max_cost_for_model(model=model, session=session)
max_cost_for_model = await calculate_discounted_max_cost(
_max_cost_for_model, request_body_dict, session
)
check_token_balance(headers, request_body_dict, max_cost_for_model)
# Handle authentication
@@ -651,18 +764,10 @@ async def proxy(
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
"upstream_response": response.body
if hasattr(response, "body")
else None,
},
)
request_id = (
request.state.request_id if hasattr(request.state, "request_id") else None
)
raise HTTPException(
status_code=502,
detail=f"Upstream request failed, please contact support with request id: {request_id}",
)
# Return the mapped error response generated earlier rather than masking with 502
return response
return response
@@ -756,7 +861,7 @@ async def forward_get_to_upstream(
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{UPSTREAM_BASE_URL}/{path}"
url = f"{settings.upstream_base_url}/{path}"
logger.info(
"Forwarding GET request to upstream",
@@ -782,6 +887,12 @@ async def forward_get_to_upstream(
"GET request forwarded successfully",
extra={"path": path, "status_code": response.status_code},
)
if response.status_code != 200:
try:
mapped = await map_upstream_error_response(request, path, response)
finally:
await response.aclose()
return mapped
return StreamingResponse(
response.aiter_bytes(),
+19 -21
View File
@@ -1,6 +1,5 @@
import asyncio
import math
import os
from typing import TypedDict
from cashu.core.base import Proof, Token
@@ -8,19 +7,14 @@ from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet
from .core import db, get_logger
from .core.settings import settings
from .payment.lnurl import raw_send_to_lnurl
logger = get_logger(__name__)
CASHU_MINTS = os.environ.get("CASHU_MINTS", "https://mint.minibits.cash/Bitcoin")
TRUSTED_MINTS = CASHU_MINTS.split(",")
PRIMARY_MINT_URL = TRUSTED_MINTS[0]
RECEIVE_LN_ADDRESS = os.environ.get("RECEIVE_LN_ADDRESS", "")
async def get_balance(unit: str) -> int:
wallet = await get_wallet(PRIMARY_MINT_URL, unit)
wallet = await get_wallet(settings.primary_mint, unit)
return wallet.available_balance.amount
@@ -34,7 +28,7 @@ async def recieve_token(
wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False)
wallet.keyset_id = token_obj.keysets[0]
if token_obj.mint not in TRUSTED_MINTS:
if token_obj.mint not in settings.cashu_mints:
return await swap_to_primary_mint(token_obj, wallet)
wallet.verify_proofs_dleq(token_obj.proofs)
@@ -44,8 +38,10 @@ async def recieve_token(
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
"""Internal send function - returns amount and serialized token"""
wallet: Wallet = await get_wallet(mint_url or PRIMARY_MINT_URL, unit)
proofs = get_proofs_per_mint_and_unit(wallet, mint_url or PRIMARY_MINT_URL, unit)
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url or settings.primary_mint, unit
)
send_proofs, _ = await wallet.select_to_send(
proofs, amount, set_reserved=True, include_fees=False
@@ -86,7 +82,7 @@ async def swap_to_primary_mint(
raise ValueError("Invalid unit")
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
primary_wallet = await get_wallet(PRIMARY_MINT_URL, "sat")
primary_wallet = await get_wallet(settings.primary_mint, "sat")
minted_amount = int(amount_msat_after_fee // 1000)
mint_quote = await primary_wallet.request_mint(minted_amount)
@@ -100,7 +96,7 @@ async def swap_to_primary_mint(
)
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
return int(minted_amount), "sat", PRIMARY_MINT_URL
return int(minted_amount), "sat", settings.primary_mint
async def credit_balance(
@@ -156,9 +152,7 @@ async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wal
global _wallets
id = f"{mint_url}_{unit}"
if id not in _wallets:
_wallets[id] = await Wallet.with_db(
mint_url, db=".wallet", load_all_keysets=True, unit=unit
)
_wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit)
if load:
await _wallets[id].load_mint()
@@ -259,7 +253,7 @@ async def fetch_all_balances(
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in TRUSTED_MINTS
for mint_url in settings.cashu_mints
for unit in units
]
@@ -299,14 +293,14 @@ async def fetch_all_balances(
async def periodic_payout() -> None:
if not RECEIVE_LN_ADDRESS:
if not settings.receive_ln_address:
logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout")
return
while True:
await asyncio.sleep(60 * 5)
await asyncio.sleep(60 * 15)
try:
async with db.create_session() as session:
for mint_url in TRUSTED_MINTS:
for mint_url in settings.cashu_mints:
for unit in ["sat", "msat"]:
wallet = await get_wallet(mint_url, unit)
proofs = get_proofs_per_mint_and_unit(
@@ -323,7 +317,11 @@ async def periodic_payout() -> None:
min_amount = 210 if unit == "sat" else 210000
if available_balance > min_amount:
amount_received = await raw_send_to_lnurl(
wallet, proofs, RECEIVE_LN_ADDRESS, unit
wallet,
proofs,
settings.receive_ln_address,
unit,
amount=available_balance,
)
logger.info(
"Payout sent successfully",
+4 -6
View File
@@ -1,7 +1,5 @@
REPO_DIR=/home/user/proxy
LOG_FILE=/home/user/proxy/update.log
* * * * * /home/user/proxy/scripts/auto_update.sh >/dev/null 2>&1
# Example crontab entries for Routstr tasks
OUTPUT_FILE=/home/user/proxy/models.json
BASE_URL=https://openrouter.ai/api/v1
0 * * * * python3 /home/user/proxy/scripts/models_meta.py >/dev/null 2>&1
# Update models.json daily at 03:15 (optional)
# OUTPUT_FILE=/app/models.json SOURCE=openrouter
15 3 * * * /usr/local/bin/python /app/scripts/models_meta.py >> /var/log/cron.log 2>&1
+2 -2
View File
@@ -42,13 +42,13 @@ class Model(TypedDict):
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "models.json")
BASE_URL = os.getenv("BASE_URL", "https://openrouter.ai/api/v1")
SOURCE = os.getenv("SOURCE")
def fetch_openrouter_models(source_filter: str | None = None) -> list[Model]:
"""Fetches model information from OpenRouter API."""
with urlopen(f"{BASE_URL}/models") as response:
base_url = "https://openrouter.ai/api/v1"
with urlopen(f"{base_url}/models") as response:
data = json.loads(response.read().decode("utf-8"))
models_data: list[Model] = []
+16 -6
View File
@@ -33,8 +33,8 @@ if use_local_services:
"RECEIVE_LN_ADDRESS": "test@routstr.com",
"REFUND_PROCESSING_INTERVAL": "3600",
"NSEC": "nsec1testkey1234567890abcdef",
"COST_PER_REQUEST": "10",
"MODEL_BASED_PRICING": "true",
"FIXED_COST_PER_REQUEST": "10",
"FIXED_PRICING": "false",
"MINIMUM_PAYOUT": "1000",
"PAYOUT_INTERVAL": "86400",
"NAME": "TestRoutstrNode",
@@ -55,14 +55,15 @@ else:
"RECEIVE_LN_ADDRESS": "test@routstr.com",
"REFUND_PROCESSING_INTERVAL": "3600",
"NSEC": "nsec1testkey1234567890abcdef",
"COST_PER_REQUEST": "10",
"MODEL_BASED_PRICING": "true",
"FIXED_COST_PER_REQUEST": "10",
"FIXED_PRICING": "false",
"MINIMUM_PAYOUT": "1000",
"PAYOUT_INTERVAL": "86400",
}
# Set test environment variables before importing the app
os.environ.update(test_env)
os.environ.pop("ADMIN_PASSWORD", None)
from routstr.core.db import ApiKey, get_session # noqa: E402
from routstr.core.main import app, lifespan # noqa: E402
@@ -507,10 +508,15 @@ async def integration_app(
else:
# Use testmint with wallet patches for all integration tests
mint_url = os.environ.get("CASHU_MINTS", "http://localhost:3338")
from routstr.core.settings import settings as _settings
# Passthrough discounted max cost to avoid dependence on MODELS in tests
def _passthrough_discount(max_cost_for_model: int, body: dict) -> int:
return max_cost_for_model
with (
patch("routstr.core.db.engine", integration_engine),
patch("routstr.wallet.TRUSTED_MINTS", [mint_url]),
patch("routstr.wallet.PRIMARY_MINT_URL", mint_url),
patch.object(_settings, "cashu_mints", [mint_url]),
patch("routstr.auth.credit_balance", testmint_wallet.credit_balance),
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
patch("routstr.balance.credit_balance", testmint_wallet.credit_balance),
@@ -521,6 +527,10 @@ async def integration_app(
patch("websockets.connect") as mock_websockets,
patch("routstr.payment.price.btc_usd_ask_price", return_value=50000.0),
patch("routstr.payment.price.sats_usd_ask_price", return_value=0.0005),
patch(
"routstr.payment.helpers.calculate_discounted_max_cost",
side_effect=_passthrough_discount,
),
):
# Configure the WebSocket mock for discovery service - fast failure for performance tests
async def mock_websocket_connect(*args: Any, **kwargs: Any) -> None:
+69 -69
View File
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from routstr.core.db import ApiKey
from routstr.payment.models import MODELS, Model, Pricing, update_sats_pricing
from routstr.payment.models import Model, Pricing, update_sats_pricing
from routstr.wallet import periodic_payout
@@ -57,51 +57,49 @@ class TestPricingUpdateTask:
},
)
# Add test model to MODELS list
original_models = MODELS.copy()
MODELS.clear()
MODELS.append(test_model)
# Compute sats pricing once using the same logic as the background task
# Run the pricing update logic once directly
sats_to_usd = mock_sats_usd
_pdict = {k: v / sats_to_usd for k, v in test_model.pricing.dict().items()}
test_model.sats_pricing = Pricing(
prompt=_pdict.get("prompt", 0.0),
completion=_pdict.get("completion", 0.0),
request=_pdict.get("request", 0.0),
image=_pdict.get("image", 0.0),
web_search=_pdict.get("web_search", 0.0),
internal_reasoning=_pdict.get("internal_reasoning", 0.0),
max_prompt_cost=_pdict.get("max_prompt_cost", 0.0),
max_completion_cost=_pdict.get("max_completion_cost", 0.0),
max_cost=_pdict.get("max_cost", 0.0),
)
mspp = test_model.sats_pricing.prompt
mspc = test_model.sats_pricing.completion
if (tp := test_model.top_provider) and (
tp.context_length or tp.max_completion_tokens
):
if (cl := test_model.top_provider.context_length) and (
mct := test_model.top_provider.max_completion_tokens
):
test_model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc
try:
# Run the pricing update logic once directly
sats_to_usd = mock_sats_usd
for model in [test_model]:
model.sats_pricing = Pricing(
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
)
mspp = model.sats_pricing.prompt
mspc = model.sats_pricing.completion
if (tp := model.top_provider) and (
tp.context_length or tp.max_completion_tokens
):
if (cl := model.top_provider.context_length) and (
mct := model.top_provider.max_completion_tokens
):
model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc
# Verify sats pricing was calculated correctly
assert test_model.sats_pricing is not None
assert test_model.sats_pricing.prompt == pytest.approx(
0.001 / mock_sats_usd
)
assert test_model.sats_pricing.completion == pytest.approx(
0.002 / mock_sats_usd
)
# Verify sats pricing was calculated correctly
assert test_model.sats_pricing is not None
assert test_model.sats_pricing.prompt == pytest.approx(
0.001 / mock_sats_usd
)
assert test_model.sats_pricing.completion == pytest.approx(
0.002 / mock_sats_usd
)
# Verify max_cost calculation
# Logic uses (context_length - max_completion_tokens) * prompt + max_completion_tokens * completion
expected_max_cost = (
(4096 - 1024) * test_model.sats_pricing.prompt
+ 1024 * test_model.sats_pricing.completion
)
assert test_model.sats_pricing.max_cost == pytest.approx(expected_max_cost)
# Verify max_cost calculation
# Logic uses (context_length - max_completion_tokens) * prompt + max_completion_tokens * completion
expected_max_cost = (
(4096 - 1024) * test_model.sats_pricing.prompt
+ 1024 * test_model.sats_pricing.completion
)
assert test_model.sats_pricing.max_cost == pytest.approx(
expected_max_cost
)
finally:
# Restore original models
MODELS.clear()
MODELS.extend(original_models)
# Nothing to clean up; no global state was modified
async def test_handles_provider_api_failures(self) -> None:
"""Test that pricing update continues running even if price API fails"""
@@ -159,37 +157,39 @@ class TestPricingUpdateTask:
),
)
original_models = MODELS.copy()
MODELS.clear()
MODELS.append(test_model)
# Initialize pricing once to ensure consistent state
with patch(
"routstr.payment.price.sats_usd_ask_price",
AsyncMock(return_value=0.00002),
):
sats_to_usd = 0.00002
_pdict = {k: v / sats_to_usd for k, v in test_model.pricing.dict().items()}
test_model.sats_pricing = Pricing(
prompt=_pdict.get("prompt", 0.0),
completion=_pdict.get("completion", 0.0),
request=_pdict.get("request", 0.0),
image=_pdict.get("image", 0.0),
web_search=_pdict.get("web_search", 0.0),
internal_reasoning=_pdict.get("internal_reasoning", 0.0),
max_prompt_cost=_pdict.get("max_prompt_cost", 0.0),
max_completion_cost=_pdict.get("max_completion_cost", 0.0),
max_cost=_pdict.get("max_cost", 0.0),
)
try:
with patch(
"routstr.payment.price.sats_usd_ask_price",
AsyncMock(return_value=0.00002),
):
# Initialize pricing once to ensure consistent state
sats_to_usd = 0.00002
test_model.sats_pricing = Pricing(
**{k: v / sats_to_usd for k, v in test_model.pricing.dict().items()}
)
# Simulate concurrent access to the model
results = []
# Simulate concurrent access to the model
results = []
async def access_model() -> None:
await asyncio.sleep(0.05) # Small delay
results.append(test_model.sats_pricing)
async def access_model() -> None:
await asyncio.sleep(0.05) # Small delay
results.append(test_model.sats_pricing)
# Run multiple concurrent accesses - they should all see the consistent state
await asyncio.gather(*[access_model() for _ in range(10)])
# Run multiple concurrent accesses - they should all see the consistent state
await asyncio.gather(*[access_model() for _ in range(10)])
# All accesses should see consistent state
assert all(r is not None for r in results)
# All accesses should see consistent state
assert all(r is not None for r in results)
finally:
MODELS.clear()
MODELS.extend(original_models)
# No global state to restore
@pytest.mark.asyncio
@@ -628,14 +628,11 @@ class TestEdgeCaseCombinations:
a single request (which costs 1000 msats). It then makes 5 concurrent requests
to verify that all requests fail with 402 Payment Required errors.
Note: The test disables MODEL_BASED_PRICING to avoid model lookup errors
Note: The test enables fixed pricing to avoid model lookup errors
since the test environment doesn't have models configured.
"""
# Disable MODEL_BASED_PRICING for this test to avoid model lookup issues
monkeypatch.setattr(
"routstr.payment.cost_caculation.MODEL_BASED_PRICING", False
)
monkeypatch.setattr("routstr.payment.helpers.MODEL_BASED_PRICING", False)
# Disable model-based pricing for this test to avoid model lookup issues
monkeypatch.setattr("routstr.core.settings.settings.fixed_pricing", True)
# Create a new API key with very low balance
# Generate a unique API key
@@ -645,7 +642,7 @@ class TestEdgeCaseCombinations:
# Create the API key with only 500 msats (less than one request cost)
new_key = ApiKey(
hashed_key=api_key_hash,
balance=500, # Less than COST_PER_REQUEST (1000 msats)
balance=500, # Less than fixed cost per request (1000 msats)
reserved_balance=0,
total_spent=0,
total_requests=0,
@@ -271,36 +271,23 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) -
async def test_admin_endpoint_unauthenticated(
integration_client: AsyncClient, db_snapshot: Any
) -> None:
"""Test GET /admin/ endpoint without authentication"""
# Capture initial database state
"""Test GET /admin/ endpoint without authentication shows setup form"""
await db_snapshot.capture()
response = await integration_client.get("/admin/")
# Should return 200 with login form (not 401/403)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
# Response should be HTML
html_content = response.text
assert "<!DOCTYPE html>" in html_content
assert "<html>" in html_content
assert "<form" in html_content
assert 'type="password"' in html_content
assert "password" in html_content.lower()
assert "<script>" in html_content or "<script " in html_content
assert "Initial Admin Setup" in html_content or "setup" in html_content.lower()
# Either shows login form or message about setting ADMIN_PASSWORD
if "ADMIN_PASSWORD" in html_content:
# When ADMIN_PASSWORD is not set, it shows a message
assert "Please set a secure ADMIN_PASSWORD" in html_content
else:
# When ADMIN_PASSWORD is set, it shows a login form
assert "<form" in html_content
assert 'type="password"' in html_content
assert "password" in html_content.lower()
assert "login" in html_content.lower()
# Should have JavaScript for form handling
assert "<script>" in html_content or "<script " in html_content
# Verify no database state changes
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0
assert len(diff["api_keys"]["removed"]) == 0
+147 -28
View File
@@ -1,46 +1,165 @@
import os
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi import HTTPException
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
from routstr.core.settings import settings # noqa: E402
from routstr.payment.helpers import ( # noqa: E402
calculate_discounted_max_cost,
check_token_balance,
get_max_cost_for_model,
)
from routstr.payment.models import Pricing # noqa: E402
def test_get_max_cost_for_model_known() -> None:
mock_model = Mock()
mock_model.id = "gpt-4"
mock_model.sats_pricing = Mock()
mock_model.sats_pricing.max_cost = 500
async def test_get_max_cost_for_model_known() -> None:
# Mock DB session behavior
mock_session = AsyncMock()
# available ids
mock_exec_result = Mock()
mock_exec_result.all = Mock(return_value=[("gpt-4",)])
mock_session.exec.return_value = mock_exec_result
# row with sats_pricing
row = Mock()
row.sats_pricing = (
"{" # minimal required fields for Pricing model
'"prompt": 0.0, "completion": 0.0, "request": 0.0, '
'"image": 0.0, "web_search": 0.0, "internal_reasoning": 0.0, '
'"max_cost": 500'
"}"
)
mock_session.get.return_value = row
with patch("routstr.payment.helpers.MODELS", [mock_model]):
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True):
cost = get_max_cost_for_model("gpt-4", tolerance_percentage=0)
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model("gpt-4", session=mock_session)
assert cost == 500000 # 500 sats * 1000 = msats
def test_get_max_cost_for_model_unknown() -> None:
with patch("routstr.payment.helpers.MODELS", []):
with patch("routstr.payment.helpers.COST_PER_REQUEST", 100):
cost = get_max_cost_for_model("unknown-model", tolerance_percentage=0)
assert cost == 100
async def test_get_max_cost_for_model_unknown() -> None:
mock_session = AsyncMock()
mock_exec_result = Mock()
mock_exec_result.all = Mock(return_value=[])
mock_session.exec.return_value = mock_exec_result
mock_session.get.return_value = None
with patch.object(settings, "fixed_cost_per_request", 100):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model("unknown-model", session=mock_session)
assert cost == 100000
def test_get_max_cost_for_model_disabled() -> None:
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", False):
with patch("routstr.payment.helpers.COST_PER_REQUEST", 200):
cost = get_max_cost_for_model("any-model", tolerance_percentage=0)
assert cost == 200
async def test_get_max_cost_for_model_disabled() -> None:
with patch.object(settings, "fixed_pricing", True):
with patch.object(settings, "fixed_cost_per_request", 200):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model("any-model", session=None)
assert cost == 200000
def test_get_max_cost_for_model_tolerance() -> None:
mock_model = Mock()
mock_model.id = "gpt-4"
mock_model.sats_pricing = Mock()
mock_model.sats_pricing.max_cost = 500
async def test_get_max_cost_for_model_tolerance() -> None:
mock_session = AsyncMock()
mock_exec_result = Mock()
mock_exec_result.all = Mock(return_value=[("gpt-4",)])
mock_session.exec.return_value = mock_exec_result
row = Mock()
row.sats_pricing = (
"{" # minimal required fields for Pricing model
'"prompt": 0.0, "completion": 0.0, "request": 0.0, '
'"image": 0.0, "web_search": 0.0, "internal_reasoning": 0.0, '
'"max_cost": 500'
"}"
)
mock_session.get.return_value = row
with patch("routstr.payment.helpers.MODELS", [mock_model]):
with patch("routstr.payment.helpers.MODEL_BASED_PRICING", True):
cost = get_max_cost_for_model("gpt-4", tolerance_percentage=10)
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 10):
cost = await get_max_cost_for_model("gpt-4", session=mock_session)
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
async def test_calculate_discounted_max_cost_no_session() -> None:
with patch.object(settings, "fixed_pricing", False):
result = await calculate_discounted_max_cost(123, {}, session=None)
assert result == 123
async def test_calculate_discounted_max_cost_clamped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mock_session = AsyncMock()
mock_exec_result = Mock()
mock_exec_result.all = Mock(return_value=[("gpt-4",)])
mock_session.exec.return_value = mock_exec_result
pricing = {
"prompt": 0.0,
"completion": 0.0,
"request": 0.0,
"image": 0.0,
"web_search": 0.0,
"internal_reasoning": 0.0,
"max_prompt_cost": 100.0,
"max_completion_cost": 200.0,
"max_cost": 300.0,
}
async def mock_get_model_cost_info(*args, **kwargs): # type: ignore
return Pricing(**pricing)
monkeypatch.setattr(
"routstr.payment.helpers.get_model_cost_info", mock_get_model_cost_info
)
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 0):
result = await calculate_discounted_max_cost(
320000,
{"model": "gpt-4", "max_tokens": 10, "messages": ["one"]},
session=mock_session,
)
assert 0 <= result <= 320000
async def test_check_token_balance_with_fee_buffer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Token:
unit = "msat"
amount = 320400
def mock_deserialize(_value: str) -> Token:
return Token()
monkeypatch.setattr(
"routstr.payment.helpers.deserialize_token_from_string", mock_deserialize
)
headers = {"x-cashu": "token"}
body = {"model": "gpt-4"}
check_token_balance(headers, body, max_cost_for_model=320450)
def test_check_token_balance_insufficient(monkeypatch: pytest.MonkeyPatch) -> None:
class Token:
unit = "msat"
amount = 320200
def mock_deserialize(_value: str) -> Token:
return Token()
monkeypatch.setattr(
"routstr.payment.helpers.deserialize_token_from_string", mock_deserialize
)
headers = {"x-cashu": "token"}
body = {"model": "gpt-4"}
with pytest.raises(HTTPException) as exc:
check_token_balance(headers, body, max_cost_for_model=320450)
assert exc.value.status_code == 413
detail = exc.value.detail # type: ignore[assignment]
assert isinstance(detail, dict)
assert detail.get("type") == "minimum_balance_required"
+37
View File
@@ -0,0 +1,37 @@
import os
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.settings import SettingsService
@pytest.mark.asyncio
async def test_settings_seed_from_env_and_persist() -> None:
os.environ["UPSTREAM_BASE_URL"] = "https://api.test/v1"
os.environ.pop("ONION_URL", None)
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
settings = await SettingsService.initialize(session)
assert settings.upstream_base_url == "https://api.test/v1"
# ONION_URL may be empty if not discoverable
assert isinstance(settings.onion_url, str)
@pytest.mark.asyncio
async def test_settings_db_precedence_over_env() -> None:
os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1"
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
_ = await SettingsService.initialize(session)
updated = await SettingsService.update({"name": "DBName"}, session)
assert updated.name == "DBName"
# Change env and re-initialize; DB should still win
os.environ["NAME"] = "EnvName"
again = await SettingsService.initialize(session)
assert again.name == "DBName"
+6 -2
View File
@@ -39,7 +39,9 @@ async def test_recieve_token_valid() -> None:
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
with patch("routstr.wallet.TRUSTED_MINTS", ["http://mint:3338"]):
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
@@ -82,7 +84,9 @@ async def test_credit_balance() -> None:
mock_key.balance = 5000000
mock_session = AsyncMock()
with patch("routstr.wallet.PRIMARY_MINT_URL", "http://mint:3338"):
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
Generated
+3 -1
View File
@@ -1783,7 +1783,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.1.2"
version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1793,6 +1793,7 @@ dependencies = [
{ name = "greenlet" },
{ name = "httpx", extra = ["socks"] },
{ name = "marshmallow" },
{ name = "mdurl" },
{ name = "nostr" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
@@ -1824,6 +1825,7 @@ requires-dist = [
{ name = "greenlet", specifier = ">=3.2.1" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.25.2" },
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "nostr", specifier = ">=0.0.2" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },