mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
151
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f55da9bb8 | ||
|
|
16dce9ea81 | ||
|
|
963ee04619 | ||
|
|
b874b1f01c | ||
|
|
b6cca3d3a0 | ||
|
|
86ebc84f4c | ||
|
|
8a74c0543f | ||
|
|
3d16a9b988 | ||
|
|
40f98b99aa | ||
|
|
7fcae5b08d | ||
|
|
f50cb31749 | ||
|
|
85a1ea4b4c | ||
|
|
0f0f8c40bf | ||
|
|
a40d224ee7 | ||
|
|
a637acd8f4 | ||
|
|
58be0c7976 | ||
|
|
d934f3eead | ||
|
|
74b58e5fa3 | ||
|
|
c1497e0cfe | ||
|
|
30db582321 | ||
|
|
a1018776e9 | ||
|
|
5ef499e2ae | ||
|
|
c7c802c610 | ||
|
|
685368bb0a | ||
|
|
55e240d92a | ||
|
|
7da4ad3818 | ||
|
|
9e1934bfda | ||
|
|
31a9730cd8 | ||
|
|
d686e0e851 | ||
|
|
7708ed1c8b | ||
|
|
22ede7636a | ||
|
|
c9a5af0ffa | ||
|
|
072079b33d | ||
|
|
2abc28d295 | ||
|
|
7879b68cad | ||
|
|
9eff340c57 | ||
|
|
933ba105d8 | ||
|
|
205dc6c3b3 | ||
|
|
c1ada39278 | ||
|
|
200b32f1ff | ||
|
|
2b623a86c3 | ||
|
|
a68998e8ff | ||
|
|
905ae67015 | ||
|
|
6fde846be2 | ||
|
|
a833cf429e | ||
|
|
c5a205cf98 | ||
|
|
62185fbd38 | ||
|
|
bb97e8dedb | ||
|
|
a7455a48d2 | ||
|
|
a090632804 | ||
|
|
d6b755ff81 | ||
|
|
a1c2a785a1 | ||
|
|
dc8ebe66e0 | ||
|
|
750193e99d | ||
|
|
4ee654331f | ||
|
|
b8fcf5bcc8 | ||
|
|
2ce8981f0c | ||
|
|
b76ff62f1c | ||
|
|
73ebfe8975 | ||
|
|
3dae03d731 | ||
|
|
8a1f909083 | ||
|
|
a6c6d3e034 | ||
|
|
f2a0473fb3 | ||
|
|
33d2ef9ef1 | ||
|
|
f1c3b515fe | ||
|
|
74ddc48ff5 | ||
|
|
6db11ed88f | ||
|
|
4d302baeb5 | ||
|
|
063df51ced | ||
|
|
e97fa60b27 | ||
|
|
42b3840df6 | ||
|
|
a0b2b9466c | ||
|
|
9a553de011 | ||
|
|
901a6a9ba2 | ||
|
|
691927a996 | ||
|
|
8fcecf2c1f | ||
|
|
aed967dc44 | ||
|
|
ed8102d533 | ||
|
|
8fa8475cca | ||
|
|
4723b9db4d | ||
|
|
358ff25899 | ||
|
|
7267bb87b9 | ||
|
|
173f5fbcbd | ||
|
|
9006709f8d | ||
|
|
7d48b36be8 | ||
|
|
8b3fdaa545 | ||
|
|
b43d57df23 | ||
|
|
b9a5a9276e | ||
|
|
a79fdf7212 | ||
|
|
9560050946 | ||
|
|
dd88e9b172 | ||
|
|
e31b45fa9e | ||
|
|
1ed8b29d64 | ||
|
|
59f8d31719 | ||
|
|
93a368b1a2 | ||
|
|
d3dd346853 | ||
|
|
0198569a9a | ||
|
|
7e648cb5c2 | ||
|
|
deb75624f3 | ||
|
|
c5cb562165 | ||
|
|
8a89a38864 | ||
|
|
09e7f1f0bf | ||
|
|
d9d082ad5c | ||
|
|
9cd4ff5c21 | ||
|
|
11eb20a2d1 | ||
|
|
a63e81db06 | ||
|
|
8fc1b6484c | ||
|
|
9bc3feff62 | ||
|
|
cb22968ff3 | ||
|
|
e3bca39815 | ||
|
|
958f28fd82 | ||
|
|
c8c30d7cfd | ||
|
|
2c2124952f | ||
|
|
f56ba92ae8 | ||
|
|
1fa71ee806 | ||
|
|
97d164e181 | ||
|
|
9f3c915dec | ||
|
|
4ff6218140 | ||
|
|
67ea6aec43 | ||
|
|
41f346417a | ||
|
|
8a373276ce | ||
|
|
4857d741de | ||
|
|
eabe17cc26 | ||
|
|
8f298ae9c1 | ||
|
|
e5f3b7d755 | ||
|
|
3796e29cb4 | ||
|
|
812f50ea28 | ||
|
|
ce5fa136c2 | ||
|
|
4643aefb22 | ||
|
|
5aa902fd4a | ||
|
|
db144e0903 | ||
|
|
e20b20dbca | ||
|
|
aa700443d3 | ||
|
|
7e89b5bc6d | ||
|
|
8dbf036558 | ||
|
|
12f2cfecc9 | ||
|
|
a0f3378cf5 | ||
|
|
66975ee271 | ||
|
|
97daddb95a | ||
|
|
847f4b07a5 | ||
|
|
350714f23a | ||
|
|
9cc2e84f7c | ||
|
|
342a7f7f16 | ||
|
|
7694f20006 | ||
|
|
3ea0a267dd | ||
|
|
68e537f6fb | ||
|
|
814c39898d | ||
|
|
fdd4f12f5c | ||
|
|
6957d8c0d9 | ||
|
|
2c358276bf | ||
|
|
c5fd386c1e |
@@ -14,6 +14,7 @@ UPSTREAM_API_KEY=your-upstream-api-key
|
||||
# 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"
|
||||
# ENABLE_ANALYTICS_SHARING=true
|
||||
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
|
||||
# RECEIVE_LN_ADDRESS=
|
||||
|
||||
|
||||
@@ -38,8 +38,3 @@ proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
output/
|
||||
.pnpm-store/
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
@@ -51,14 +51,26 @@ curl https://api.routstr.com/v1/chat/completions \
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
|
||||
If you are a node runner, start a Routstr Core instance using Docker Compose:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr-proxy \
|
||||
-p 8000:8000 \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
1. **Prepare your `.env`**:
|
||||
```bash
|
||||
ADMIN_PASSWORD=mysecretpassword
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
NSEC=yournsec
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
```
|
||||
|
||||
2. **Start the services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. **Configure**:
|
||||
Open [http://localhost:8000/admin/](http://localhost:8000/admin/) to connect your AI providers and set pricing.
|
||||
|
||||
For full instructions, see the **[Provider Quick Start Guide](https://docs.routstr.com/provider/quickstart/)**.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ Announce your node on the network:
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
| **Share Analytics** | Publish aggregate usage stats to Nostr |
|
||||
|
||||
See [Discovery](discovery.md) for details.
|
||||
|
||||
@@ -122,6 +123,7 @@ Use environment variables for:
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
|
||||
@@ -152,6 +152,7 @@ Manage which mints you accept payments from:
|
||||
|-------|-------------|
|
||||
| **Nsec** | Private key for signing announcements |
|
||||
| **Relays** | Where to publish your node advertisement |
|
||||
| **Share Analytics** | Toggle publishing aggregate usage stats to Nostr |
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -6,16 +6,7 @@ Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||
|
||||
### Unified Setup (All-in-one)
|
||||
To build and run the node with the UI integrated in a single container using the multi-stage build:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
docker run -d -p 8000:8000 --env-file .env routstr-full
|
||||
```
|
||||
|
||||
### Advanced Setup (Separated UI & Node)
|
||||
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
||||
Use the included `compose.yml` for a flexible setup that handles both the UI and the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -184,20 +175,16 @@ docker compose up -d
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Unified Image (UI + Node)
|
||||
The easiest way to build everything from source into a single production-ready image:
|
||||
### Using Docker Compose
|
||||
The easiest way to build everything from source:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
docker compose build
|
||||
```
|
||||
|
||||
### Individual Components
|
||||
If you prefer building them separately or using Docker Compose:
|
||||
If you prefer building the node only (requires manual UI build first):
|
||||
|
||||
```bash
|
||||
# Build using compose
|
||||
docker compose build
|
||||
|
||||
# Or build the node only (requires manual UI build first)
|
||||
docker build -t routstr-node .
|
||||
```
|
||||
|
||||
+12
-24
@@ -35,6 +35,7 @@ ADMIN_PASSWORD=mysecretpassword
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
NSEC=yournsec
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
@@ -43,32 +44,10 @@ RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
|
||||
## 2. Start the Node
|
||||
|
||||
You can run the pre-built image directly:
|
||||
The recommended way to run Routstr is using Docker Compose, which handles the node, the UI, and optional services like Tor.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
-v routstr-data:/app/data \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
|
||||
|
||||
### Build from Source (Recommended)
|
||||
|
||||
If you want to build the node and UI yourself from source, use the unified Dockerfile:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
# Edit your .env with ADMIN_PASSWORD and API keys
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
docker build -f Dockerfile.full -t routstr-local .
|
||||
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
@@ -77,6 +56,15 @@ Verify it's running:
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
### Build from Source (Optional)
|
||||
|
||||
If you've cloned the repository and want to build the images yourself:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configure via Dashboard
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add provider_settings to upstream_providers
|
||||
|
||||
Revision ID: 614c0a740e68
|
||||
Revises: 06f81c0fc88d
|
||||
Create Date: 2026-02-13 22:36:53.608737
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "614c0a740e68"
|
||||
down_revision = "06f81c0fc88d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if column exists before adding it
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [c["name"] for c in inspector.get_columns("upstream_providers")]
|
||||
|
||||
if "provider_settings" not in columns:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("provider_settings", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("upstream_providers", "provider_settings")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add cashu_transactions table
|
||||
|
||||
Revision ID: a776ca70e5fe
|
||||
Revises: 614c0a740e68
|
||||
Create Date: 2026-03-11 22:00:01.554762
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a776ca70e5fe"
|
||||
down_revision = "614c0a740e68"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cashu_transactions",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount", sa.Integer(), nullable=False),
|
||||
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column(
|
||||
"type",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
server_default="out",
|
||||
),
|
||||
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("collected", sa.Boolean(), nullable=False),
|
||||
sa.Column("swept", sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cashu_transactions")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add forwarded_model_id to models
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: a776ca70e5fe
|
||||
Create Date: 2026-04-05 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b1c2d3e4f5a6"
|
||||
down_revision = "a776ca70e5fe"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column(
|
||||
"forwarded_model_id",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
# Backfill: set forwarded_model_id = id for all existing rows
|
||||
op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("models", "forwarded_model_id")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add source to cashu_transactions
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-04-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "b1c2d3e4f5a6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||
if "source" not in columns:
|
||||
op.add_column(
|
||||
"cashu_transactions",
|
||||
sa.Column(
|
||||
"source",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
server_default="x-cashu",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cashu_transactions", "source")
|
||||
@@ -0,0 +1,355 @@
|
||||
# Plan: Track unsupported incoming mints in `other_mints` and include them in balances
|
||||
|
||||
## Goal
|
||||
|
||||
When a Cashu token arrives from a mint that is **not** in `settings.cashu_mints`, we currently create/use a wallet for that mint and swap value into the primary mint. Any change/surplus left behind after `melt()` remains in the foreign `token_wallet`, but that balance is not surfaced by `fetch_all_balances()` because it only iterates over configured mints.
|
||||
|
||||
This plan adds persistent tracking for those foreign mints in a new database table called `other_mints`, and updates balance reporting to include them.
|
||||
|
||||
---
|
||||
|
||||
## Current behavior
|
||||
|
||||
### Incoming unsupported mint flow
|
||||
|
||||
In `routstr/wallet.py`:
|
||||
|
||||
- `recieve_token()` deserializes the token
|
||||
- if `token_obj.mint not in settings.cashu_mints`, it calls `swap_to_primary_mint(token_obj, wallet)`
|
||||
- `swap_to_primary_mint()` calls `token_wallet.melt(...)` to pay the primary mint invoice
|
||||
|
||||
### Important detail: change is retained, not discarded
|
||||
|
||||
The underlying Cashu wallet library keeps any melt change:
|
||||
|
||||
- `Wallet.melt()` constructs blank outputs for change
|
||||
- when the melt succeeds, returned change is reconstructed into proofs
|
||||
- those proofs are appended to `self.proofs` and stored in the wallet DB
|
||||
|
||||
So surplus from unsupported mints is **not discarded**, but it may become invisible operationally.
|
||||
|
||||
### Visibility problem
|
||||
|
||||
`fetch_all_balances()` currently only loops over:
|
||||
|
||||
- `settings.cashu_mints`
|
||||
- units `sat` and `msat`
|
||||
|
||||
This means balances left on unsupported mints are not shown in admin balance reporting.
|
||||
|
||||
---
|
||||
|
||||
## Proposed design
|
||||
|
||||
## 1. Add a new DB table: `other_mints`
|
||||
|
||||
Add a small table in `routstr/core/db.py` to persist unsupported mints we have seen in incoming tokens.
|
||||
|
||||
Suggested schema:
|
||||
|
||||
- `mint_url: str` primary key
|
||||
- `created_at: int`
|
||||
- `last_seen_at: int`
|
||||
|
||||
Minimal model:
|
||||
|
||||
```python
|
||||
class OtherMint(SQLModel, table=True):
|
||||
__tablename__ = "other_mints"
|
||||
|
||||
mint_url: str = Field(primary_key=True)
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
last_seen_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
```
|
||||
|
||||
Why minimal:
|
||||
|
||||
- the only required function is mint discovery/tracking
|
||||
- unit handling can remain dynamic via existing balance queries over `sat` and `msat`
|
||||
|
||||
---
|
||||
|
||||
## 2. Add DB helpers for `other_mints`
|
||||
|
||||
In `routstr/core/db.py`, add helper functions:
|
||||
|
||||
### `register_other_mint(mint_url: str) -> None`
|
||||
|
||||
Behavior:
|
||||
|
||||
- if the mint is not present, insert it
|
||||
- if it already exists, update `last_seen_at`
|
||||
|
||||
### `list_other_mints(session) -> list[str]`
|
||||
|
||||
Behavior:
|
||||
|
||||
- return all tracked unsupported mint URLs
|
||||
|
||||
Optional later:
|
||||
|
||||
- `delete_other_mint(...)`
|
||||
- admin cleanup helpers
|
||||
|
||||
---
|
||||
|
||||
## 3. Register unsupported mints during token receipt
|
||||
|
||||
Update `recieve_token()` in `routstr/wallet.py`.
|
||||
|
||||
Current logic:
|
||||
|
||||
```python
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
```
|
||||
|
||||
Planned logic:
|
||||
|
||||
```python
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
await db.register_other_mint(token_obj.mint)
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
```
|
||||
|
||||
Why here:
|
||||
|
||||
- this is the earliest reliable point where we know the mint came in via an actual token
|
||||
- this is exactly the path that can leave foreign-mint change behind
|
||||
- it avoids needing to infer unsupported mints later from wallet internals
|
||||
|
||||
---
|
||||
|
||||
## 4. Update `fetch_all_balances()` to include `other_mints`
|
||||
|
||||
Current behavior only includes configured mints.
|
||||
|
||||
Planned behavior:
|
||||
|
||||
- load tracked unsupported mints from DB
|
||||
- combine them with `settings.cashu_mints`
|
||||
- dedupe while preserving order
|
||||
- fetch balances for all tracked mints across requested units
|
||||
|
||||
Conceptual flow:
|
||||
|
||||
```python
|
||||
tracked_mints = dedupe(settings.cashu_mints + other_mints_from_db)
|
||||
```
|
||||
|
||||
Then existing per-mint/per-unit balance logic can remain mostly unchanged.
|
||||
|
||||
This ensures that retained change on unsupported mints becomes visible in admin balance reporting.
|
||||
|
||||
---
|
||||
|
||||
## 5. Add a balance source marker
|
||||
|
||||
Extend `BalanceDetail` in `routstr/wallet.py` to identify whether a balance row comes from a configured mint or an `other_mints` entry.
|
||||
|
||||
Suggested field:
|
||||
|
||||
- `source: str` with values:
|
||||
- `"configured"`
|
||||
- `"other"`
|
||||
|
||||
Updated shape:
|
||||
|
||||
```python
|
||||
class BalanceDetail(TypedDict, total=False):
|
||||
mint_url: str
|
||||
unit: str
|
||||
source: str
|
||||
wallet_balance: int
|
||||
user_balance: int
|
||||
owner_balance: int
|
||||
error: str
|
||||
```
|
||||
|
||||
Why this helps:
|
||||
|
||||
- admin can distinguish normal configured wallet balances from foreign/unsupported balances
|
||||
- avoids confusion if unexpected mint URLs show up in the balances API/UI
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin/API impact
|
||||
|
||||
Backend impact is minimal because `/admin/api/balances` already returns `fetch_all_balances()` output.
|
||||
|
||||
Effects:
|
||||
|
||||
- supported mints continue to show as before
|
||||
- tracked unsupported mints will also appear
|
||||
- UI can optionally display the new `source` field
|
||||
|
||||
No API contract break is expected if the frontend ignores unknown fields.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payout behavior: do not change in phase 1
|
||||
|
||||
`periodic_payout()` currently only iterates over `settings.cashu_mints`.
|
||||
|
||||
Recommendation for this change:
|
||||
|
||||
- **do not** expand `periodic_payout()` to include `other_mints` yet
|
||||
- only improve visibility through balance reporting
|
||||
|
||||
Reason:
|
||||
|
||||
- automatic payout from unsupported/foreign mints may be operationally undesirable
|
||||
- visibility should come first, automation second
|
||||
|
||||
Possible future phase:
|
||||
|
||||
- add optional sweeping/payout support for `other_mints`
|
||||
- or provide an admin-triggered withdrawal/sweep flow
|
||||
|
||||
---
|
||||
|
||||
## 8. Logging improvements (optional)
|
||||
|
||||
Optional follow-up improvement in `swap_to_primary_mint()`:
|
||||
|
||||
- capture the return value from `token_wallet.melt(...)`
|
||||
- if feasible, log any reported change amount
|
||||
- otherwise, rely on wallet balance reporting to surface residual amounts
|
||||
|
||||
This is useful but not required for the first implementation.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
### `routstr/core/db.py`
|
||||
|
||||
Add:
|
||||
|
||||
- `OtherMint` SQLModel
|
||||
- `register_other_mint()`
|
||||
- `list_other_mints()`
|
||||
|
||||
### `migrations/versions/<new_revision>_add_other_mints_table.py`
|
||||
|
||||
Create migration to add the `other_mints` table.
|
||||
|
||||
### `routstr/wallet.py`
|
||||
|
||||
Update:
|
||||
|
||||
- `recieve_token()` to register unsupported mints
|
||||
- `BalanceDetail` to include `source`
|
||||
- `fetch_all_balances()` to include both configured and tracked unsupported mints
|
||||
|
||||
### `routstr/core/admin.py`
|
||||
|
||||
Likely no backend changes required unless a dedicated `other_mints` API is desired.
|
||||
|
||||
---
|
||||
|
||||
## Behavior rules
|
||||
|
||||
### Register a mint when
|
||||
|
||||
- an incoming token is processed
|
||||
- the token mint is not in `settings.cashu_mints`
|
||||
|
||||
### Do not remove automatically when
|
||||
|
||||
- balance reaches zero
|
||||
|
||||
Reason:
|
||||
|
||||
- historical visibility is useful
|
||||
- avoids flapping entries in the admin balance list
|
||||
- mint may receive additional unsupported tokens later
|
||||
|
||||
Potential future enhancement:
|
||||
|
||||
- admin endpoint to prune zero-balance `other_mints`
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
### A mint later becomes configured
|
||||
|
||||
If a mint in `other_mints` is later added to `settings.cashu_mints`:
|
||||
|
||||
- deduplication prevents duplicate balance rows
|
||||
- `source` should resolve to `configured`
|
||||
|
||||
### Unsupported mint with zero balance
|
||||
|
||||
A tracked unsupported mint may show zero balances.
|
||||
|
||||
Initial recommendation:
|
||||
|
||||
- allow it to appear
|
||||
- consider later filtering zero-balance `other` rows if the UI becomes noisy
|
||||
|
||||
### Units
|
||||
|
||||
Balance fetching can continue to query both `sat` and `msat` for each tracked mint.
|
||||
|
||||
If a mint has no proofs in one unit, current error/zero handling can continue to apply.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### DB tests
|
||||
|
||||
- registering a new unsupported mint inserts a row
|
||||
- registering the same mint again updates `last_seen_at` without duplication
|
||||
- listing other mints returns expected mint URLs
|
||||
|
||||
### Wallet tests
|
||||
|
||||
#### `recieve_token()`
|
||||
|
||||
- when mint is unsupported, `db.register_other_mint()` is called before swap
|
||||
- when mint is configured, `db.register_other_mint()` is not called
|
||||
|
||||
#### `fetch_all_balances()`
|
||||
|
||||
- includes configured mints
|
||||
- includes `other_mints` from DB
|
||||
- dedupes if a mint exists in both configured and other lists
|
||||
- sets `source` correctly
|
||||
|
||||
### Regression tests
|
||||
|
||||
- existing trusted mint balance reporting remains unchanged
|
||||
- `/admin/api/balances` continues to work
|
||||
|
||||
---
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Add `OtherMint` model to `routstr/core/db.py`
|
||||
2. Add Alembic migration for `other_mints`
|
||||
3. Add `register_other_mint()` and `list_other_mints()` helpers
|
||||
4. Update `recieve_token()` to register unsupported mints
|
||||
5. Update `fetch_all_balances()` to union configured + tracked other mints
|
||||
6. Add `source` to `BalanceDetail`
|
||||
7. Add/adjust tests
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This change solves an operational visibility problem:
|
||||
|
||||
- unsupported incoming mints can leave retained change in foreign wallets
|
||||
- those funds are currently preserved but not surfaced in balance reporting
|
||||
- introducing `other_mints` makes those mints discoverable and auditable
|
||||
- expanding `fetch_all_balances()` ensures their balances are visible in admin tooling
|
||||
|
||||
Recommended scope for the first pass:
|
||||
|
||||
- track unsupported mints in DB
|
||||
- include them in balance reporting
|
||||
- mark them as `source="other"`
|
||||
- do not yet change payout/sweeping behavior
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+22
-1
@@ -126,11 +126,24 @@ def create_model_mappings(
|
||||
if isinstance(db_id, int):
|
||||
providers_by_db_id[db_id] = upstream
|
||||
|
||||
# Group upstreams by URL and keep only the one with the lowest fee for each URL
|
||||
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
for upstream in upstreams:
|
||||
url = getattr(upstream, "base_url", "")
|
||||
if url not in upstreams_by_url:
|
||||
upstreams_by_url[url] = []
|
||||
upstreams_by_url[url].append(upstream)
|
||||
|
||||
filtered_upstreams: list["BaseUpstreamProvider"] = []
|
||||
for providers in upstreams_by_url.values():
|
||||
best_provider = min(providers, key=lambda p: p.provider_fee)
|
||||
filtered_upstreams.append(best_provider)
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
other_upstreams: list["BaseUpstreamProvider"] = []
|
||||
|
||||
for upstream in upstreams:
|
||||
for upstream in filtered_upstreams:
|
||||
base_url = getattr(upstream, "base_url", "")
|
||||
if base_url == "https://openrouter.ai/api/v1":
|
||||
openrouter = upstream
|
||||
@@ -204,6 +217,10 @@ def create_model_mappings(
|
||||
if prefixed_id not in aliases:
|
||||
aliases.append(prefixed_id)
|
||||
|
||||
# Register forwarded_model_id as a routable alias
|
||||
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||
aliases.append(model_to_use.forwarded_model_id)
|
||||
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
@@ -292,6 +309,10 @@ def create_model_mappings(
|
||||
if prefixed_id not in aliases:
|
||||
aliases.append(prefixed_id)
|
||||
|
||||
# Register forwarded_model_id as a routable alias
|
||||
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||
aliases.append(model_to_use.forwarded_model_id)
|
||||
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream_for_override)
|
||||
seen_model_provider.add(dedupe_key)
|
||||
|
||||
+200
-38
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import case
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
@@ -22,6 +23,7 @@ from .payment.cost_calculation import (
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
payments_logger = get_logger("routstr.payments")
|
||||
|
||||
# TODO: implement prepaid api key (not like it was before)
|
||||
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
||||
@@ -372,12 +374,12 @@ async def validate_bearer_key(
|
||||
},
|
||||
)
|
||||
|
||||
key_preview = bearer_key[:10] + "..." if len(bearer_key) > 10 else bearer_key
|
||||
logger.error(
|
||||
"Invalid API key format",
|
||||
f"Invalid API key format: preview={key_preview!r} length={len(bearer_key)} "
|
||||
f"(expected 'sk-...' or 'cashu...' token)",
|
||||
extra={
|
||||
"key_preview": bearer_key[:10] + "..."
|
||||
if len(bearer_key) > 10
|
||||
else bearer_key,
|
||||
"key_preview": key_preview,
|
||||
"key_length": len(bearer_key),
|
||||
},
|
||||
)
|
||||
@@ -386,7 +388,7 @@ async def validate_bearer_key(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Invalid API key",
|
||||
"message": "Invalid API key format. Expected an 'sk-...' API key or a 'cashu...' token.",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
@@ -584,6 +586,18 @@ async def pay_for_request(
|
||||
"total_requests": billing_key.total_requests,
|
||||
},
|
||||
)
|
||||
payments_logger.info(
|
||||
"RESERVE",
|
||||
extra={
|
||||
"event": "reserve",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost_reserved": cost_per_request,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
},
|
||||
)
|
||||
|
||||
return cost_per_request
|
||||
|
||||
@@ -635,6 +649,17 @@ async def revert_pay_for_request(
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
payments_logger.info(
|
||||
"REVERT",
|
||||
extra={
|
||||
"event": "revert",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost_reverted": cost_per_request,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -728,11 +753,32 @@ async def adjust_payment_for_tokens(
|
||||
},
|
||||
)
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
if billing_key.reserved_balance < deducted_max_cost:
|
||||
logger.error(
|
||||
"reserved_balance below deducted_max_cost before MaxCost finalization — clamping to 0",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"balance": billing_key.balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
safe_reserved = case(
|
||||
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||
else_=0,
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
reserved_balance=safe_reserved,
|
||||
balance=col(ApiKey.balance) - cost.total_msats,
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
)
|
||||
@@ -741,13 +787,17 @@ async def adjust_payment_for_tokens(
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_safe_reserved = case(
|
||||
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||
else_=0,
|
||||
)
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
reserved_balance=child_safe_reserved,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
@@ -782,6 +832,23 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
"event": "finalize",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"cost_reserved": deducted_max_cost,
|
||||
"cost_charged": cost.total_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"finalize_type": "max_cost",
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
@@ -815,12 +882,32 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
if billing_key.reserved_balance < deducted_max_cost:
|
||||
logger.error(
|
||||
"reserved_balance below deducted_max_cost on exact-cost finalization — clamping to 0",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"total_cost_msats": total_cost_msats,
|
||||
"balance": billing_key.balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
exact_safe_reserved = case(
|
||||
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||
else_=0,
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
reserved_balance=exact_safe_reserved,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
@@ -829,13 +916,17 @@ async def adjust_payment_for_tokens(
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_exact_safe_reserved = case(
|
||||
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||
else_=0,
|
||||
)
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
reserved_balance=child_exact_safe_reserved,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
@@ -844,44 +935,55 @@ async def adjust_payment_for_tokens(
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more than reserved, finalize by releasing reservation and charging total
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
"event": "finalize",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": billing_key.balance,
|
||||
"sufficient_balance": billing_key.balance >= cost_difference,
|
||||
"model": model,
|
||||
"cost_reserved": deducted_max_cost,
|
||||
"cost_charged": total_cost_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"finalize_type": "exact",
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
# actual cost exceeded discounted reservation (due to tolerance_percentage)
|
||||
if cost_difference > 0:
|
||||
# Always release the reservation and charge min(actual_cost, balance).
|
||||
# Using a CASE expression makes this a single atomic UPDATE — no
|
||||
# multi-level fallback needed and balance can never go negative.
|
||||
chargeable = case(
|
||||
(col(ApiKey.balance) >= total_cost_msats, total_cost_msats),
|
||||
else_=col(ApiKey.balance),
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - chargeable,
|
||||
total_spent=col(ApiKey.total_spent) + chargeable,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
total_spent=col(ApiKey.total_spent) + min(billing_key.balance, total_cost_msats),
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
@@ -889,11 +991,10 @@ async def adjust_payment_for_tokens(
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
@@ -904,9 +1005,28 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
"event": "finalize",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"cost_reserved": deducted_max_cost,
|
||||
"cost_charged": total_cost_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"finalize_type": "overrun",
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Guard fired: reservation was already released by a concurrent
|
||||
# finalization for this key. Nothing left to do.
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge - releasing reservation",
|
||||
"Finalization skipped - reservation already released",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
@@ -914,7 +1034,6 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
@@ -929,12 +1048,33 @@ async def adjust_payment_for_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
if billing_key.reserved_balance < deducted_max_cost:
|
||||
logger.error(
|
||||
"reserved_balance below deducted_max_cost on refund finalization — clamping to 0",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"total_cost_msats": total_cost_msats,
|
||||
"refund_amount": refund,
|
||||
"balance": billing_key.balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_safe_reserved = case(
|
||||
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||
else_=0,
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
reserved_balance=refund_safe_reserved,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
@@ -943,13 +1083,17 @@ async def adjust_payment_for_tokens(
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_refund_safe_reserved = case(
|
||||
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||
else_=0,
|
||||
)
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
reserved_balance=child_refund_safe_reserved,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
@@ -986,6 +1130,24 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
"event": "finalize",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"cost_reserved": deducted_max_cost,
|
||||
"cost_charged": total_cost_msats,
|
||||
"refunded": refund,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"finalize_type": "refund",
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
|
||||
+128
-8
@@ -5,11 +5,18 @@ from time import monotonic
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
CashuTransaction,
|
||||
get_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
@@ -154,9 +161,23 @@ async def topup_wallet_endpoint(
|
||||
raise HTTPException(status_code=400, detail="Token already spent")
|
||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Token value is too small to cover swap fees. {error_msg}",
|
||||
)
|
||||
elif "failed to melt" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to swap foreign mint token. {error_msg}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Failed to redeem token")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"msats": amount_msats}
|
||||
|
||||
@@ -190,19 +211,57 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
|
||||
_refund_cache[key] = (expiry, value)
|
||||
|
||||
|
||||
@router.post("/refund")
|
||||
@router.post("/refund", response_model=None)
|
||||
async def refund_wallet_endpoint(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
x_cashu: Annotated[str | None, Header()] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
if not authorization.startswith("Bearer "):
|
||||
) -> JSONResponse | dict[str, str]:
|
||||
if x_cashu:
|
||||
# Find the "in" transaction by the original payment token
|
||||
in_tx_result = await session.exec(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.token == x_cashu,
|
||||
CashuTransaction.type == "in",
|
||||
)
|
||||
)
|
||||
in_tx = in_tx_result.first()
|
||||
if in_tx is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
|
||||
# Use the request_id to find the associated "out" (refund) transaction
|
||||
if in_tx.request_id is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
|
||||
out_tx_result = await session.exec(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.request_id == in_tx.request_id,
|
||||
CashuTransaction.type == "out",
|
||||
)
|
||||
)
|
||||
out_tx = out_tx_result.first()
|
||||
if out_tx is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if out_tx.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
|
||||
out_tx.collected = True
|
||||
session.add(out_tx)
|
||||
await session.commit()
|
||||
body: dict[str, str] = {"token": out_tx.token}
|
||||
if out_tx.unit == "sat":
|
||||
body["sats"] = str(out_tx.amount)
|
||||
else:
|
||||
body["msats"] = str(out_tx.amount)
|
||||
return JSONResponse(content=body, headers={"X-Cashu": out_tx.token})
|
||||
|
||||
if authorization is None or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
|
||||
)
|
||||
|
||||
bearer_value: str = authorization[7:]
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
|
||||
if key.total_balance <= 0:
|
||||
@@ -215,6 +274,12 @@ async def refund_wallet_endpoint(
|
||||
detail="Cannot refund child key. Please refund the parent key instead.",
|
||||
)
|
||||
|
||||
if key.reserved_balance > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
@@ -251,6 +316,17 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
|
||||
if "token" in result:
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: cashu token issued",
|
||||
extra={
|
||||
"path": "/v1/wallet/refund",
|
||||
"token": result["token"],
|
||||
"amount": remaining_balance,
|
||||
"currency": key.refund_currency or "sat",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
raise
|
||||
@@ -269,11 +345,34 @@ async def refund_wallet_endpoint(
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
previous_reserved_balance = key.reserved_balance
|
||||
key.balance = 0
|
||||
key.reserved_balance = 0
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
if "token" in result:
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=result["token"],
|
||||
amount=remaining_balance,
|
||||
unit=key.refund_currency or "sat",
|
||||
mint_url=key.refund_mint_url,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="apikey",
|
||||
)
|
||||
except Exception:
|
||||
pass # store_cashu_transaction already logs
|
||||
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: refund successful",
|
||||
extra={
|
||||
"refunded_msats": remaining_balance_msats,
|
||||
"previous_reserved_balance": previous_reserved_balance,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -401,6 +500,27 @@ async def reset_child_key_spent(
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
@router.get("/cashu-refund/{payment_token_hash}")
|
||||
async def get_cashu_refund(
|
||||
payment_token_hash: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
||||
result = await session.get(CashuTransaction, payment_token_hash)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if result.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
result.collected = True
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
return {
|
||||
"refund_token": result.token,
|
||||
"amount": result.amount,
|
||||
"unit": result.unit,
|
||||
}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
|
||||
+348
-55
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
@@ -17,7 +17,13 @@ from ..wallet import (
|
||||
send_token,
|
||||
slow_filter_spend_proofs,
|
||||
)
|
||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
||||
from .db import (
|
||||
ApiKey,
|
||||
CashuTransaction,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
)
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService, settings
|
||||
@@ -27,55 +33,20 @@ logger = get_logger(__name__)
|
||||
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
|
||||
|
||||
admin_sessions: dict[str, int] = {}
|
||||
ADMIN_SESSION_DURATION = 12 * 60 * 60
|
||||
ADMIN_SESSION_DURATION = 3600
|
||||
# Usage analytics remain queryable up to 12 months.
|
||||
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
|
||||
|
||||
|
||||
def _current_timestamp() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None:
|
||||
current_timestamp = (
|
||||
now_timestamp if now_timestamp is not None else _current_timestamp()
|
||||
)
|
||||
expired_tokens = [
|
||||
token
|
||||
for token, expiry_timestamp in admin_sessions.items()
|
||||
if expiry_timestamp <= current_timestamp
|
||||
]
|
||||
for token in expired_tokens:
|
||||
admin_sessions.pop(token, None)
|
||||
|
||||
|
||||
def _raise_unauthorized(detail: str) -> NoReturn:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def require_admin_api(request: Request) -> None:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
_raise_unauthorized("Missing bearer token")
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
expiry = admin_sessions.get(token)
|
||||
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
|
||||
return
|
||||
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
_raise_unauthorized("Missing bearer token")
|
||||
|
||||
now_timestamp = _current_timestamp()
|
||||
expiry_timestamp = admin_sessions.get(token)
|
||||
if expiry_timestamp is None:
|
||||
_raise_unauthorized("Invalid session token")
|
||||
|
||||
if expiry_timestamp <= now_timestamp:
|
||||
admin_sessions.pop(token, None)
|
||||
_raise_unauthorized("Session expired")
|
||||
|
||||
_cleanup_expired_admin_sessions(now_timestamp)
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
|
||||
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
||||
@@ -244,10 +215,18 @@ async def admin_login(
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION
|
||||
expiry_timestamp = (
|
||||
int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
|
||||
)
|
||||
admin_sessions[token] = expiry_timestamp
|
||||
|
||||
_cleanup_expired_admin_sessions()
|
||||
expired_tokens = [
|
||||
t
|
||||
for t, exp in admin_sessions.items()
|
||||
if exp <= int(datetime.now(timezone.utc).timestamp())
|
||||
]
|
||||
for t in expired_tokens:
|
||||
del admin_sessions[t]
|
||||
|
||||
return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION}
|
||||
|
||||
@@ -316,6 +295,7 @@ class ModelCreate(BaseModel):
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
enabled: bool = True
|
||||
forwarded_model_id: str | None = None
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
@@ -360,6 +340,7 @@ async def upsert_provider_model(
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
)
|
||||
existing_row.enabled = payload.enabled
|
||||
existing_row.forwarded_model_id = payload.forwarded_model_id or payload.id
|
||||
|
||||
session.add(existing_row)
|
||||
await session.commit()
|
||||
@@ -392,6 +373,7 @@ async def upsert_provider_model(
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
@@ -574,6 +556,7 @@ class UpstreamProviderCreate(BaseModel):
|
||||
api_version: str | None = None
|
||||
enabled: bool = True
|
||||
provider_fee: float = 1.01
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
class UpstreamProviderUpdate(BaseModel):
|
||||
@@ -583,6 +566,7 @@ class UpstreamProviderUpdate(BaseModel):
|
||||
api_version: str | None = None
|
||||
enabled: bool | None = None
|
||||
provider_fee: float | None = None
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||
@@ -599,6 +583,9 @@ async def get_upstream_providers() -> list[dict[str, object]]:
|
||||
"api_version": p.api_version,
|
||||
"enabled": p.enabled,
|
||||
"provider_fee": p.provider_fee,
|
||||
"provider_settings": json.loads(p.provider_settings)
|
||||
if p.provider_settings
|
||||
else None,
|
||||
}
|
||||
for p in providers
|
||||
]
|
||||
@@ -628,6 +615,9 @@ async def create_upstream_provider(
|
||||
api_version=payload.api_version,
|
||||
enabled=payload.enabled,
|
||||
provider_fee=payload.provider_fee,
|
||||
provider_settings=json.dumps(payload.provider_settings)
|
||||
if payload.provider_settings
|
||||
else None,
|
||||
)
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -643,6 +633,7 @@ async def create_upstream_provider(
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": payload.provider_settings,
|
||||
}
|
||||
|
||||
|
||||
@@ -662,6 +653,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -688,6 +682,8 @@ async def update_upstream_provider(
|
||||
provider.enabled = payload.enabled
|
||||
if payload.provider_fee is not None:
|
||||
provider.provider_fee = payload.provider_fee
|
||||
if payload.provider_settings is not None:
|
||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
@@ -703,6 +699,9 @@ async def update_upstream_provider(
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -760,7 +759,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
)
|
||||
|
||||
db_model_ids = {model.id for model in db_models}
|
||||
filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids]
|
||||
filtered_remote_models = [
|
||||
m for m in upstream_models if m.id not in db_model_ids
|
||||
]
|
||||
|
||||
return {
|
||||
"provider": {
|
||||
@@ -822,6 +823,47 @@ class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
class TopupTokenRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup-token",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def topup_provider_with_token(
|
||||
provider_id: int, payload: TopupTokenRequest
|
||||
) -> dict:
|
||||
"""Redeem a Cashu token for an upstream provider."""
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
resp = await client.post(
|
||||
f"{clean_url}/v1/balance/topup",
|
||||
json={"cashu_token": payload.token},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True, "message": "Token redeemed successfully"}
|
||||
else:
|
||||
logger.error(f"Upstream token topup failed: {resp.text}")
|
||||
try:
|
||||
error_detail = resp.json()
|
||||
except Exception:
|
||||
error_detail = resp.text
|
||||
return {"ok": False, "message": f"Upstream error: {error_detail}"}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -837,18 +879,84 @@ async def initiate_provider_topup(
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
|
||||
# For Routstr providers, we might be doing a Lightning top-up or a direct token transfer
|
||||
if provider.provider_type == "routstr":
|
||||
# UI sends sats for Routstr topup
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
request_json = {
|
||||
"amount_sats": int(payload.amount),
|
||||
"purpose": "topup",
|
||||
"api_key": provider.api_key,
|
||||
}
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {provider.api_key}"}
|
||||
if provider.api_key
|
||||
else {}
|
||||
)
|
||||
|
||||
last_status_code = 500
|
||||
last_error_detail: object = "Failed to create top-up invoice"
|
||||
|
||||
# Some upstream Routstr nodes fail the first invoice request after warm-up
|
||||
# and succeed immediately on retry. Retry once here so the UI stays single-click.
|
||||
for attempt in range(2):
|
||||
resp = await client.post(
|
||||
f"{clean_url}/v1/balance/lightning/invoice",
|
||||
json=request_json,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"payment_request": data.get("bolt11"),
|
||||
"invoice_id": data.get("invoice_id"),
|
||||
"status": "pending",
|
||||
},
|
||||
}
|
||||
|
||||
logger.error(
|
||||
f"Upstream topup request failed: {resp.text}",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"attempt": attempt + 1,
|
||||
"status_code": resp.status_code,
|
||||
},
|
||||
)
|
||||
try:
|
||||
last_error_detail = resp.json()
|
||||
except Exception:
|
||||
last_error_detail = resp.text
|
||||
last_status_code = resp.status_code
|
||||
|
||||
if resp.status_code < 500 or attempt == 1:
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=last_status_code, detail=last_error_detail
|
||||
)
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
@@ -899,6 +1007,25 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# For Routstr providers, proxy the status check
|
||||
if provider.provider_type == "routstr":
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
resp = await client.get(
|
||||
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
|
||||
headers={"Authorization": f"Bearer {provider.api_key}"}
|
||||
if provider.api_key
|
||||
else {},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
status_data = resp.json()
|
||||
return {"ok": True, "paid": status_data.get("status") == "paid"}
|
||||
else:
|
||||
logger.error(f"Upstream status check failed: {resp.text}")
|
||||
return {"ok": False, "paid": False}
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
@@ -926,7 +1053,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
"""Get the current balance for an upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
@@ -934,6 +1061,61 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
# For Routstr providers, proxy the balance check
|
||||
if provider.provider_type == "routstr":
|
||||
import httpx
|
||||
|
||||
clean_url = provider.base_url.rstrip("/")
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{clean_url}/v1/balance/info",
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
logger.error(
|
||||
"Timed out fetching Routstr provider balance",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"base_url": clean_url,
|
||||
"upstream_url": f"{clean_url}/v1/balance/info",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Timed out contacting upstream Routstr provider",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
logger.error(
|
||||
"Failed to fetch Routstr provider balance",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"base_url": clean_url,
|
||||
"upstream_url": f"{clean_url}/v1/balance/info",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Failed to contact upstream Routstr provider",
|
||||
) from exc
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# Return balance in sats
|
||||
balance = data.get("balance", 0)
|
||||
if isinstance(balance, (int, float)):
|
||||
return {"ok": True, "balance_data": balance // 1000}
|
||||
return {"ok": True, "balance_data": balance}
|
||||
else:
|
||||
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
|
||||
return {"ok": False, "balance_data": None}
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
@@ -1146,3 +1328,114 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
||||
continue
|
||||
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
||||
async def get_transactions_api(
|
||||
type: str | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
async with create_session() as session:
|
||||
from sqlmodel import col
|
||||
|
||||
stmt = select(CashuTransaction)
|
||||
if type:
|
||||
stmt = stmt.where(CashuTransaction.type == type)
|
||||
if status:
|
||||
if status == "collected":
|
||||
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
|
||||
elif status == "swept":
|
||||
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
|
||||
elif status == "pending":
|
||||
stmt = stmt.where(
|
||||
CashuTransaction.collected == False, # noqa: E712
|
||||
CashuTransaction.swept == False, # noqa: E712
|
||||
)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
stmt = stmt.where(
|
||||
(col(CashuTransaction.id).like(search_pattern))
|
||||
| (col(CashuTransaction.token).like(search_pattern))
|
||||
| (col(CashuTransaction.request_id).like(search_pattern))
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
|
||||
|
||||
results = await session.exec(stmt)
|
||||
transactions = results.all()
|
||||
|
||||
return {
|
||||
"transactions": [tx.dict() for tx in transactions],
|
||||
"total": len(transactions),
|
||||
}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider_row = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider_row:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
if provider_row.provider_type != "routstr":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Refund only supported for Routstr providers"
|
||||
)
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if not isinstance(provider, RoutstrUpstreamProvider):
|
||||
raise HTTPException(status_code=400, detail="Invalid provider instance")
|
||||
|
||||
try:
|
||||
# Request refund from upstream
|
||||
data = await provider.refund_balance()
|
||||
if "error" in data:
|
||||
# If the upstream returned an OpenAI-style error (like the model unknown error)
|
||||
# it means the request likely didn't even reach the refund endpoint handler
|
||||
# but was intercepted by the proxy layer.
|
||||
error_info = data.get("error", {})
|
||||
message = (
|
||||
error_info.get("message")
|
||||
if isinstance(error_info, dict)
|
||||
else str(error_info)
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Upstream refund failed: {message}",
|
||||
}
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return {"ok": False, "message": "Upstream did not return a token"}
|
||||
|
||||
# Receive token into local wallet
|
||||
from ..wallet import recieve_token
|
||||
|
||||
try:
|
||||
# Use current wallet to receive
|
||||
await recieve_token(token)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Successfully received refund from upstream provider",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to receive refund token: {e}")
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"Failed to receive refund token: {str(e)}",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Refund failed for provider {provider_id}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
+104
-2
@@ -2,12 +2,15 @@ import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.util.exc import CommandError
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -103,6 +106,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
||||
default=None, description="JSON array of model alias IDs"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||
forwarded_model_id: str | None = Field(
|
||||
default=None,
|
||||
description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.",
|
||||
)
|
||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||
|
||||
|
||||
@@ -128,6 +135,65 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
|
||||
|
||||
class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "cashu_transactions"
|
||||
|
||||
id: str = Field(
|
||||
primary_key=True,
|
||||
default_factory=lambda: uuid.uuid4().hex,
|
||||
description="Unique transaction identifier",
|
||||
)
|
||||
token: str = Field(description="Serialized Cashu token")
|
||||
amount: int = Field(description="Amount in the token's unit")
|
||||
unit: str = Field(description="Token unit (sat or msat)")
|
||||
mint_url: str | None = Field(default=None, description="Mint URL for the token")
|
||||
type: str = Field(default="out", description="Transaction type: in or out")
|
||||
request_id: str | None = Field(default=None, description="Associated request ID")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()),
|
||||
description="Unix timestamp",
|
||||
)
|
||||
collected: bool = Field(default=False)
|
||||
swept: bool = Field(default=False)
|
||||
source: str = Field(
|
||||
default="x-cashu",
|
||||
description="Payment source: x-cashu or apikey",
|
||||
)
|
||||
|
||||
|
||||
async def store_cashu_transaction(
|
||||
token: str,
|
||||
amount: int,
|
||||
unit: str,
|
||||
mint_url: str | None = None,
|
||||
typ: str = "out",
|
||||
request_id: str | None = None,
|
||||
collected: bool = False,
|
||||
created_at: int | None = None,
|
||||
source: str = "x-cashu",
|
||||
) -> None:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
tx = CashuTransaction(
|
||||
token=token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint_url,
|
||||
type=typ,
|
||||
request_id=request_id,
|
||||
collected=collected,
|
||||
created_at=created_at or int(time.time()),
|
||||
source=source,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to store cashu transaction: {e} (type={typ})",
|
||||
extra={"error": str(e), "type": typ},
|
||||
)
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
@@ -148,6 +214,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
provider_fee: float = Field(
|
||||
default=1.01, description="Provider fee multiplier (default 1%)"
|
||||
)
|
||||
provider_settings: str | None = Field(
|
||||
default=None, description="JSON string for provider-specific settings"
|
||||
)
|
||||
models: list["ModelRow"] = Relationship(
|
||||
back_populates="upstream_provider",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
@@ -224,6 +293,17 @@ def fix_cashu_migrations() -> None:
|
||||
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
||||
|
||||
|
||||
def _clear_alembic_version() -> None:
|
||||
"""Clear the alembic_version table so stamp/upgrade can proceed."""
|
||||
sync_url = DATABASE_URL.replace("+aiosqlite", "")
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
eng = create_engine(sync_url)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Run Alembic migrations programmatically."""
|
||||
try:
|
||||
@@ -245,8 +325,30 @@ def run_migrations() -> None:
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Run migrations to the latest revision
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
except CommandError as e:
|
||||
if "Can't locate revision" in str(e):
|
||||
logger.warning(
|
||||
"Database stamped with unknown revision (likely from another branch). "
|
||||
"Re-stamping to current head.",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
_clear_alembic_version()
|
||||
command.stamp(alembic_cfg, "head")
|
||||
else:
|
||||
raise
|
||||
except OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.warning(
|
||||
"Migration hit a column that already exists (likely added via "
|
||||
"create_all on another branch). Stamping to current head.",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
_clear_alembic_version()
|
||||
command.stamp(alembic_cfg, "head")
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info("Database migrations completed successfully")
|
||||
|
||||
|
||||
+28
-5
@@ -12,12 +12,17 @@ from starlette.exceptions import HTTPException
|
||||
|
||||
from ..auth import periodic_key_reset
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..nostr import (
|
||||
announce_provider,
|
||||
providers_cache_refresher,
|
||||
publish_usage_analytics,
|
||||
)
|
||||
from ..nostr.discovery import providers_router
|
||||
from ..payment.models import models_router, update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
@@ -31,9 +36,9 @@ setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.4.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -44,10 +49,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
analytics_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
auto_topup_task = None
|
||||
refund_sweep_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -101,9 +109,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
if global_settings.nsec:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
analytics_task = asyncio.create_task(publish_usage_analytics())
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
key_reset_task = asyncio.create_task(periodic_key_reset())
|
||||
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
||||
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
|
||||
|
||||
yield
|
||||
|
||||
@@ -127,6 +138,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if analytics_task is not None:
|
||||
analytics_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
@@ -135,6 +148,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
model_maps_refresh_task.cancel()
|
||||
if key_reset_task is not None:
|
||||
key_reset_task.cancel()
|
||||
if auto_topup_task is not None:
|
||||
auto_topup_task.cancel()
|
||||
if refund_sweep_task is not None:
|
||||
refund_sweep_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -146,6 +163,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if analytics_task is not None:
|
||||
tasks_to_wait.append(analytics_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
@@ -154,6 +173,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
if key_reset_task is not None:
|
||||
tasks_to_wait.append(key_reset_task)
|
||||
if auto_topup_task is not None:
|
||||
tasks_to_wait.append(auto_topup_task)
|
||||
if refund_sweep_task is not None:
|
||||
tasks_to_wait.append(refund_sweep_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
@@ -174,7 +197,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["x-routstr-request-id"],
|
||||
expose_headers=["x-routstr-request-id", "x-cashu"],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
|
||||
@@ -38,11 +38,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract request info
|
||||
client_host = None
|
||||
if request.client:
|
||||
client_host = request.client.host
|
||||
|
||||
# Log incoming request
|
||||
logger.info(
|
||||
"Incoming request",
|
||||
@@ -51,7 +46,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
"client_host": client_host,
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
@@ -100,7 +94,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_host": client_host,
|
||||
},
|
||||
)
|
||||
if hasattr(response, "headers"):
|
||||
@@ -120,7 +113,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_host": client_host,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ class Settings(BaseSettings):
|
||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||
child_key_cost: int = Field(default=0, env="CHILD_KEY_COST")
|
||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||
reset_reserved_balance_on_startup: bool = Field(
|
||||
@@ -74,6 +74,7 @@ class Settings(BaseSettings):
|
||||
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
|
||||
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
|
||||
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
|
||||
refund_sweep_ttl_seconds: int = Field(default=604800, env="REFUND_SWEEP_TTL_SECONDS")
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
@@ -92,6 +93,20 @@ class Settings(BaseSettings):
|
||||
|
||||
# Discovery
|
||||
relays: list[str] = Field(default_factory=list, env="RELAYS")
|
||||
enable_analytics_sharing: bool = Field(
|
||||
default=True, env="ENABLE_ANALYTICS_SHARING"
|
||||
)
|
||||
|
||||
def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discard unknown keys from persisted settings."""
|
||||
normalized: dict[str, Any] = {}
|
||||
known_fields = Settings.__fields__
|
||||
|
||||
for key, value in data.items():
|
||||
if key in known_fields:
|
||||
normalized[key] = value
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _compute_primary_mint(cashu_mints: list[str]) -> str:
|
||||
@@ -231,16 +246,21 @@ class SettingsService:
|
||||
|
||||
db_id, db_data, _updated_at = row
|
||||
try:
|
||||
db_json = (
|
||||
db_json_raw = (
|
||||
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
|
||||
)
|
||||
if not isinstance(db_json_raw, dict):
|
||||
db_json_raw = {}
|
||||
except Exception:
|
||||
db_json = {}
|
||||
db_json_raw = {}
|
||||
db_json = _normalize_settings_data(db_json_raw)
|
||||
|
||||
valid_fields = set(env_resolved.dict().keys())
|
||||
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, "", [], {})}
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
|
||||
)
|
||||
merged_dict = Settings(**merged_dict).dict()
|
||||
|
||||
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
||||
if not merged_dict.get("primary_mint"):
|
||||
@@ -248,7 +268,7 @@ class SettingsService:
|
||||
merged_dict.get("cashu_mints", [])
|
||||
)
|
||||
|
||||
if any(k not in db_json for k in merged_dict.keys()):
|
||||
if db_json_raw != merged_dict:
|
||||
await db_session.exec( # type: ignore
|
||||
text(
|
||||
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
|
||||
@@ -271,7 +291,7 @@ class SettingsService:
|
||||
) -> Settings:
|
||||
async with cls._lock:
|
||||
current = cls.get()
|
||||
candidate_dict = {**current.dict(), **partial}
|
||||
candidate_dict = {**current.dict(), **_normalize_settings_data(partial)}
|
||||
candidate = Settings(**candidate_dict)
|
||||
from sqlmodel import text
|
||||
|
||||
@@ -305,8 +325,10 @@ class SettingsService:
|
||||
raise RuntimeError("Settings row missing")
|
||||
(data_str,) = row
|
||||
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
|
||||
valid_fields = set(settings.dict().keys())
|
||||
# Update in-place
|
||||
for k, v in data.items():
|
||||
setattr(settings, k, v)
|
||||
if k in valid_fields:
|
||||
setattr(settings, k, v)
|
||||
cls._current = settings
|
||||
return settings
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .analytics import publish_usage_analytics
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = ["providers_cache_refresher", "announce_provider"]
|
||||
__all__ = ["providers_cache_refresher", "announce_provider", "publish_usage_analytics"]
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nostr usage analytics publisher.
|
||||
Publishes a single replaceable analytics snapshot for each provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from nostr.event import Event
|
||||
from nostr.key import PrivateKey
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.log_manager import log_manager
|
||||
from ..core.settings import settings
|
||||
from .listing import nsec_to_keypair, publish_to_relay
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ANALYTICS_KIND = 38422
|
||||
ANALYTICS_SCHEMA = "routstr.analytics.snapshot.v1"
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
PUBLISH_INTERVAL_SECONDS = 15 * 60
|
||||
DISABLED_POLL_SECONDS = 60
|
||||
DASHBOARD_WINDOW_HOURS = 24
|
||||
DASHBOARD_INTERVAL_MINUTES = 60
|
||||
MODEL_LIMIT = 20
|
||||
WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = (
|
||||
("24h", 24, 60),
|
||||
("7d", 7 * 24, 6 * 60),
|
||||
("30d", 30 * 24, 24 * 60),
|
||||
("3m", 90 * 24, 24 * 60),
|
||||
("1y", 365 * 24, 7 * 24 * 60),
|
||||
)
|
||||
|
||||
|
||||
def _event_to_dict(ev: Event) -> dict[str, Any]:
|
||||
return {
|
||||
"id": ev.id,
|
||||
"pubkey": ev.public_key,
|
||||
"created_at": ev.created_at,
|
||||
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
|
||||
"tags": ev.tags,
|
||||
"content": ev.content,
|
||||
"sig": ev.signature,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_provider_id(public_key_hex: str) -> str:
|
||||
explicit_provider_id = (settings.provider_id or "").strip()
|
||||
if explicit_provider_id:
|
||||
return explicit_provider_id
|
||||
return public_key_hex[:12]
|
||||
|
||||
|
||||
def _resolve_endpoint_urls() -> list[str]:
|
||||
urls: list[str] = []
|
||||
http_url = (settings.http_url or "").strip()
|
||||
onion_url = (settings.onion_url or "").strip()
|
||||
|
||||
if http_url and http_url != "http://localhost:8000":
|
||||
urls.append(http_url)
|
||||
|
||||
if onion_url:
|
||||
if onion_url.endswith(".onion") and not (
|
||||
onion_url.startswith("http://") or onion_url.startswith("https://")
|
||||
):
|
||||
onion_url = f"http://{onion_url}"
|
||||
urls.append(onion_url)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def _resolve_relays() -> list[str]:
|
||||
configured = [url.strip() for url in settings.relays if url.strip()]
|
||||
return configured if configured else list(DEFAULT_RELAYS)
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float:
|
||||
if isinstance(value, bool):
|
||||
return float(int(value))
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _aggregate_top_model_usage(
|
||||
model_usage_mix: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
top_models_raw = model_usage_mix.get("top_models", [])
|
||||
mix_metrics_raw = model_usage_mix.get("metrics", [])
|
||||
|
||||
top_models = [model for model in top_models_raw if isinstance(model, str)]
|
||||
metrics = [row for row in mix_metrics_raw if isinstance(row, dict)]
|
||||
|
||||
model_totals: dict[str, dict[str, float | int]] = {
|
||||
model: {
|
||||
"successful_requests": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for model in top_models
|
||||
}
|
||||
others = {
|
||||
"successful_requests": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
|
||||
for metric in metrics:
|
||||
model_counts = metric.get("model_counts", {})
|
||||
model_revenue = metric.get("model_revenue_msats", {})
|
||||
model_tokens = metric.get("model_tokens", {})
|
||||
|
||||
if isinstance(model_counts, dict):
|
||||
for model, count in model_counts.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["successful_requests"] += _to_int(count)
|
||||
|
||||
if isinstance(model_revenue, dict):
|
||||
for model, amount in model_revenue.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["revenue_msats"] += _to_float(amount)
|
||||
|
||||
if isinstance(model_tokens, dict):
|
||||
for model, token_count in model_tokens.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["total_tokens"] += _to_int(token_count)
|
||||
|
||||
others["successful_requests"] += _to_int(metric.get("others", 0))
|
||||
others["revenue_msats"] += _to_float(metric.get("others_revenue_msats", 0.0))
|
||||
others["total_tokens"] += _to_int(metric.get("others_tokens", 0))
|
||||
|
||||
model_rows = [
|
||||
{
|
||||
"model": model,
|
||||
"successful_requests": int(values["successful_requests"]),
|
||||
"revenue_msats": float(values["revenue_msats"]),
|
||||
"total_tokens": int(values["total_tokens"]),
|
||||
}
|
||||
for model, values in model_totals.items()
|
||||
]
|
||||
model_rows.sort(
|
||||
key=lambda row: _to_int(row.get("successful_requests", 0)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return model_rows, others
|
||||
|
||||
|
||||
def _build_summary_payload(summary: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"total_requests": _to_int(summary.get("total_requests", 0)),
|
||||
"successful_chat_completions": _to_int(
|
||||
summary.get("successful_chat_completions", 0)
|
||||
),
|
||||
"failed_requests": _to_int(summary.get("failed_requests", 0)),
|
||||
"success_rate": _to_float(summary.get("success_rate", 0.0)),
|
||||
"unique_models_count": _to_int(summary.get("unique_models_count", 0)),
|
||||
"input_tokens": _to_int(summary.get("input_tokens", 0)),
|
||||
"output_tokens": _to_int(summary.get("output_tokens", 0)),
|
||||
"total_tokens": _to_int(summary.get("total_tokens", 0)),
|
||||
"revenue_msats": _to_float(summary.get("revenue_msats", 0.0)),
|
||||
"refunds_msats": _to_float(summary.get("refunds_msats", 0.0)),
|
||||
"net_revenue_msats": _to_float(summary.get("net_revenue_msats", 0.0)),
|
||||
"revenue_sats": _to_float(summary.get("revenue_sats", 0.0)),
|
||||
"refunds_sats": _to_float(summary.get("refunds_sats", 0.0)),
|
||||
"net_revenue_sats": _to_float(summary.get("net_revenue_sats", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
def _build_window_payload(
|
||||
*,
|
||||
hours: int,
|
||||
interval_minutes: int,
|
||||
model_limit: int,
|
||||
) -> dict[str, Any]:
|
||||
dashboard = log_manager.get_usage_dashboard(
|
||||
interval=interval_minutes,
|
||||
hours=hours,
|
||||
error_limit=1,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
summary = dashboard.get("summary", {})
|
||||
model_usage_mix = dashboard.get("model_usage_mix", {})
|
||||
|
||||
summary_payload = _build_summary_payload(summary if isinstance(summary, dict) else {})
|
||||
usage_mix_payload = model_usage_mix if isinstance(model_usage_mix, dict) else {}
|
||||
top_model_usage, others_usage = _aggregate_top_model_usage(usage_mix_payload)
|
||||
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"interval_minutes": interval_minutes,
|
||||
"summary": summary_payload,
|
||||
"model_usage_mix": usage_mix_payload,
|
||||
"top_model_usage": top_model_usage,
|
||||
"others_usage": others_usage,
|
||||
}
|
||||
|
||||
|
||||
def build_stats_snapshot_payload(
|
||||
provider_id: str,
|
||||
*,
|
||||
public_key_hex: str,
|
||||
generated_at: int,
|
||||
window_hours: int = DASHBOARD_WINDOW_HOURS,
|
||||
interval_minutes: int = DASHBOARD_INTERVAL_MINUTES,
|
||||
model_limit: int = MODEL_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
_ = (window_hours, interval_minutes)
|
||||
windows: dict[str, dict[str, Any]] = {}
|
||||
for key, hours, window_interval_minutes in WINDOW_DEFINITIONS:
|
||||
windows[key] = _build_window_payload(
|
||||
hours=hours,
|
||||
interval_minutes=window_interval_minutes,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
primary_window = windows.get("24h", {})
|
||||
summary_payload = (
|
||||
primary_window.get("summary", {})
|
||||
if isinstance(primary_window.get("summary", {}), dict)
|
||||
else {}
|
||||
)
|
||||
usage_mix_payload = (
|
||||
primary_window.get("model_usage_mix", {})
|
||||
if isinstance(primary_window.get("model_usage_mix", {}), dict)
|
||||
else {}
|
||||
)
|
||||
top_model_usage = (
|
||||
primary_window.get("top_model_usage", [])
|
||||
if isinstance(primary_window.get("top_model_usage", []), list)
|
||||
else []
|
||||
)
|
||||
others_usage = (
|
||||
primary_window.get("others_usage", {})
|
||||
if isinstance(primary_window.get("others_usage", {}), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": ANALYTICS_SCHEMA,
|
||||
"generated_at": generated_at,
|
||||
"provider_id": provider_id,
|
||||
"pubkey": public_key_hex,
|
||||
"npub": settings.npub or "",
|
||||
"endpoint_urls": _resolve_endpoint_urls(),
|
||||
"window_hours": DASHBOARD_WINDOW_HOURS,
|
||||
"interval_minutes": DASHBOARD_INTERVAL_MINUTES,
|
||||
"summary": summary_payload,
|
||||
"model_usage_mix": usage_mix_payload,
|
||||
"top_model_usage": top_model_usage,
|
||||
"others_usage": others_usage,
|
||||
"windows": windows,
|
||||
}
|
||||
|
||||
|
||||
def create_stats_snapshot_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
payload_json: str,
|
||||
*,
|
||||
d_tag: str,
|
||||
) -> dict[str, Any]:
|
||||
private_key = PrivateKey(bytes.fromhex(private_key_hex))
|
||||
tags = [
|
||||
["d", d_tag],
|
||||
["provider", provider_id],
|
||||
["schema", ANALYTICS_SCHEMA],
|
||||
]
|
||||
|
||||
event = Event(
|
||||
public_key=private_key.public_key.hex(),
|
||||
content=payload_json,
|
||||
kind=ANALYTICS_KIND,
|
||||
tags=tags,
|
||||
)
|
||||
private_key.sign_event(event)
|
||||
return _event_to_dict(event)
|
||||
|
||||
|
||||
def _fingerprint_payload(payload: dict[str, Any]) -> str:
|
||||
normalized = dict(payload)
|
||||
# Ignore generated timestamp for semantic dedupe.
|
||||
normalized.pop("generated_at", None)
|
||||
payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True)
|
||||
return hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def publish_usage_analytics() -> None:
|
||||
last_payload_hash: str | None = None
|
||||
|
||||
parsed_nsec: str | None = None
|
||||
private_key_hex: str | None = None
|
||||
public_key_hex: str | None = None
|
||||
provider_id: str | None = None
|
||||
warned_missing_nsec = False
|
||||
|
||||
logger.info("Usage analytics sharing task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not settings.enable_analytics_sharing:
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
nsec = (settings.nsec or "").strip()
|
||||
if not nsec:
|
||||
if not warned_missing_nsec:
|
||||
logger.info("NSEC is not configured; skipping analytics sharing to Nostr")
|
||||
warned_missing_nsec = True
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
warned_missing_nsec = False
|
||||
if nsec != parsed_nsec or private_key_hex is None or public_key_hex is None:
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Invalid NSEC; analytics sharing is paused")
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
private_key_hex, public_key_hex = keypair
|
||||
parsed_nsec = nsec
|
||||
provider_id = _resolve_provider_id(public_key_hex)
|
||||
last_payload_hash = None
|
||||
|
||||
if private_key_hex is None or public_key_hex is None:
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
relay_urls = _resolve_relays()
|
||||
if not relay_urls:
|
||||
logger.warning("No Nostr relays configured; analytics sharing skipped")
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
resolved_provider_id = provider_id or _resolve_provider_id(public_key_hex)
|
||||
now_ts = int(time.time())
|
||||
payload = build_stats_snapshot_payload(
|
||||
resolved_provider_id,
|
||||
public_key_hex=public_key_hex,
|
||||
generated_at=now_ts,
|
||||
)
|
||||
|
||||
payload_hash = _fingerprint_payload(payload)
|
||||
if last_payload_hash == payload_hash:
|
||||
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
||||
d_tag = f"{resolved_provider_id}:stats"
|
||||
event = create_stats_snapshot_event(
|
||||
private_key_hex,
|
||||
resolved_provider_id,
|
||||
payload_json,
|
||||
d_tag=d_tag,
|
||||
)
|
||||
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
if await publish_to_relay(relay_url, event):
|
||||
success_count += 1
|
||||
|
||||
if success_count > 0:
|
||||
last_payload_hash = payload_hash
|
||||
|
||||
logger.info(
|
||||
"Published analytics snapshot (success=%s/%s provider=%s)",
|
||||
success_count,
|
||||
len(relay_urls),
|
||||
resolved_provider_id,
|
||||
extra={
|
||||
"relay_success_count": success_count,
|
||||
"relay_total": len(relay_urls),
|
||||
"provider_id": resolved_provider_id,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Usage analytics sharing task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Usage analytics sharing error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
@@ -109,12 +109,20 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
|
||||
usd_cost = 0.0
|
||||
input_usd = 0.0
|
||||
output_usd = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
input_usd = float(
|
||||
usage_data["cost_details"].get("upstream_inference_prompt_cost", 0) or 0
|
||||
)
|
||||
output_usd = float(
|
||||
usage_data["cost_details"].get("upstream_inference_completions_cost", 0)
|
||||
or 0
|
||||
)
|
||||
|
||||
# Fallback to cost field if upstream_inference_cost is 0
|
||||
if usd_cost == 0 and "cost" in usage_data:
|
||||
@@ -123,12 +131,34 @@ async def calculate_cost( # todo: can be sync
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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 usd_cost > 0:
|
||||
try:
|
||||
sats_per_usd = 1.0 / sats_usd_price()
|
||||
cost_in_sats = usd_cost * sats_per_usd
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
input_msats = 0
|
||||
output_msats = 0
|
||||
|
||||
if input_usd > 0 or output_usd > 0:
|
||||
input_msats = int((input_usd * sats_per_usd) * 1000)
|
||||
output_msats = int((output_usd * sats_per_usd) * 1000)
|
||||
else:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
if total_tokens > 0:
|
||||
input_ratio = input_tokens / total_tokens
|
||||
input_msats = int(cost_in_msats * input_ratio)
|
||||
output_msats = cost_in_msats - input_msats
|
||||
else:
|
||||
output_msats = cost_in_msats
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
@@ -140,9 +170,9 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=-1,
|
||||
input_msats=-1, # Cost field doesn't break down by token type
|
||||
output_msats=-1,
|
||||
base_msats=0,
|
||||
input_msats=input_msats,
|
||||
output_msats=output_msats,
|
||||
total_msats=cost_in_msats,
|
||||
total_usd=usd_cost,
|
||||
input_tokens=input_tokens,
|
||||
@@ -159,13 +189,6 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
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 not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
@@ -231,10 +254,10 @@ async def calculate_cost( # todo: can be sync
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
calc_input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
calc_output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(calc_input_msats + calc_output_msats)
|
||||
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
|
||||
|
||||
logger.info(
|
||||
@@ -242,8 +265,8 @@ async def calculate_cost( # todo: can be sync
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"input_cost_msats": calc_input_msats,
|
||||
"output_cost_msats": calc_output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"total_usd": total_usd,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
@@ -252,8 +275,8 @@ async def calculate_cost( # todo: can be sync
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
input_msats=int(calc_input_msats),
|
||||
output_msats=int(calc_output_msats),
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
input_tokens=input_tokens,
|
||||
|
||||
+13
-27
@@ -60,33 +60,12 @@ class Model(BaseModel):
|
||||
upstream_provider_id: int | str | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
forwarded_model_id: str | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def _normalize_legacy_prompt_completion_pricing(
|
||||
pricing: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
normalized = dict(pricing)
|
||||
|
||||
for field in ("prompt", "completion"):
|
||||
raw_value = normalized.get(field)
|
||||
if not isinstance(raw_value, (str, int, float)):
|
||||
continue
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
# Older admin overrides were occasionally saved in "per 1M tokens"
|
||||
# units instead of the backend's expected per-token unit.
|
||||
if value > 0.1:
|
||||
normalized[field] = value / 1_000_000
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
@@ -178,9 +157,6 @@ def _row_to_model(
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict):
|
||||
pricing = _normalize_legacy_prompt_completion_pricing(pricing)
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
@@ -202,6 +178,7 @@ def _row_to_model(
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||
forwarded_model_id=getattr(row, "forwarded_model_id", None) or row.id,
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
@@ -354,6 +331,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
alias_ids=model.alias_ids,
|
||||
forwarded_model_id=model.forwarded_model_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -428,10 +406,18 @@ async def update_sats_pricing() -> None:
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
@models_router.get("/v1/models/", include_in_schema=False)
|
||||
@models_router.get("/models")
|
||||
@models_router.get("/models/", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Get all available models from all providers with database overrides applied."""
|
||||
from ..proxy import get_unique_models
|
||||
|
||||
items = get_unique_models()
|
||||
return {"data": items}
|
||||
data = []
|
||||
for model in items:
|
||||
m = model.dict()
|
||||
if model.forwarded_model_id:
|
||||
m["id"] = model.forwarded_model_id
|
||||
data.append(m)
|
||||
return {"data": data}
|
||||
|
||||
+13
-11
@@ -138,11 +138,6 @@ async def proxy(
|
||||
) -> Response | StreamingResponse:
|
||||
headers = dict(request.headers)
|
||||
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
return create_error_response(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
|
||||
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
@@ -153,6 +148,7 @@ async def proxy(
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
|
||||
if not model_obj:
|
||||
return create_error_response(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
@@ -211,7 +207,7 @@ async def proxy(
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
key = await get_bearer_token_key(
|
||||
headers, path, session, auth, max_cost_for_model
|
||||
headers, path, session, auth, max_cost_for_model, model_id
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -391,7 +387,12 @@ async def proxy(
|
||||
|
||||
|
||||
async def get_bearer_token_key(
|
||||
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
|
||||
headers: dict,
|
||||
path: str,
|
||||
session: AsyncSession,
|
||||
auth: str,
|
||||
min_cost: int = 0,
|
||||
model_id: str = "unknown",
|
||||
) -> ApiKey:
|
||||
"""Handle bearer token authentication proxy requests."""
|
||||
parts = auth.split()
|
||||
@@ -459,15 +460,16 @@ async def get_bearer_token_key(
|
||||
)
|
||||
return key
|
||||
except Exception as e:
|
||||
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
|
||||
logger.error(
|
||||
"Bearer token validation failed",
|
||||
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} model={model_id!r} min_cost={min_cost} key={key_preview!r}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"model_id": model_id,
|
||||
"min_cost_msat": min_cost,
|
||||
"bearer_key_preview": key_preview,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -10,6 +10,7 @@ from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
@@ -24,6 +25,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
RoutstrUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
from ..wallet import send_token
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Check every 60 seconds
|
||||
AUTO_TOPUP_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def periodic_auto_topup() -> None:
|
||||
"""Background task that monitors Routstr provider balances and auto-tops up when below threshold.
|
||||
|
||||
For each Routstr provider with auto_topup enabled in provider_settings:
|
||||
1. Checks the upstream balance via get_balance()
|
||||
2. If balance < topup_threshold, creates a cashu token from the configured mint
|
||||
3. Sends the token to the upstream provider via topup()
|
||||
"""
|
||||
# Wait for initial startup to complete
|
||||
await asyncio.sleep(30)
|
||||
logger.info("Auto top-up worker started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _run_auto_topup_cycle()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto top-up cycle failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
await asyncio.sleep(AUTO_TOPUP_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def _run_auto_topup_cycle() -> None:
|
||||
"""Single cycle: check all eligible providers and top up if needed."""
|
||||
async with create_session() as session:
|
||||
query = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.provider_type == "routstr",
|
||||
UpstreamProviderRow.enabled == True, # noqa: E712
|
||||
)
|
||||
result = await session.exec(query)
|
||||
providers = result.all()
|
||||
|
||||
for row in providers:
|
||||
try:
|
||||
await _check_and_topup(row)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Auto top-up failed for provider",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"base_url": row.base_url,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
"""Check a single provider's balance and top up if below threshold."""
|
||||
# Parse provider settings
|
||||
settings: dict = {}
|
||||
if row.provider_settings:
|
||||
try:
|
||||
settings = json.loads(row.provider_settings)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
|
||||
if not settings.get("auto_topup"):
|
||||
return
|
||||
|
||||
threshold = settings.get("topup_threshold")
|
||||
amount = settings.get("topup_amount_limit")
|
||||
mint_url = settings.get("topup_mint_url")
|
||||
|
||||
if not threshold or not amount or not mint_url:
|
||||
logger.warning(
|
||||
"Auto top-up enabled but missing configuration",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"has_threshold": bool(threshold),
|
||||
"has_amount": bool(amount),
|
||||
"has_mint": bool(mint_url),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if not row.api_key:
|
||||
return
|
||||
|
||||
# Instantiate provider and check balance
|
||||
provider = RoutstrUpstreamProvider.from_db_row(row)
|
||||
balance = await provider.get_balance()
|
||||
|
||||
if balance is None:
|
||||
logger.warning(
|
||||
"Could not fetch balance for auto top-up",
|
||||
extra={"provider_id": row.id, "base_url": row.base_url},
|
||||
)
|
||||
return
|
||||
|
||||
if balance >= threshold * 1000:
|
||||
return
|
||||
|
||||
# Balance is below threshold - create token and top up
|
||||
logger.info(
|
||||
"Auto top-up triggered",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"balance": balance,
|
||||
"threshold": threshold,
|
||||
"topup_amount": amount,
|
||||
"mint_url": mint_url,
|
||||
},
|
||||
)
|
||||
|
||||
print(amount, mint_url)
|
||||
try:
|
||||
token = await send_token(amount, "sat", mint_url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create cashu token for auto top-up",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"amount": amount,
|
||||
"mint_url": mint_url,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
result = await provider.topup(token)
|
||||
|
||||
if "error" in result:
|
||||
logger.error(
|
||||
"Auto top-up upstream call failed",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"error": result["error"],
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto top-up completed successfully",
|
||||
extra={
|
||||
"provider_id": row.id,
|
||||
"amount": amount,
|
||||
"new_balance_approx": balance + amount,
|
||||
},
|
||||
)
|
||||
+555
-49
File diff suppressed because it is too large
Load Diff
@@ -79,22 +79,6 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
data = response.json()
|
||||
|
||||
models_list = []
|
||||
|
||||
def parse_pricing_value(value: object) -> float:
|
||||
if isinstance(value, dict):
|
||||
value = value.get("usd", 0.0)
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
|
||||
return 0.0
|
||||
|
||||
for model_data in data.get("data", []):
|
||||
model_id = model_data.get("id", "")
|
||||
if not model_id:
|
||||
@@ -127,8 +111,8 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
input_pricing = pricing_info.get("input", {})
|
||||
output_pricing = pricing_info.get("output", {})
|
||||
|
||||
prompt_price = parse_pricing_value(input_pricing) / 1_000_000
|
||||
completion_price = parse_pricing_value(output_pricing) / 1_000_000
|
||||
prompt_price = input_pricing.get("usd", 0.001) / 1000000
|
||||
completion_price = output_pricing.get("usd", 0.001) / 1000000
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
|
||||
@@ -283,7 +283,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for communicating with another Routstr instance."""
|
||||
|
||||
provider_type = "routstr"
|
||||
default_base_url = None
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider_fee: float = 1.01,
|
||||
provider_settings: dict | None = None,
|
||||
):
|
||||
"""Initialize Routstr provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the upstream Routstr instance
|
||||
api_key: API key for the upstream Routstr instance
|
||||
provider_fee: Provider fee multiplier
|
||||
provider_settings: Provider-specific settings (auto-topup, etc.)
|
||||
"""
|
||||
# Ensure base_url doesn't end with /v1 as BaseUpstreamProvider appends it if needed
|
||||
# but Routstr paths are usually absolute from base.
|
||||
super().__init__(
|
||||
base_url=base_url.rstrip("/"),
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
self.settings = provider_settings or {}
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "RoutstrUpstreamProvider":
|
||||
import json
|
||||
|
||||
settings = {}
|
||||
if provider_row.provider_settings:
|
||||
try:
|
||||
settings = json.loads(provider_row.provider_settings)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
provider_settings=settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Routstr Node",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": False,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Fetch balance from the upstream Routstr node.
|
||||
|
||||
Returns:
|
||||
Balance in satoshis, or None if failed
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/info"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Routstr balance info usually contains 'balance' in msats or sats
|
||||
# Check for msats and convert to sats
|
||||
if "balance_msats" in data:
|
||||
return float(data["balance_msats"]) / 1000.0
|
||||
return float(data.get("balance", 0))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to fetch balance from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
async def topup(self, cashu_token: str) -> dict[str, Any]:
|
||||
"""Top up balance on the upstream Routstr node.
|
||||
|
||||
Args:
|
||||
cashu_token: Cashu token to deposit
|
||||
|
||||
Returns:
|
||||
Dict containing top-up result
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/topup"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
payload = {"cashu_token": cashu_token}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url, headers=headers, json=payload, timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to topup upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return {"error": str(e)}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from the upstream Routstr node."""
|
||||
url = f"{self.base_url}/v1/models"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers={}, timeout=15.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = data.get("data", [])
|
||||
return [Model(**m) for m in models]
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to fetch models from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return []
|
||||
|
||||
async def refund_balance(self) -> dict[str, Any]:
|
||||
"""Request a refund from the upstream Routstr node.
|
||||
|
||||
Returns:
|
||||
Dict containing refund result and token
|
||||
"""
|
||||
url = f"{self.base_url}/v1/balance/refund"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(url, headers=headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to request refund from upstream Routstr",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return {"error": str(e)}
|
||||
+272
-90
@@ -1,12 +1,11 @@
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import TypedDict
|
||||
|
||||
from cashu.core.base import Proof, Token
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from sqlmodel import col, update
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import db, get_logger
|
||||
from .core.settings import settings
|
||||
@@ -35,6 +34,7 @@ async def recieve_token(
|
||||
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
|
||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||
|
||||
|
||||
@@ -59,15 +59,86 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
|
||||
return token
|
||||
|
||||
|
||||
async def _calculate_swap_amount(
|
||||
amount_msat: int,
|
||||
token_unit: str,
|
||||
token_mint_url: str,
|
||||
token_wallet: Wallet,
|
||||
primary_wallet: Wallet,
|
||||
) -> int:
|
||||
"""
|
||||
Calculate the amount to mint on the primary mint after accounting for
|
||||
potential swap fees (melt fees) on the foreign mint.
|
||||
"""
|
||||
if settings.primary_mint_unit == "sat":
|
||||
receive_amount = amount_msat // 1000
|
||||
else:
|
||||
receive_amount = amount_msat
|
||||
|
||||
if token_mint_url == settings.primary_mint:
|
||||
logger.info(
|
||||
"swap_to_primary_mint: skipping fee estimation (same mint)",
|
||||
extra={"minted_amount": receive_amount},
|
||||
)
|
||||
return int(receive_amount)
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: estimating fees",
|
||||
extra={
|
||||
"dummy_amount": receive_amount,
|
||||
"unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
dummy_mint_quote = await primary_wallet.request_mint(receive_amount)
|
||||
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
|
||||
|
||||
fee_reserve = dummy_melt_quote.fee_reserve
|
||||
if token_unit == "sat":
|
||||
fee_msat = fee_reserve * 1000
|
||||
else:
|
||||
fee_msat = fee_reserve
|
||||
|
||||
amount_msat_after_fee = amount_msat - fee_msat
|
||||
|
||||
if settings.primary_mint_unit == "sat":
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
else:
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
|
||||
if minted_amount <= 0:
|
||||
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: fee estimation result",
|
||||
extra={
|
||||
"token_amount_sat": amount_msat // 1000,
|
||||
"estimated_fee_sat": fee_msat // 1000,
|
||||
"minted_amount": minted_amount,
|
||||
"minted_unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
return minted_amount
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: fee estimation failed",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
raise ValueError(f"Failed to estimate fees: {e}") from e
|
||||
|
||||
|
||||
async def swap_to_primary_mint(
|
||||
token_obj: Token, token_wallet: Wallet
|
||||
) -> tuple[int, str, str]:
|
||||
logger.info(
|
||||
"swap_to_primary_mint",
|
||||
"swap_to_primary_mint: starting",
|
||||
extra={
|
||||
"mint": token_obj.mint,
|
||||
"amount": token_obj.amount,
|
||||
"foreign_mint": token_obj.mint,
|
||||
"token_amount": token_obj.amount,
|
||||
"unit": token_obj.unit,
|
||||
"primary_mint": settings.primary_mint,
|
||||
},
|
||||
)
|
||||
# Ensure amount is an integer
|
||||
@@ -82,24 +153,118 @@ async def swap_to_primary_mint(
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
if settings.primary_mint_unit == "sat":
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
else:
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
# If the token is already from the primary mint, we don't need to swap
|
||||
# and we definitely don't want to calculate or pay fees.
|
||||
if token_obj.mint == settings.primary_mint:
|
||||
logger.info(
|
||||
"swap_to_primary_mint: token already on primary mint, skipping swap",
|
||||
extra={
|
||||
"mint": token_obj.mint,
|
||||
"amount": token_amount,
|
||||
"unit": token_obj.unit,
|
||||
},
|
||||
)
|
||||
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
return token_amount, token_obj.unit, token_obj.mint
|
||||
|
||||
minted_amount = await _calculate_swap_amount(
|
||||
amount_msat,
|
||||
token_obj.unit,
|
||||
token_obj.mint,
|
||||
token_wallet,
|
||||
primary_wallet,
|
||||
)
|
||||
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
logger.info(
|
||||
"swap_to_primary_mint: mint quote received",
|
||||
extra={"mint_quote_id": mint_quote.quote},
|
||||
)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
total_needed = melt_quote.amount + melt_quote.fee_reserve
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt quote received",
|
||||
extra={
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"total_needed": total_needed,
|
||||
"token_amount": token_amount,
|
||||
},
|
||||
)
|
||||
|
||||
if total_needed > token_amount:
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: insufficient token amount for melt fees",
|
||||
extra={
|
||||
"token_amount": token_amount,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"total_needed": total_needed,
|
||||
"shortfall": total_needed - token_amount,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
|
||||
f"melt fees. Needed: {total_needed} {token_obj.unit} "
|
||||
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
|
||||
)
|
||||
|
||||
try:
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"foreign_mint": token_obj.mint,
|
||||
"token_amount": token_amount,
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"total_needed": total_needed,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Failed to melt token from foreign mint {token_obj.mint}: {e}"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt succeeded, minting on primary",
|
||||
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
|
||||
)
|
||||
|
||||
try:
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: mint on primary failed after successful melt",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"minted_amount": minted_amount,
|
||||
"mint_quote_id": mint_quote.quote,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: completed successfully",
|
||||
extra={
|
||||
"foreign_mint": token_obj.mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"original_amount": token_amount,
|
||||
"minted_amount": minted_amount,
|
||||
"unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
|
||||
|
||||
return int(minted_amount), settings.primary_mint_unit, settings.primary_mint
|
||||
|
||||
@@ -159,14 +324,6 @@ async def credit_balance(
|
||||
|
||||
|
||||
_wallets: dict[str, Wallet] = {}
|
||||
_balances_cache_ttl_seconds = 300.0
|
||||
_balances_cache: dict[
|
||||
tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]]
|
||||
] = {}
|
||||
_balances_refresh_tasks: dict[
|
||||
tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]]
|
||||
] = {}
|
||||
_balances_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
|
||||
@@ -235,12 +392,6 @@ async def fetch_all_balances(
|
||||
"""
|
||||
if units is None:
|
||||
units = ["sat", "msat"]
|
||||
units_key = tuple(units)
|
||||
|
||||
now = time.time()
|
||||
cached = _balances_cache.get(units_key)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
|
||||
async def fetch_balance(
|
||||
session: db.AsyncSession, mint_url: str, unit: str
|
||||
@@ -276,71 +427,47 @@ async def fetch_all_balances(
|
||||
}
|
||||
return error_result
|
||||
|
||||
async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]:
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
fetch_balance(session, mint_url, unit)
|
||||
for mint_url in settings.cashu_mints
|
||||
for unit in units
|
||||
]
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
fetch_balance(session, mint_url, unit)
|
||||
for mint_url in settings.cashu_mints
|
||||
for unit in units
|
||||
]
|
||||
|
||||
# Run all tasks concurrently
|
||||
balance_details = list(await asyncio.gather(*tasks))
|
||||
# Run all tasks concurrently
|
||||
balance_details = list(await asyncio.gather(*tasks))
|
||||
|
||||
# Calculate totals
|
||||
total_wallet_balance_sats = 0
|
||||
total_user_balance_sats = 0
|
||||
# Calculate totals
|
||||
total_wallet_balance_sats = 0
|
||||
total_user_balance_sats = 0
|
||||
|
||||
for detail in balance_details:
|
||||
if not detail.get("error"):
|
||||
# Convert to sats for total calculation
|
||||
unit = detail["unit"]
|
||||
proofs_balance_sats = (
|
||||
detail["wallet_balance"]
|
||||
if unit == "sat"
|
||||
else detail["wallet_balance"] // 1000
|
||||
)
|
||||
user_balance_sats = (
|
||||
detail["user_balance"]
|
||||
if unit == "sat"
|
||||
else detail["user_balance"] // 1000
|
||||
)
|
||||
for detail in balance_details:
|
||||
if not detail.get("error"):
|
||||
# Convert to sats for total calculation
|
||||
unit = detail["unit"]
|
||||
proofs_balance_sats = (
|
||||
detail["wallet_balance"]
|
||||
if unit == "sat"
|
||||
else detail["wallet_balance"] // 1000
|
||||
)
|
||||
user_balance_sats = (
|
||||
detail["user_balance"]
|
||||
if unit == "sat"
|
||||
else detail["user_balance"] // 1000
|
||||
)
|
||||
|
||||
total_wallet_balance_sats += proofs_balance_sats
|
||||
total_user_balance_sats += user_balance_sats
|
||||
total_wallet_balance_sats += proofs_balance_sats
|
||||
total_user_balance_sats += user_balance_sats
|
||||
|
||||
owner_balance = total_wallet_balance_sats - total_user_balance_sats
|
||||
return (
|
||||
balance_details,
|
||||
total_wallet_balance_sats,
|
||||
total_user_balance_sats,
|
||||
owner_balance,
|
||||
)
|
||||
owner_balance = total_wallet_balance_sats - total_user_balance_sats
|
||||
|
||||
async with _balances_cache_lock:
|
||||
now = time.time()
|
||||
cached = _balances_cache.get(units_key)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
|
||||
refresh_task = _balances_refresh_tasks.get(units_key)
|
||||
if refresh_task is None or refresh_task.done():
|
||||
refresh_task = asyncio.create_task(compute_balances())
|
||||
_balances_refresh_tasks[units_key] = refresh_task
|
||||
|
||||
result = await refresh_task
|
||||
|
||||
async with _balances_cache_lock:
|
||||
_balances_cache[units_key] = (
|
||||
time.time() + _balances_cache_ttl_seconds,
|
||||
result,
|
||||
)
|
||||
current_task = _balances_refresh_tasks.get(units_key)
|
||||
if current_task is refresh_task and refresh_task.done():
|
||||
_balances_refresh_tasks.pop(units_key, None)
|
||||
|
||||
return result
|
||||
return (
|
||||
balance_details,
|
||||
total_wallet_balance_sats,
|
||||
total_user_balance_sats,
|
||||
owner_balance,
|
||||
)
|
||||
|
||||
|
||||
async def periodic_payout() -> None:
|
||||
@@ -391,6 +518,61 @@ async def periodic_payout() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def periodic_refund_sweep() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60 * 60) # every hour
|
||||
try:
|
||||
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
|
||||
async with db.create_session() as session:
|
||||
stmt = select(db.CashuTransaction).where(
|
||||
db.CashuTransaction.type == "out",
|
||||
db.CashuTransaction.collected == False, # noqa: E712
|
||||
db.CashuTransaction.swept == False, # noqa: E712
|
||||
db.CashuTransaction.created_at < cutoff,
|
||||
)
|
||||
results = await session.exec(stmt)
|
||||
refunds = results.all()
|
||||
|
||||
for refund in refunds:
|
||||
try:
|
||||
await recieve_token(refund.token)
|
||||
refund.swept = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Swept uncollected refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"amount": refund.amount,
|
||||
"unit": refund.unit,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already spent" in error_msg:
|
||||
refund.collected = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Refund already spent (client collected), marking swept",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to sweep refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in periodic refund sweep",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
|
||||
@@ -380,6 +380,13 @@ async def integration_session(
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def patched_db_engine(integration_engine: Any) -> AsyncGenerator[None, None]:
|
||||
"""Patch the global db engine so create_session() uses the test engine."""
|
||||
with patch("routstr.core.db.engine", integration_engine):
|
||||
yield
|
||||
|
||||
|
||||
class DatabaseSnapshot:
|
||||
"""Utility to capture and compare database states"""
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
async def _create_routstr_provider() -> UpstreamProviderRow:
|
||||
return UpstreamProviderRow(
|
||||
provider_type="routstr",
|
||||
base_url="https://upstream.example",
|
||||
api_key="",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
token = "test-admin-token"
|
||||
admin_sessions[token] = int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_provider_balance_timeout_returns_504(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = await _create_routstr_provider()
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
|
||||
|
||||
with patch(
|
||||
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||
new=AsyncMock(side_effect=timeout_error),
|
||||
):
|
||||
response = await integration_client.get(
|
||||
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 504
|
||||
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_provider_balance_request_error_returns_502(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = await _create_routstr_provider()
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
|
||||
request_error = httpx.ConnectError("Connection failed", request=request)
|
||||
|
||||
with patch(
|
||||
"httpx.AsyncHTTPTransport.handle_async_request",
|
||||
new=AsyncMock(side_effect=request_error),
|
||||
):
|
||||
response = await integration_client.get(
|
||||
f"/admin/api/upstream-providers/{provider.id}/balance",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Integration tests for the balance-goes-negative bug in adjust_payment_for_tokens.
|
||||
|
||||
Root cause: when actual token cost exceeds the discounted reservation
|
||||
(cost_difference > 0, caused by tolerance_percentage discounting the reservation),
|
||||
the finalization UPDATE had no WHERE guard on balance, allowing balance to go negative.
|
||||
|
||||
Fix: added `.where(col(ApiKey.balance) >= total_cost_msats)` so the UPDATE is a no-op
|
||||
when balance is insufficient, then falls back to charging only deducted_max_cost.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
|
||||
def _make_key(balance: int, reserved: int) -> ApiKey:
|
||||
return ApiKey(
|
||||
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||
balance=balance,
|
||||
reserved_balance=reserved,
|
||||
total_spent=0,
|
||||
total_requests=1,
|
||||
)
|
||||
|
||||
|
||||
async def _refresh(session: AsyncSession, key: ApiKey) -> ApiKey:
|
||||
await session.refresh(key)
|
||||
return key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build a CostData where token cost > deducted_max_cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cost_data(total_msats: int) -> CostData:
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=total_msats // 2,
|
||||
output_msats=total_msats - total_msats // 2,
|
||||
total_msats=total_msats,
|
||||
total_usd=0.0,
|
||||
input_tokens=100,
|
||||
output_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — exact reproduction of the bug
|
||||
#
|
||||
# Setup: balance == deducted_max_cost (user has just enough for the reservation,
|
||||
# nothing extra). Actual token cost is 1% higher (tolerance_percentage).
|
||||
#
|
||||
# Before fix: balance -= total_cost_msats → goes negative.
|
||||
# After fix: WHERE balance >= total_cost_msats fails → fallback charges
|
||||
# deducted_max_cost → balance reaches 0, never negative.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_never_negative_when_cost_exceeds_reservation(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Balance must not go negative when actual token cost > discounted reservation."""
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
|
||||
deducted_max_cost = 990 # reserved (1% below true max of 1000)
|
||||
actual_token_cost = 1000 # actual cost at true max
|
||||
|
||||
# User has balance exactly equal to the reservation — tight budget
|
||||
key = _make_key(balance=deducted_max_cost, reserved=deducted_max_cost)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
response_data = {"model": "test-model", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}
|
||||
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||
|
||||
await _refresh(integration_session, key)
|
||||
|
||||
assert key.balance >= 0, f"Balance went negative: {key.balance}"
|
||||
assert key.reserved_balance >= 0, f"Reserved balance went negative: {key.reserved_balance}"
|
||||
assert key.reserved_balance == 0, "Reservation must be fully released after finalization"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — balance is ZERO after the reservation is accounted for
|
||||
# (absolute floor case)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_floor_at_zero_on_overrun(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""When balance exactly covers deducted_max_cost and cost overruns, balance reaches 0 not negative."""
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
|
||||
deducted_max_cost = 500
|
||||
actual_token_cost = 550 # 10% overrun
|
||||
|
||||
key = _make_key(balance=500, reserved=500)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 50}}
|
||||
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||
|
||||
await _refresh(integration_session, key)
|
||||
|
||||
assert key.balance == 0, (
|
||||
f"Expected balance=0 (charged deducted_max_cost fallback), got {key.balance}"
|
||||
)
|
||||
assert key.reserved_balance == 0, f"Reserved balance should be 0, got {key.reserved_balance}"
|
||||
# Fallback charges deducted_max_cost
|
||||
assert key.total_spent == deducted_max_cost, (
|
||||
f"Expected total_spent={deducted_max_cost}, got {key.total_spent}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — balance has enough room: full token cost should be charged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_cost_charged_when_balance_sufficient_for_overrun(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""When balance covers total_cost_msats, the full amount is charged (not just deducted_max_cost)."""
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
|
||||
deducted_max_cost = 990
|
||||
actual_token_cost = 1000
|
||||
|
||||
# User has extra balance beyond the reservation
|
||||
key = _make_key(balance=2000, reserved=990)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
response_data = {"model": "test-model", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}
|
||||
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||
|
||||
await _refresh(integration_session, key)
|
||||
|
||||
assert key.balance >= 0, f"Balance went negative: {key.balance}"
|
||||
assert key.reserved_balance == 0, f"Reservation not released: {key.reserved_balance}"
|
||||
assert key.total_spent == actual_token_cost, (
|
||||
f"Expected full charge of {actual_token_cost}, got {key.total_spent}"
|
||||
)
|
||||
assert key.balance == 2000 - actual_token_cost, (
|
||||
f"Expected balance={2000 - actual_token_cost}, got {key.balance}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — concurrent finalizations with cost overrun
|
||||
#
|
||||
# Multiple requests finish concurrently. Each has a small overrun.
|
||||
# None should drive balance negative.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_cost_overruns_never_negative(
|
||||
integration_session: AsyncSession,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""Concurrent finalization with cost overruns must never produce negative balance."""
|
||||
import asyncio
|
||||
|
||||
from routstr.auth import adjust_payment_for_tokens, pay_for_request
|
||||
from routstr.core.db import create_session
|
||||
|
||||
deducted_max_cost = 990
|
||||
actual_token_cost = 1000
|
||||
n_requests = 5
|
||||
|
||||
# Fund the key with exactly enough for n_requests reservations + a tiny buffer
|
||||
starting_balance = deducted_max_cost * n_requests
|
||||
key_hash = f"test_concurrent_{uuid.uuid4().hex}"
|
||||
|
||||
async with create_session() as session:
|
||||
key = ApiKey(
|
||||
hashed_key=key_hash,
|
||||
balance=starting_balance,
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
# Reserve n_requests slots (sequentially, as pay_for_request is atomic)
|
||||
async with create_session() as session:
|
||||
key_to_reserve = await session.get(ApiKey, key_hash)
|
||||
assert key_to_reserve is not None
|
||||
for _ in range(n_requests):
|
||||
await pay_for_request(key_to_reserve, deducted_max_cost, session)
|
||||
await session.refresh(key_to_reserve)
|
||||
|
||||
# Now finalize all concurrently with cost overrun
|
||||
async def finalize() -> None:
|
||||
response_data = {
|
||||
"model": "test-model",
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 100},
|
||||
}
|
||||
async with create_session() as session:
|
||||
fresh_key = await session.get(ApiKey, key_hash)
|
||||
assert fresh_key is not None
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(
|
||||
fresh_key, response_data, session, deducted_max_cost
|
||||
)
|
||||
|
||||
await asyncio.gather(*[finalize() for _ in range(n_requests)])
|
||||
|
||||
async with create_session() as session:
|
||||
final_key = await session.get(ApiKey, key_hash)
|
||||
assert final_key is not None
|
||||
|
||||
assert final_key.balance >= 0, (
|
||||
f"Balance went negative after concurrent overruns: {final_key.balance}"
|
||||
)
|
||||
assert final_key.reserved_balance == 0, (
|
||||
f"Reserved balance not fully released: {final_key.reserved_balance}"
|
||||
)
|
||||
assert final_key.total_spent <= starting_balance, (
|
||||
f"Total spent ({final_key.total_spent}) exceeds starting balance ({starting_balance})"
|
||||
)
|
||||
# Every request must have been charged at least deducted_max_cost — no free inference.
|
||||
assert final_key.total_spent == starting_balance, (
|
||||
f"Expected total_spent={starting_balance} (all {n_requests} reservations charged), "
|
||||
f"got {final_key.total_spent} — at least one request got free inference"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5 — overrun with no balance at all (reserved_balance == balance)
|
||||
# simulates a user who topped up to exactly the reservation floor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_free_balance_overrun_is_safe(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""User with zero free balance (all reserved) should never go negative on overrun."""
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
|
||||
deducted_max_cost = 1000
|
||||
actual_token_cost = 1050
|
||||
|
||||
# balance == reserved_balance: zero free balance
|
||||
key = _make_key(balance=1000, reserved=1000)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 100}}
|
||||
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||
|
||||
await _refresh(integration_session, key)
|
||||
|
||||
assert key.balance >= 0, f"Balance went negative: {key.balance}"
|
||||
assert key.reserved_balance >= 0, f"Reserved balance went negative: {key.reserved_balance}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6 — parallel requests: second finalization must not get free inference
|
||||
#
|
||||
# Root cause of the bug fixed in auth.py:
|
||||
# `.where(col(ApiKey.balance) >= total_cost_msats)` ignores other requests'
|
||||
# reservations, so after Request A charges total_cost_msats, balance can drop
|
||||
# below deducted_max_cost, causing Request B's fallback to release for free.
|
||||
#
|
||||
# Fix: use `balance - reserved_balance + deducted_max_cost >= total_cost_msats`
|
||||
# so the check accounts for concurrent reservations.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_requests_no_free_inference(
|
||||
integration_session: AsyncSession,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""Second parallel finalization must be charged even when first depleted free balance."""
|
||||
import asyncio
|
||||
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
from routstr.core.db import create_session
|
||||
|
||||
deducted_max_cost = 100
|
||||
actual_token_cost = 150 # overrun: 50 more than reserved
|
||||
|
||||
# Fund the key with exactly 2 * deducted_max_cost.
|
||||
# Both requests pre-reserved 100 each → balance=200, reserved=200, free=0.
|
||||
# Old check (balance >= total_cost_msats):
|
||||
# Request A: 200 >= 150 ✓ → charges 150 → balance=50, reserved=100
|
||||
# Request B: 50 >= 150 ✗ → fallback: 50 >= 100 ✗ → releases FREE
|
||||
# New check (balance - reserved + deducted >= total_cost_msats):
|
||||
# Both fall to fallback (0 free balance).
|
||||
# Both charge deducted_max_cost=100 → total_spent=200, balance=0.
|
||||
starting_balance = deducted_max_cost * 2
|
||||
key_hash = f"test_parallel_no_free_{uuid.uuid4().hex}"
|
||||
|
||||
async with create_session() as session:
|
||||
key = ApiKey(
|
||||
hashed_key=key_hash,
|
||||
balance=starting_balance,
|
||||
reserved_balance=deducted_max_cost * 2, # both slots pre-reserved
|
||||
total_spent=0,
|
||||
total_requests=2,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
async def finalize() -> None:
|
||||
response_data = {
|
||||
"model": "test-model",
|
||||
"usage": {"prompt_tokens": 50, "completion_tokens": 100},
|
||||
}
|
||||
async with create_session() as session:
|
||||
fresh_key = await session.get(ApiKey, key_hash)
|
||||
assert fresh_key is not None
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(
|
||||
fresh_key, response_data, session, deducted_max_cost
|
||||
)
|
||||
|
||||
await asyncio.gather(finalize(), finalize())
|
||||
|
||||
async with create_session() as session:
|
||||
final_key = await session.get(ApiKey, key_hash)
|
||||
assert final_key is not None
|
||||
|
||||
assert final_key.balance >= 0, f"Balance went negative: {final_key.balance}"
|
||||
assert final_key.reserved_balance == 0, (
|
||||
f"Reserved balance not released: {final_key.reserved_balance}"
|
||||
)
|
||||
# Both requests must have been charged — no free inference.
|
||||
assert final_key.total_spent == starting_balance, (
|
||||
f"Expected total_spent={starting_balance} (both reservations charged), "
|
||||
f"got {final_key.total_spent} — one request got free inference"
|
||||
)
|
||||
@@ -264,13 +264,12 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) -
|
||||
async def test_admin_endpoint_unauthenticated(
|
||||
integration_client: AsyncClient, db_snapshot: Any
|
||||
) -> None:
|
||||
"""Test unauthenticated access to admin settings endpoint is rejected."""
|
||||
"""Test GET /admin/ endpoint redirects to /"""
|
||||
await db_snapshot.capture()
|
||||
|
||||
response = await integration_client.get("/admin/api/settings")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.headers.get("www-authenticate") == "Bearer"
|
||||
assert response.status_code == 403
|
||||
|
||||
diff = await db_snapshot.diff()
|
||||
assert len(diff["api_keys"]["added"]) == 0
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Tests showing how a user hits "Insufficient balance: X mSats required for this model"
|
||||
when their balance is too low for the model's cost.
|
||||
|
||||
The log line that triggered this:
|
||||
WARNING Insufficient billing balance during validation
|
||||
ERROR Bearer token validation failed: HTTPException: 402:
|
||||
{'error': {'message': 'Insufficient balance: 622888 mSats required
|
||||
for this model. 20320 available.', ...}}
|
||||
|
||||
This happens in validate_bearer_key (auth.py) when:
|
||||
billing_key.total_balance < min_cost (model's max cost)
|
||||
|
||||
and also in pay_for_request when the atomic UPDATE finds no available balance.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
def _key(balance: int, reserved: int = 0) -> ApiKey:
|
||||
return ApiKey(
|
||||
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||
balance=balance,
|
||||
reserved_balance=reserved,
|
||||
total_spent=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — simplest case: balance < model cost → pay_for_request raises 402
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_raises_402_when_balance_too_low(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
User has 20_000 msats. Model costs 622_888 msats.
|
||||
pay_for_request must raise HTTP 402 with a clear message.
|
||||
"""
|
||||
from routstr.auth import pay_for_request
|
||||
|
||||
model_cost = 622_888
|
||||
user_balance = 20_000
|
||||
|
||||
key = _key(balance=user_balance)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(key, model_cost, integration_session)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
detail = exc_info.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
error = detail["error"]
|
||||
assert error["code"] == "insufficient_balance"
|
||||
assert str(model_cost) in error["message"]
|
||||
assert str(user_balance) in error["message"]
|
||||
|
||||
# Balance must be untouched
|
||||
await integration_session.refresh(key)
|
||||
assert key.balance == user_balance
|
||||
assert key.reserved_balance == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — balance is zero
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_raises_402_on_zero_balance(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""User with zero balance cannot make any request."""
|
||||
from routstr.auth import pay_for_request
|
||||
|
||||
key = _key(balance=0)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(key, 1_000, integration_session)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
detail = exc_info.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert detail["error"]["code"] == "insufficient_balance"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — all balance is reserved (total_balance = balance - reserved = 0)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_raises_402_when_all_balance_reserved(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
User has 50_000 msats balance but 50_000 is already reserved for in-flight
|
||||
requests. Free balance (total_balance) = 0. Should get 402.
|
||||
"""
|
||||
from routstr.auth import pay_for_request
|
||||
|
||||
key = _key(balance=50_000, reserved=50_000)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(key, 1_000, integration_session)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
# Balance and reserved must be untouched
|
||||
await integration_session.refresh(key)
|
||||
assert key.balance == 50_000
|
||||
assert key.reserved_balance == 50_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — balance just one msat below model cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_raises_402_one_msat_short(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Off-by-one: balance is exactly model_cost - 1."""
|
||||
from routstr.auth import pay_for_request
|
||||
|
||||
model_cost = 10_000
|
||||
key = _key(balance=model_cost - 1)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(key, model_cost, integration_session)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
await integration_session.refresh(key)
|
||||
assert key.balance == model_cost - 1 # untouched
|
||||
assert key.reserved_balance == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5 — balance exactly equal to model cost → succeeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_succeeds_when_balance_equals_cost(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Balance == model cost: the request should be reserved successfully."""
|
||||
from routstr.auth import pay_for_request
|
||||
|
||||
model_cost = 10_000
|
||||
key = _key(balance=model_cost)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Should not raise
|
||||
await pay_for_request(key, model_cost, integration_session)
|
||||
|
||||
await integration_session.refresh(key)
|
||||
assert key.reserved_balance == model_cost
|
||||
assert key.balance == model_cost # balance unchanged, only reserved goes up
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6 — HTTP layer returns 402 JSON with the right shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_402_response_shape_on_insufficient_balance(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
End-to-end: POST /v1/chat/completions with a key whose balance is far below
|
||||
the mocked model cost returns HTTP 402 with the expected JSON error body.
|
||||
|
||||
Matches exactly the log snippet in the bug report:
|
||||
'Insufficient balance: X mSats required for this model. Y available.'
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
model_cost = 622_888
|
||||
user_balance = 20_320
|
||||
|
||||
key = _key(balance=user_balance)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Minimal model stub so proxy routing doesn't 400 before reaching balance check
|
||||
mock_model = MagicMock()
|
||||
mock_model.sats_pricing = None
|
||||
|
||||
# Upstream stub — never reached because balance check fires first
|
||||
mock_upstream = MagicMock()
|
||||
mock_upstream.prepare_headers = MagicMock(return_value={})
|
||||
|
||||
with (
|
||||
patch("routstr.proxy.get_model_instance", return_value=mock_model),
|
||||
patch("routstr.proxy.get_provider_for_model", return_value=[mock_upstream]),
|
||||
# Patch where it is used (proxy imports it at module level)
|
||||
patch(
|
||||
"routstr.proxy.get_max_cost_for_model",
|
||||
new=AsyncMock(return_value=model_cost),
|
||||
),
|
||||
):
|
||||
response = await integration_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
|
||||
json={
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 402
|
||||
body = response.json()
|
||||
# FastAPI wraps HTTPException detail under "detail"
|
||||
error = body["detail"]["error"]
|
||||
assert error["code"] == "insufficient_balance"
|
||||
assert error["type"] == "insufficient_quota"
|
||||
assert str(model_cost) in error["message"]
|
||||
assert str(user_balance) in error["message"]
|
||||
|
||||
# Balance must be completely untouched
|
||||
await integration_session.refresh(key)
|
||||
assert key.balance == user_balance
|
||||
assert key.reserved_balance == 0
|
||||
assert key.total_spent == 0
|
||||
@@ -0,0 +1,143 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import AsyncSession, ModelRow, UpstreamProviderRow
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
from routstr.proxy import refresh_model_maps
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforce_lowest_provider_fee_for_same_url(
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
"""Test that the algorithm selects the provider with the lowest fee when URLs match."""
|
||||
|
||||
# 1. Create two providers with the same URL but different fees
|
||||
url = "https://api.example.com"
|
||||
p1 = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=url,
|
||||
api_key="key1",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
p2 = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=url,
|
||||
api_key="key2",
|
||||
enabled=True,
|
||||
provider_fee=1.05,
|
||||
)
|
||||
|
||||
integration_session.add(p1)
|
||||
integration_session.add(p2)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(p1)
|
||||
await integration_session.refresh(p2)
|
||||
|
||||
assert p1.id is not None
|
||||
assert p2.id is not None
|
||||
|
||||
# 2. Add a model for each provider
|
||||
m1 = ModelRow(
|
||||
id="model-a",
|
||||
name="Model A",
|
||||
created=1,
|
||||
description="desc",
|
||||
context_length=100,
|
||||
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
|
||||
pricing='{"prompt": 1.0, "completion": 1.0}',
|
||||
upstream_provider_id=p1.id,
|
||||
enabled=True,
|
||||
)
|
||||
m2 = ModelRow(
|
||||
id="model-a",
|
||||
name="Model A",
|
||||
created=1,
|
||||
description="desc",
|
||||
context_length=100,
|
||||
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
|
||||
pricing='{"prompt": 1.0, "completion": 1.0}',
|
||||
upstream_provider_id=p2.id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
integration_session.add(m1)
|
||||
integration_session.add(m2)
|
||||
await integration_session.commit()
|
||||
|
||||
# 3. Create mock provider instances
|
||||
class MockProvider(BaseUpstreamProvider):
|
||||
db_id: int
|
||||
|
||||
def __init__(self, db_id: int, base_url: str, api_key: str, fee: float):
|
||||
super().__init__(base_url, api_key, fee)
|
||||
self.db_id = db_id
|
||||
self.provider_type = "custom"
|
||||
|
||||
def get_cached_models(self) -> list[Model]:
|
||||
return [
|
||||
Model(
|
||||
id="model-a",
|
||||
name="Model A",
|
||||
created=1,
|
||||
description="desc",
|
||||
context_length=100,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="tiktoken",
|
||||
instruct_type="chat",
|
||||
),
|
||||
pricing=Pricing(prompt=1.0, completion=1.0),
|
||||
enabled=True,
|
||||
upstream_provider_id=self.db_id,
|
||||
)
|
||||
]
|
||||
|
||||
async def refresh_models_cache(self) -> None:
|
||||
pass
|
||||
|
||||
def prepare_headers(self, request_headers: dict[str, str]) -> dict[str, str]:
|
||||
return request_headers
|
||||
|
||||
# 4. Inject mock providers into the proxy
|
||||
from routstr import proxy
|
||||
|
||||
assert p1.id is not None
|
||||
assert p2.id is not None
|
||||
|
||||
# Need to patch proxy._upstreams and proxy.create_session
|
||||
mp1: MockProvider = MockProvider(p1.id, url, "key1", 1.01)
|
||||
mp2: MockProvider = MockProvider(p2.id, url, "key2", 1.05)
|
||||
|
||||
with (
|
||||
patch("routstr.proxy._upstreams", [mp1, mp2]),
|
||||
patch("routstr.proxy.create_session") as mock_session_factory,
|
||||
):
|
||||
# Configure mock_session_factory to return a session that uses the test engine
|
||||
@asynccontextmanager
|
||||
async def mock_create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield integration_session
|
||||
|
||||
mock_session_factory.return_value = mock_create_session()
|
||||
|
||||
await refresh_model_maps()
|
||||
|
||||
# 5. Check which provider is selected for 'model-a'
|
||||
provider_map = proxy.get_provider_for_model("model-a")
|
||||
|
||||
# Assertions
|
||||
assert provider_map is not None
|
||||
assert len(provider_map) >= 1
|
||||
|
||||
# Check the first one, cast to MockProvider to access db_id
|
||||
best_provider = cast(MockProvider, provider_map[0])
|
||||
assert best_provider.db_id == p1.id
|
||||
assert best_provider.provider_fee == 1.01
|
||||
@@ -3,12 +3,16 @@ Integration tests for provider management functionality.
|
||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||
"""
|
||||
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import Any, Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import ResponseValidator
|
||||
@@ -678,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
|
||||
assert final_diff["api_keys"]["added"] == []
|
||||
assert final_diff["api_keys"]["modified"] == []
|
||||
assert final_diff["api_keys"]["removed"] == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_routstr_topup_retries_transient_upstream_failure(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
admin_token = "test-admin-token"
|
||||
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="routstr",
|
||||
base_url="https://node.example",
|
||||
api_key="sk-upstream-test",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
|
||||
self.status_code = status_code
|
||||
self._data = data or {}
|
||||
self.text = str(self._data)
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._data
|
||||
|
||||
class MockAsyncClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def __aenter__(self) -> "MockAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def post(
|
||||
self, url: str, json: dict[str, Any], headers: dict[str, str]
|
||||
) -> MockResponse:
|
||||
self.calls += 1
|
||||
assert url == "https://node.example/v1/balance/lightning/invoice"
|
||||
assert json["amount_sats"] == 10
|
||||
assert json["purpose"] == "topup"
|
||||
assert json["api_key"] == "sk-upstream-test"
|
||||
assert headers["Authorization"] == "Bearer sk-upstream-test"
|
||||
|
||||
if self.calls == 1:
|
||||
return MockResponse(500, {"detail": "warmup failure"})
|
||||
|
||||
return MockResponse(
|
||||
200,
|
||||
{
|
||||
"bolt11": "lnbc1testinvoice",
|
||||
"invoice_id": "invoice-123",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = MockAsyncClient()
|
||||
|
||||
try:
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
response = await integration_client.post(
|
||||
f"/admin/api/upstream-providers/{provider.id}/topup",
|
||||
json={"amount": 10},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
|
||||
assert data["topup_data"]["invoice_id"] == "invoice-123"
|
||||
assert mock_client.calls == 2
|
||||
finally:
|
||||
admin_sessions.pop(admin_token, None)
|
||||
|
||||
@@ -171,10 +171,11 @@ async def test_proxy_get_unauthorized_access(integration_client: AsyncClient) ->
|
||||
assert response.status_code == 200 # GET requests are allowed
|
||||
|
||||
# Test 2: POST requests without auth should return 401
|
||||
# Note: Model validation happens before auth, so missing model returns 400
|
||||
response = await integration_client.post(
|
||||
"/v1/chat/completions", json={"test": "data"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.status_code in [400, 401] # Accept both for now
|
||||
|
||||
# Test 3: POST with invalid API key
|
||||
# Note: After refactor, model validation may happen before auth validation
|
||||
@@ -550,9 +551,6 @@ async def test_proxy_get_concurrent_requests(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_get_response_format_preservation(
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Tests for the reservation lifecycle:
|
||||
|
||||
1. Reserve → reserved_balance increases, available (total_balance) decreases.
|
||||
2. Reserve → revert → reserved_balance restored, balance untouched.
|
||||
3. Reserve → finalise → reserved_balance released, balance charged.
|
||||
4. Two parallel reserves, only one fits → second blocked with 402.
|
||||
5. Three parallel reserves, two fit, third blocked with 402.
|
||||
6. Sequential reserves until balance exhausted → next request blocked.
|
||||
|
||||
Reservation invariant enforced by the atomic WHERE clause in pay_for_request:
|
||||
balance - reserved_balance >= cost_per_request
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import pay_for_request, revert_pay_for_request
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_key(balance: int, reserved: int = 0) -> ApiKey:
|
||||
return ApiKey(
|
||||
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||
balance=balance,
|
||||
reserved_balance=reserved,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
|
||||
|
||||
async def _persist(session: AsyncSession, key: ApiKey) -> ApiKey:
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — Reserve: reserved_balance increases, available balance decreases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reserve_increases_reserved_balance(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""pay_for_request must increment reserved_balance by cost_per_request."""
|
||||
cost = 100
|
||||
key = await _persist(integration_session, _make_key(balance=500))
|
||||
|
||||
await pay_for_request(key, cost, integration_session)
|
||||
await integration_session.refresh(key)
|
||||
|
||||
assert key.reserved_balance == cost
|
||||
assert key.balance == 500 # balance column is NOT decremented on reserve
|
||||
assert key.total_balance == 500 - cost # available = balance - reserved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — Revert: reserved_balance restored, balance untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_releases_reservation(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""revert_pay_for_request must release the reservation without touching balance."""
|
||||
cost = 150
|
||||
key = await _persist(integration_session, _make_key(balance=300))
|
||||
|
||||
await pay_for_request(key, cost, integration_session)
|
||||
await integration_session.refresh(key)
|
||||
assert key.reserved_balance == cost
|
||||
|
||||
await revert_pay_for_request(key, integration_session, cost)
|
||||
await integration_session.refresh(key)
|
||||
|
||||
assert key.reserved_balance == 0
|
||||
assert key.balance == 300 # balance unchanged after revert
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — Finalise: reservation released + balance charged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalise_releases_reservation_and_charges_balance(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""adjust_payment_for_tokens must zero reserved_balance and deduct actual cost."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
cost = 100
|
||||
actual = 80 # actual < reserved → refund path
|
||||
key = await _persist(integration_session, _make_key(balance=500))
|
||||
|
||||
await pay_for_request(key, cost, integration_session)
|
||||
await integration_session.refresh(key)
|
||||
assert key.reserved_balance == cost
|
||||
|
||||
cost_data = CostData(
|
||||
base_msats=0,
|
||||
input_msats=40,
|
||||
output_msats=40,
|
||||
total_msats=actual,
|
||||
total_usd=0.0,
|
||||
input_tokens=50,
|
||||
output_tokens=50,
|
||||
)
|
||||
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 50}}
|
||||
|
||||
with patch("routstr.auth.calculate_cost", return_value=cost_data):
|
||||
await adjust_payment_for_tokens(key, response_data, integration_session, cost)
|
||||
|
||||
await integration_session.refresh(key)
|
||||
|
||||
assert key.reserved_balance == 0
|
||||
assert key.balance == 500 - actual
|
||||
assert key.total_spent == actual
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — Concurrent: second parallel reserve blocked when balance exhausted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_second_reserve_blocked_when_balance_exhausted(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""When two requests race for the same balance, only one succeeds; the other gets 402."""
|
||||
cost = 300
|
||||
key_hash = f"test_concurrent_{uuid.uuid4().hex}"
|
||||
|
||||
async with create_session() as session:
|
||||
key = ApiKey(
|
||||
hashed_key=key_hash,
|
||||
balance=300, # exactly enough for ONE reservation
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
async def attempt_reserve() -> None:
|
||||
async with create_session() as session:
|
||||
fresh_key = await session.get(ApiKey, key_hash)
|
||||
assert fresh_key is not None
|
||||
try:
|
||||
await pay_for_request(fresh_key, cost, session)
|
||||
results.append("success")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
results.append("blocked")
|
||||
|
||||
await asyncio.gather(attempt_reserve(), attempt_reserve())
|
||||
|
||||
assert sorted(results) == ["blocked", "success"], (
|
||||
f"Expected exactly one success and one 402, got: {results}"
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
final = await session.get(ApiKey, key_hash)
|
||||
assert final is not None
|
||||
|
||||
# reserved_balance must equal exactly one reservation (not two)
|
||||
assert final.reserved_balance == cost, (
|
||||
f"Expected reserved_balance={cost}, got {final.reserved_balance}"
|
||||
)
|
||||
assert final.balance == 300, "Balance column must not be modified by reservation"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5 — Concurrent: three requests, two fit, third blocked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_parallel_reserves_third_blocked(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""Balance covers two reservations exactly; the third concurrent request must be blocked."""
|
||||
cost = 100
|
||||
key_hash = f"test_three_parallel_{uuid.uuid4().hex}"
|
||||
|
||||
async with create_session() as session:
|
||||
key = ApiKey(
|
||||
hashed_key=key_hash,
|
||||
balance=200, # fits exactly 2 reservations of 100
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
async def attempt_reserve() -> None:
|
||||
async with create_session() as session:
|
||||
fresh_key = await session.get(ApiKey, key_hash)
|
||||
assert fresh_key is not None
|
||||
try:
|
||||
await pay_for_request(fresh_key, cost, session)
|
||||
results.append("success")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
results.append("blocked")
|
||||
|
||||
await asyncio.gather(
|
||||
attempt_reserve(),
|
||||
attempt_reserve(),
|
||||
attempt_reserve(),
|
||||
)
|
||||
|
||||
successes = results.count("success")
|
||||
blocked = results.count("blocked")
|
||||
|
||||
assert successes == 2, f"Expected 2 successes, got {successes}: {results}"
|
||||
assert blocked == 1, f"Expected 1 blocked, got {blocked}: {results}"
|
||||
|
||||
async with create_session() as session:
|
||||
final = await session.get(ApiKey, key_hash)
|
||||
assert final is not None
|
||||
|
||||
assert final.reserved_balance == cost * 2, (
|
||||
f"Expected reserved_balance={cost * 2}, got {final.reserved_balance}"
|
||||
)
|
||||
assert final.balance == 200, "Balance column must not be modified by reservation"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6 — Sequential exhaustion: reserve until empty, next request blocked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_reserves_block_when_balance_exhausted(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Repeated reservations should block as soon as available balance drops below cost."""
|
||||
cost = 100
|
||||
key = await _persist(integration_session, _make_key(balance=250))
|
||||
|
||||
# First two succeed (100 + 100 = 200 ≤ 250)
|
||||
await pay_for_request(key, cost, integration_session)
|
||||
await pay_for_request(key, cost, integration_session)
|
||||
await integration_session.refresh(key)
|
||||
assert key.reserved_balance == 200
|
||||
assert key.total_balance == 50 # 250 - 200
|
||||
|
||||
# Third: only 50 available, need 100 → blocked
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(key, cost, integration_session)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
await integration_session.refresh(key)
|
||||
assert key.reserved_balance == 200 # unchanged after failed reserve
|
||||
@@ -0,0 +1,250 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routstr.balance import refund_wallet_endpoint
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
|
||||
|
||||
def _make_cashu_tx(
|
||||
token: str,
|
||||
amount: int,
|
||||
unit: str,
|
||||
type: str = "out",
|
||||
request_id: str | None = "req-abc",
|
||||
swept: bool = False,
|
||||
collected: bool = False,
|
||||
) -> CashuTransaction:
|
||||
tx = CashuTransaction(token=token, amount=amount, unit=unit, type=type, request_id=request_id)
|
||||
tx.swept = swept
|
||||
tx.collected = collected
|
||||
return tx
|
||||
|
||||
|
||||
def _exec_result(tx: CashuTransaction | None) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.first.return_value = tx
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_returns_token() -> None:
|
||||
x_cashu_token = "cashuAtest_token_value"
|
||||
in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-abc")
|
||||
out_tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat", type="out", request_id="req-abc")
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
result = await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu=x_cashu_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
body = json.loads(result.body)
|
||||
assert body["token"] == "cashuArefund_token"
|
||||
assert body["msats"] == "1000"
|
||||
assert result.headers["X-Cashu"] == "cashuArefund_token"
|
||||
assert out_tx.collected is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_sat_unit() -> None:
|
||||
x_cashu_token = "cashuAsat_token"
|
||||
in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="sat", type="in", request_id="req-sat")
|
||||
out_tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat", type="out", request_id="req-sat")
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
result = await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu=x_cashu_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
body = json.loads(result.body)
|
||||
assert body["token"] == "cashuArefund_sat"
|
||||
assert body["sats"] == "500"
|
||||
assert "msats" not in body
|
||||
assert result.headers["X-Cashu"] == "cashuArefund_sat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_not_found_raises_404() -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(return_value=_exec_result(None))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu="cashuAmissing_token",
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_swept_raises_410() -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
in_tx = _make_cashu_tx(token="cashuAswept_token", amount=0, unit="msat", type="in", request_id="req-swept")
|
||||
out_tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", type="out", request_id="req-swept", swept=True)
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu="cashuAswept_token",
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 410
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# source field defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cashu_transaction_source_defaults_to_x_cashu() -> None:
|
||||
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat")
|
||||
assert tx.source == "x-cashu"
|
||||
|
||||
|
||||
def test_cashu_transaction_source_can_be_apikey() -> None:
|
||||
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat", source="apikey")
|
||||
assert tx.source == "apikey"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apikey-based refund: token logging and CashuTransaction storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_api_key(
|
||||
balance: int = 5000,
|
||||
refund_currency: str | None = "sat",
|
||||
refund_mint_url: str | None = "https://mint.example.com",
|
||||
refund_address: str | None = None,
|
||||
parent_key_hash: str | None = None,
|
||||
) -> ApiKey:
|
||||
key = ApiKey(hashed_key="testhash")
|
||||
key.balance = balance
|
||||
key.reserved_balance = 0
|
||||
key.refund_currency = refund_currency
|
||||
key.refund_mint_url = refund_mint_url
|
||||
key.refund_address = refund_address
|
||||
key.parent_key_hash = parent_key_hash
|
||||
key.total_spent = 0
|
||||
key.total_requests = 0
|
||||
return key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> None:
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
refund_token = "cashuArefund_apikey_token"
|
||||
|
||||
session = MagicMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
|
||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||
):
|
||||
result = await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-testhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["token"] == refund_token
|
||||
|
||||
mock_store.assert_awaited_once()
|
||||
call_kwargs = mock_store.call_args.kwargs
|
||||
assert call_kwargs["source"] == "apikey"
|
||||
assert call_kwargs["token"] == refund_token
|
||||
assert call_kwargs["typ"] == "out"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_logs_token() -> None:
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
refund_token = "cashuAlogged_token"
|
||||
|
||||
session = MagicMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||
patch("routstr.balance.logger") as mock_logger,
|
||||
):
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-testhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||
assert any("cashu token issued" in c for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_log_includes_path() -> None:
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
refund_token = "cashuApath_token"
|
||||
|
||||
session = MagicMock()
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||
patch("routstr.balance.logger") as mock_logger,
|
||||
):
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-testhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# Find the "cashu token issued" call and verify extra contains the path
|
||||
token_issued_calls = [
|
||||
c for c in mock_logger.info.call_args_list
|
||||
if c.args and "cashu token issued" in c.args[0]
|
||||
]
|
||||
assert len(token_issued_calls) == 1
|
||||
extra = token_issued_calls[0].kwargs.get("extra", {})
|
||||
assert extra.get("path") == "/v1/wallet/refund"
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Tests for pricing unit normalization across model sources."""
|
||||
|
||||
import json
|
||||
from types import TracebackType
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ModelRow
|
||||
from routstr.payment.models import _row_to_model
|
||||
from routstr.upstream.generic import GenericUpstreamProvider
|
||||
|
||||
|
||||
def test_row_to_model_normalizes_legacy_per_million_db_pricing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("routstr.payment.models.sats_usd_price", lambda: 0.00002)
|
||||
|
||||
row = ModelRow(
|
||||
id="anthropic/claude-opus-4.5",
|
||||
upstream_provider_id=1,
|
||||
name="Claude Opus 4.5",
|
||||
created=0,
|
||||
description="test",
|
||||
context_length=200000,
|
||||
architecture=json.dumps(
|
||||
{
|
||||
"modality": "text->text",
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "test",
|
||||
"instruct_type": None,
|
||||
}
|
||||
),
|
||||
pricing=json.dumps(
|
||||
{
|
||||
"prompt": 5.0,
|
||||
"completion": 25.0,
|
||||
"request": 0.0,
|
||||
"image": 0.0,
|
||||
"web_search": 0.0,
|
||||
"internal_reasoning": 0.0,
|
||||
}
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
model = _row_to_model(row)
|
||||
|
||||
assert model.pricing.prompt == pytest.approx(0.000005)
|
||||
assert model.pricing.completion == pytest.approx(0.000025)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_provider_missing_pricing_defaults_to_zero(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-5-chat",
|
||||
"name": "gpt-5-chat",
|
||||
"created": 0,
|
||||
"owned_by": "openai",
|
||||
"model_spec": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self) -> "FakeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
return None
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: dict[str, str] | None = None
|
||||
) -> FakeResponse:
|
||||
del url, headers
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"routstr.upstream.generic.httpx.AsyncClient",
|
||||
lambda timeout=30.0: FakeClient(),
|
||||
)
|
||||
|
||||
provider = GenericUpstreamProvider(base_url="https://example.test")
|
||||
|
||||
models = await provider.fetch_models()
|
||||
|
||||
assert len(models) == 1
|
||||
assert models[0].pricing.prompt == 0.0
|
||||
assert models[0].pricing.completion == 0.0
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.nostr import analytics
|
||||
|
||||
|
||||
def test_aggregate_top_model_usage_sums_metrics() -> None:
|
||||
model_usage_mix = {
|
||||
"top_models": ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"],
|
||||
"metrics": [
|
||||
{
|
||||
"model_counts": {
|
||||
"openai/gpt-4o": 4,
|
||||
"anthropic/claude-3.5-sonnet": 2,
|
||||
},
|
||||
"model_revenue_msats": {
|
||||
"openai/gpt-4o": 1500,
|
||||
"anthropic/claude-3.5-sonnet": 700,
|
||||
},
|
||||
"model_tokens": {
|
||||
"openai/gpt-4o": 1200,
|
||||
"anthropic/claude-3.5-sonnet": 600,
|
||||
},
|
||||
"others": 1,
|
||||
"others_revenue_msats": 300,
|
||||
"others_tokens": 200,
|
||||
},
|
||||
{
|
||||
"model_counts": {
|
||||
"openai/gpt-4o": 3,
|
||||
"anthropic/claude-3.5-sonnet": 1,
|
||||
},
|
||||
"model_revenue_msats": {
|
||||
"openai/gpt-4o": 1000,
|
||||
"anthropic/claude-3.5-sonnet": 500,
|
||||
},
|
||||
"model_tokens": {
|
||||
"openai/gpt-4o": 800,
|
||||
"anthropic/claude-3.5-sonnet": 300,
|
||||
},
|
||||
"others": 2,
|
||||
"others_revenue_msats": 450,
|
||||
"others_tokens": 350,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
rows, others = analytics._aggregate_top_model_usage(model_usage_mix)
|
||||
assert rows == [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"successful_requests": 7,
|
||||
"revenue_msats": 2500.0,
|
||||
"total_tokens": 2000,
|
||||
},
|
||||
{
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
"successful_requests": 3,
|
||||
"revenue_msats": 1200.0,
|
||||
"total_tokens": 900,
|
||||
},
|
||||
]
|
||||
assert others == {
|
||||
"successful_requests": 3,
|
||||
"revenue_msats": 750.0,
|
||||
"total_tokens": 550,
|
||||
}
|
||||
|
||||
|
||||
def test_build_stats_snapshot_payload_schema_and_shape(monkeypatch: Any) -> None:
|
||||
seen_windows: set[tuple[int, int]] = set()
|
||||
|
||||
def fake_usage_dashboard(
|
||||
*, interval: int, hours: int, error_limit: int, model_limit: int
|
||||
) -> dict[str, Any]:
|
||||
seen_windows.add((hours, interval))
|
||||
assert error_limit == 1
|
||||
assert model_limit == 20
|
||||
return {
|
||||
"summary": {
|
||||
"total_requests": hours,
|
||||
"successful_chat_completions": max(1, hours - 1),
|
||||
"failed_requests": 2,
|
||||
"success_rate": 90.0,
|
||||
"unique_models_count": 2,
|
||||
"input_tokens": 2000,
|
||||
"output_tokens": 1000,
|
||||
"total_tokens": 3000,
|
||||
"revenue_msats": 9000.0,
|
||||
"refunds_msats": 1000.0,
|
||||
"net_revenue_msats": 8000.0,
|
||||
"revenue_sats": 9.0,
|
||||
"refunds_sats": 1.0,
|
||||
"net_revenue_sats": 8.0,
|
||||
},
|
||||
"model_usage_mix": {
|
||||
"top_models": ["openai/gpt-4o"],
|
||||
"metrics": [
|
||||
{
|
||||
"timestamp": "2026-03-02 10:00:00",
|
||||
"model_counts": {"openai/gpt-4o": hours},
|
||||
"model_revenue_msats": {"openai/gpt-4o": float(hours * 100)},
|
||||
"model_tokens": {"openai/gpt-4o": hours * 10},
|
||||
"others": 4,
|
||||
"others_revenue_msats": 1800.0,
|
||||
"others_tokens": 400,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
analytics.log_manager, "get_usage_dashboard", fake_usage_dashboard
|
||||
)
|
||||
monkeypatch.setattr(analytics.settings, "npub", "npub1example")
|
||||
monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com")
|
||||
monkeypatch.setattr(analytics.settings, "onion_url", "")
|
||||
|
||||
payload = analytics.build_stats_snapshot_payload(
|
||||
"provider123",
|
||||
public_key_hex="ab" * 32,
|
||||
generated_at=1772451600,
|
||||
)
|
||||
|
||||
assert payload["schema"] == analytics.ANALYTICS_SCHEMA
|
||||
assert payload["provider_id"] == "provider123"
|
||||
assert payload["window_hours"] == 24
|
||||
assert payload["interval_minutes"] == 60
|
||||
assert payload["endpoint_urls"] == ["https://node.example.com"]
|
||||
assert seen_windows == {
|
||||
(24, 60),
|
||||
(7 * 24, 6 * 60),
|
||||
(30 * 24, 24 * 60),
|
||||
(90 * 24, 24 * 60),
|
||||
(365 * 24, 7 * 24 * 60),
|
||||
}
|
||||
assert set(payload["windows"].keys()) == {"24h", "7d", "30d", "3m", "1y"}
|
||||
assert payload["windows"]["1y"]["interval_minutes"] == 7 * 24 * 60
|
||||
assert payload["summary"]["total_requests"] == 24
|
||||
assert payload["top_model_usage"] == [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"successful_requests": 24,
|
||||
"revenue_msats": 2400.0,
|
||||
"total_tokens": 240,
|
||||
}
|
||||
]
|
||||
assert payload["others_usage"] == {
|
||||
"successful_requests": 4,
|
||||
"revenue_msats": 1800.0,
|
||||
"total_tokens": 400,
|
||||
}
|
||||
|
||||
|
||||
def test_create_stats_snapshot_event_tags() -> None:
|
||||
private_key_hex = "11" * 32
|
||||
event = analytics.create_stats_snapshot_event(
|
||||
private_key_hex,
|
||||
"provider123",
|
||||
payload_json='{"schema":"routstr.analytics.snapshot.v1"}',
|
||||
d_tag="provider123:stats",
|
||||
)
|
||||
|
||||
tags = event["tags"]
|
||||
assert ["d", "provider123:stats"] in tags
|
||||
assert ["provider", "provider123"] in tags
|
||||
assert ["schema", analytics.ANALYTICS_SCHEMA] in tags
|
||||
assert all(tag[0] != "period" for tag in tags)
|
||||
|
||||
|
||||
def test_fingerprint_payload_ignores_generated_at() -> None:
|
||||
a = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 1000, "summary": {"x": 1}}
|
||||
b = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 2000, "summary": {"x": 1}}
|
||||
|
||||
assert analytics._fingerprint_payload(a) == analytics._fingerprint_payload(b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_usage_analytics_skips_when_disabled(monkeypatch: Any) -> None:
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(seconds: int) -> None:
|
||||
delays.append(seconds)
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
raise AssertionError("build_stats_snapshot_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", False)
|
||||
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build)
|
||||
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
|
||||
|
||||
await analytics.publish_usage_analytics()
|
||||
|
||||
assert delays == [analytics.DISABLED_POLL_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_usage_analytics_skips_without_nsec(monkeypatch: Any) -> None:
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(seconds: int) -> None:
|
||||
delays.append(seconds)
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
raise AssertionError("build_stats_snapshot_payload should not be called")
|
||||
|
||||
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True)
|
||||
monkeypatch.setattr(analytics.settings, "nsec", "")
|
||||
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build)
|
||||
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
|
||||
|
||||
await analytics.publish_usage_analytics()
|
||||
|
||||
assert delays == [analytics.DISABLED_POLL_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_usage_analytics_dedupes_unchanged_payload(monkeypatch: Any) -> None:
|
||||
published_events: list[dict[str, Any]] = []
|
||||
sleep_calls = 0
|
||||
|
||||
async def fake_sleep(seconds: int) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
def fake_build_payload(
|
||||
provider_id: str,
|
||||
*,
|
||||
public_key_hex: str,
|
||||
generated_at: int,
|
||||
window_hours: int = 24,
|
||||
interval_minutes: int = 60,
|
||||
model_limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
_ = (public_key_hex, generated_at, window_hours, interval_minutes, model_limit)
|
||||
return {
|
||||
"schema": analytics.ANALYTICS_SCHEMA,
|
||||
"generated_at": generated_at,
|
||||
"provider_id": provider_id,
|
||||
"summary": {"total_requests": 1},
|
||||
}
|
||||
|
||||
async def fake_publish(relay_url: str, event: dict[str, Any]) -> bool:
|
||||
_ = relay_url
|
||||
published_events.append(event)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True)
|
||||
monkeypatch.setattr(analytics.settings, "nsec", "11" * 32)
|
||||
monkeypatch.setattr(analytics.settings, "relays", ["wss://relay.example.com"])
|
||||
monkeypatch.setattr(analytics.settings, "provider_id", "")
|
||||
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fake_build_payload)
|
||||
monkeypatch.setattr(analytics, "publish_to_relay", fake_publish)
|
||||
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
|
||||
|
||||
await analytics.publish_usage_analytics()
|
||||
|
||||
assert len(published_events) == 1
|
||||
assert ["schema", analytics.ANALYTICS_SCHEMA] in published_events[0].get("tags", [])
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import text
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.settings import SettingsService
|
||||
@@ -11,6 +12,7 @@ from routstr.core.settings import SettingsService
|
||||
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)
|
||||
os.environ.pop("ENABLE_ANALYTICS_SHARING", None)
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
@@ -19,19 +21,53 @@ async def test_settings_seed_from_env_and_persist() -> None:
|
||||
assert settings.upstream_base_url == "https://api.test/v1"
|
||||
# ONION_URL may be empty if not discoverable
|
||||
assert isinstance(settings.onion_url, str)
|
||||
assert settings.enable_analytics_sharing is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_db_precedence_over_env() -> None:
|
||||
os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1"
|
||||
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
|
||||
|
||||
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)
|
||||
updated = await SettingsService.update(
|
||||
{"name": "DBName", "enable_analytics_sharing": False}, session
|
||||
)
|
||||
assert updated.name == "DBName"
|
||||
assert updated.enable_analytics_sharing is False
|
||||
|
||||
# Change env and re-initialize; DB should still win
|
||||
os.environ["NAME"] = "EnvName"
|
||||
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
|
||||
again = await SettingsService.initialize(session)
|
||||
assert again.name == "DBName"
|
||||
assert again.enable_analytics_sharing is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_initialize_discards_unknown_keys() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
_ = await SettingsService.initialize(session)
|
||||
|
||||
# Simulate older persisted key name and an unknown key.
|
||||
await session.exec( # type: ignore
|
||||
text(
|
||||
"UPDATE settings SET data = :data WHERE id = 1"
|
||||
).bindparams(
|
||||
data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}'
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
reloaded = await SettingsService.initialize(session)
|
||||
assert reloaded.name == "LegacyNode"
|
||||
assert reloaded.enable_analytics_sharing is True
|
||||
|
||||
row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
|
||||
stored_data = row.first()[0]
|
||||
assert '"enable_analytics_sharing": true' in stored_data
|
||||
assert "nostr_analytics_enabled" not in stored_data
|
||||
assert "unknown_key" not in stored_data
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_with_id_injection() -> None:
|
||||
"""Test that stream_with_cost correctly injects IDs into complete JSON chunks but skips partials."""
|
||||
provider = BaseUpstreamProvider(
|
||||
base_url="https://api.example.com", api_key="test_key"
|
||||
)
|
||||
|
||||
# Mock response with mixed chunks:
|
||||
# 1. Complete JSON without ID
|
||||
# 2. Partial JSON (should be passed through)
|
||||
# 3. Complete JSON with ID (should be preserved or updated if requested_model is set)
|
||||
# 4. [DONE] message
|
||||
chunks = [
|
||||
b'data: {"choices": [{"delta": {"content": "Hello"}}]}\n\n',
|
||||
b'data: {"choices": [{"delta": {"content": "', # Partial
|
||||
b'world"}}]}\n\n',
|
||||
b'data: {"id": "existing-id", "choices": [{"delta": {"content": "!"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
async def aiter_bytes() -> AsyncGenerator[bytes, None]:
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "text/event-stream"}
|
||||
mock_response.aiter_bytes = aiter_bytes
|
||||
|
||||
key = MagicMock(spec=ApiKey)
|
||||
key.hashed_key = "test_hash"
|
||||
key.balance = 1000
|
||||
|
||||
background_tasks = MagicMock()
|
||||
|
||||
# We need to mock adjust_payment_for_tokens since it's called at the end
|
||||
with MagicMock():
|
||||
from routstr.upstream import base
|
||||
|
||||
# Mocking the module-level function used in the generator
|
||||
base.adjust_payment_for_tokens = AsyncMock(
|
||||
return_value={"total_usd": 0.1, "total_msats": 100}
|
||||
)
|
||||
base.create_session = MagicMock()
|
||||
|
||||
streaming_response = await provider.handle_streaming_chat_completion(
|
||||
response=mock_response,
|
||||
key=key,
|
||||
max_cost_for_model=100,
|
||||
background_tasks=background_tasks,
|
||||
requested_model="test-model",
|
||||
)
|
||||
|
||||
results = []
|
||||
async for chunk in streaming_response.body_iterator:
|
||||
results.append(chunk)
|
||||
|
||||
# Parse results
|
||||
parsed_results = []
|
||||
for r in results:
|
||||
if isinstance(r, bytes) and r.startswith(b"data: "):
|
||||
data = r[6:].decode().strip()
|
||||
if data == "[DONE]":
|
||||
parsed_results.append(data)
|
||||
else:
|
||||
try:
|
||||
parsed_results.append(json.loads(data))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
parsed_results.append(
|
||||
data
|
||||
) # Keep as string if it failed to parse
|
||||
|
||||
# Verifications
|
||||
# 1. First chunk should have an injected ID and the requested model
|
||||
assert isinstance(parsed_results[0], dict)
|
||||
assert "id" in parsed_results[0]
|
||||
assert parsed_results[0]["id"].startswith("chatcmpl-")
|
||||
assert parsed_results[0]["model"] == "test-model"
|
||||
|
||||
# 2. Second chunk was partial, should be passed as-is
|
||||
# In current implementation, re.split(b"data: ", b'data: {...') gives ['', '{...']
|
||||
# The first empty part is skipped. The second part is processed.
|
||||
|
||||
# Check that we have results
|
||||
assert len(parsed_results) >= 4
|
||||
|
||||
# Find the chunk that was "existing-id"
|
||||
id_chunk = next(
|
||||
r
|
||||
for r in parsed_results
|
||||
if isinstance(r, dict)
|
||||
and "choices" in r
|
||||
and r["choices"][0]["delta"].get("content") == "!"
|
||||
)
|
||||
assert id_chunk["id"] == parsed_results[0]["id"]
|
||||
assert id_chunk["model"] == "test-model"
|
||||
|
||||
# 4. [DONE] should be there
|
||||
assert "[DONE]" in parsed_results
|
||||
@@ -0,0 +1,76 @@
|
||||
from types import TracebackType
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
|
||||
class DummyAsyncClient:
|
||||
def __init__(self, response: Mock | None = None, error: Exception | None = None):
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
async def __aenter__(self) -> "DummyAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def get(
|
||||
self, url: str, headers: dict[str, str], timeout: float
|
||||
) -> Mock:
|
||||
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
assert self.response is not None
|
||||
return self.response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_omits_auth_header_when_api_key_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = {"balance_msats": 42000}
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
client = DummyAsyncClient(response=response)
|
||||
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||
|
||||
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
|
||||
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance == 42.0
|
||||
assert client.calls == [
|
||||
{
|
||||
"url": "https://node.example/v1/balance/info",
|
||||
"headers": {},
|
||||
"timeout": 10.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_balance_returns_none_on_connect_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
|
||||
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
|
||||
|
||||
provider = RoutstrUpstreamProvider(
|
||||
base_url="https://node.example",
|
||||
api_key="secret",
|
||||
)
|
||||
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance is None
|
||||
@@ -108,6 +108,92 @@ async def test_credit_balance() -> None:
|
||||
assert mock_session.refresh.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
|
||||
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = "http://foreign:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 404
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [{"amount": 404}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_mint_quote = Mock()
|
||||
mock_mint_quote.quote = "mint_quote_123"
|
||||
mock_mint_quote.request = "lnbc1..."
|
||||
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
|
||||
|
||||
mock_melt_quote = Mock()
|
||||
mock_melt_quote.quote = "melt_quote_123"
|
||||
mock_melt_quote.amount = 400
|
||||
mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404
|
||||
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(ValueError, match="insufficient to cover melt fees"):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
# melt should never have been called
|
||||
mock_token_wallet.melt.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
|
||||
"""Melt failure from cashu lib is wrapped as ValueError."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = "http://foreign:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 5000
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [{"amount": 5000}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_mint_quote = Mock()
|
||||
mock_mint_quote.quote = "mint_quote_456"
|
||||
mock_mint_quote.request = "lnbc1..."
|
||||
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
|
||||
|
||||
mock_melt_quote = Mock()
|
||||
mock_melt_quote.quote = "melt_quote_456"
|
||||
mock_melt_quote.amount = 4940
|
||||
mock_melt_quote.fee_reserve = 50 # total 4990 < 5000, passes fee check
|
||||
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
|
||||
mock_token_wallet.melt = AsyncMock(
|
||||
side_effect=Exception("Provided: 5000, needed: 5100 (Code: 11000)")
|
||||
)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(ValueError, match="Failed to melt token"):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_wallet = Mock()
|
||||
@@ -131,3 +217,104 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
assert amount == 900
|
||||
assert unit == "sat"
|
||||
assert mint == "http://mint:3338"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_already_on_primary() -> None:
|
||||
from routstr.core.settings import settings
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = settings.primary_mint
|
||||
mock_token.amount = 1000
|
||||
mock_token.unit = "sat"
|
||||
mock_token.proofs = []
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.split = AsyncMock(return_value=None)
|
||||
mock_token_wallet.request_mint = AsyncMock()
|
||||
mock_token_wallet.melt_quote = AsyncMock()
|
||||
|
||||
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
|
||||
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
assert amount == 1000
|
||||
assert unit == "sat"
|
||||
assert mint == settings.primary_mint
|
||||
mock_token_wallet.split.assert_called_once()
|
||||
mock_token_wallet.request_mint.assert_not_called()
|
||||
mock_token_wallet.melt_quote.assert_not_called()
|
||||
|
||||
|
||||
async def test_swap_to_primary_mint_success() -> None:
|
||||
"""Test successful swap with dynamic fee calculation."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = "http://foreign:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 1000
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [{"amount": 1000}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
|
||||
# Mocks for the estimation phase
|
||||
# 1. request_mint(dummy_amount=1000) -> invoice_dummy
|
||||
# 2. melt_quote(invoice_dummy) -> fee=10
|
||||
|
||||
# Mocks for the execution phase
|
||||
# 3. request_mint(minted_amount=990) -> invoice_real
|
||||
# 4. melt_quote(invoice_real) -> amount=990, fee=10
|
||||
# 5. melt() -> success
|
||||
# 6. mint() -> success
|
||||
|
||||
mock_mint_quote_dummy = Mock(quote="dummy_quote", request="lnbc_dummy")
|
||||
mock_mint_quote_real = Mock(quote="real_quote", request="lnbc_real")
|
||||
|
||||
# side_effect for request_mint to return dummy then real
|
||||
mock_primary_wallet.request_mint = AsyncMock(
|
||||
side_effect=[mock_mint_quote_dummy, mock_mint_quote_real]
|
||||
)
|
||||
|
||||
mock_melt_quote_dummy = Mock(amount=1000, fee_reserve=10)
|
||||
mock_melt_quote_real = Mock(amount=990, fee_reserve=10)
|
||||
|
||||
# side_effect for melt_quote
|
||||
mock_token_wallet.melt_quote = AsyncMock(
|
||||
side_effect=[mock_melt_quote_dummy, mock_melt_quote_real]
|
||||
)
|
||||
|
||||
mock_token_wallet.melt = AsyncMock(return_value="melted_proofs")
|
||||
mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
amount, unit, mint = await swap_to_primary_mint(
|
||||
mock_token, mock_token_wallet
|
||||
)
|
||||
|
||||
assert amount == 990 # 1000 - 10
|
||||
assert unit == "sat"
|
||||
assert mint == "http://primary:3338"
|
||||
|
||||
# Verify call order/counts
|
||||
assert mock_primary_wallet.request_mint.call_count == 2
|
||||
# First call with full amount for estimation
|
||||
mock_primary_wallet.request_mint.assert_any_call(1000)
|
||||
# Second call with calculated amount
|
||||
mock_primary_wallet.request_mint.assert_any_call(990)
|
||||
|
||||
assert mock_token_wallet.melt_quote.call_count == 2
|
||||
assert mock_token_wallet.melt.called
|
||||
assert mock_primary_wallet.mint.called
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||
|
||||
from routstr.payment.cost_calculation import CostData # noqa: E402
|
||||
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||
|
||||
|
||||
def _make_provider() -> BaseUpstreamProvider:
|
||||
return BaseUpstreamProvider(base_url="http://test", api_key="test-key")
|
||||
|
||||
|
||||
def _make_httpx_response(status_code: int = 200) -> httpx.Response:
|
||||
return httpx.Response(status_code, headers={})
|
||||
|
||||
|
||||
def _make_cost_data(total_msats: int = 5000) -> CostData:
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=3000,
|
||||
output_msats=2000,
|
||||
total_msats=total_msats,
|
||||
total_usd=0.00025,
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-streaming (chat completions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_includes_cost_sats() -> None:
|
||||
provider = _make_provider()
|
||||
cost_data = _make_cost_data(total_msats=5000)
|
||||
|
||||
response_body = {
|
||||
"model": "gpt-4o",
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 150,
|
||||
"cost": 0.00025,
|
||||
},
|
||||
}
|
||||
content_str = json.dumps(response_body)
|
||||
httpx_response = _make_httpx_response()
|
||||
|
||||
with (
|
||||
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
|
||||
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
|
||||
):
|
||||
response = await provider.handle_x_cashu_non_streaming_response(
|
||||
content_str=content_str,
|
||||
response=httpx_response,
|
||||
amount=10000,
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
mint=None,
|
||||
payment_token_hash=None,
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert "cost_sats" in body["usage"]
|
||||
assert body["usage"]["cost_sats"] == 5 # 5000 msats // 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_cost_sats_value_rounds_down() -> None:
|
||||
provider = _make_provider()
|
||||
cost_data = _make_cost_data(total_msats=1999)
|
||||
|
||||
response_body = {"model": "gpt-4o", "usage": {"prompt_tokens": 10}}
|
||||
content_str = json.dumps(response_body)
|
||||
|
||||
with (
|
||||
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
|
||||
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
|
||||
):
|
||||
response = await provider.handle_x_cashu_non_streaming_response(
|
||||
content_str=content_str,
|
||||
response=_make_httpx_response(),
|
||||
amount=10000,
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert body["usage"]["cost_sats"] == 1 # 1999 // 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_streaming_preserves_existing_usage_fields() -> None:
|
||||
provider = _make_provider()
|
||||
cost_data = _make_cost_data(total_msats=3000)
|
||||
|
||||
response_body = {
|
||||
"model": "gpt-4o",
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 150,
|
||||
"cost": 0.00015,
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
|
||||
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
|
||||
):
|
||||
response = await provider.handle_x_cashu_non_streaming_response(
|
||||
content_str=json.dumps(response_body),
|
||||
response=_make_httpx_response(),
|
||||
amount=10000,
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
usage = body["usage"]
|
||||
assert usage["prompt_tokens"] == 100
|
||||
assert usage["completion_tokens"] == 50
|
||||
assert usage["total_tokens"] == 150
|
||||
assert usage["cost"] == 0.00015
|
||||
assert usage["cost_sats"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming (chat completions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _collect_streaming(response: object) -> list[str]:
|
||||
chunks: list[str] = []
|
||||
async for chunk in response.body_iterator: # type: ignore[attr-defined]
|
||||
if isinstance(chunk, bytes):
|
||||
chunks.append(chunk.decode("utf-8"))
|
||||
else:
|
||||
chunks.append(str(chunk))
|
||||
return chunks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_includes_cost_sats_in_usage_chunk() -> None:
|
||||
provider = _make_provider()
|
||||
cost_data = _make_cost_data(total_msats=7000)
|
||||
|
||||
usage_chunk = {
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4o",
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
|
||||
}
|
||||
content_str = "\n".join([
|
||||
'data: {"id":"chatcmpl-123","model":"gpt-4o","choices":[]}',
|
||||
f"data: {json.dumps(usage_chunk)}",
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
with patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)):
|
||||
response = await provider.handle_x_cashu_streaming_response(
|
||||
content_str=content_str,
|
||||
response=_make_httpx_response(),
|
||||
amount=10000,
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
mint=None,
|
||||
payment_token_hash=None,
|
||||
)
|
||||
|
||||
chunks = await _collect_streaming(response)
|
||||
|
||||
full_output = "".join(chunks)
|
||||
usage_line = next(
|
||||
line for line in full_output.split("\n") if '"usage"' in line and "cost_sats" in line
|
||||
)
|
||||
data_json = json.loads(usage_line.lstrip("data: ").strip())
|
||||
assert data_json["usage"]["cost_sats"] == 7 # 7000 // 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_non_usage_chunks_unmodified() -> None:
|
||||
provider = _make_provider()
|
||||
cost_data = _make_cost_data(total_msats=2000)
|
||||
|
||||
regular_chunk = {"id": "chatcmpl-123", "model": "gpt-4o", "choices": [{"delta": {"content": "hi"}}]}
|
||||
usage_chunk = {"id": "chatcmpl-123", "model": "gpt-4o", "usage": {"prompt_tokens": 10}}
|
||||
content_str = "\n".join([
|
||||
f"data: {json.dumps(regular_chunk)}",
|
||||
f"data: {json.dumps(usage_chunk)}",
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
with patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)):
|
||||
response = await provider.handle_x_cashu_streaming_response(
|
||||
content_str=content_str,
|
||||
response=_make_httpx_response(),
|
||||
amount=10000,
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
)
|
||||
|
||||
chunks = await _collect_streaming(response)
|
||||
|
||||
lines = [
|
||||
line for line in "".join(chunks).split("\n")
|
||||
if line.startswith("data: ") and line != "data: [DONE]"
|
||||
]
|
||||
regular_line_data = json.loads(lines[0][6:])
|
||||
# regular chunk should not have cost_sats injected
|
||||
assert "cost_sats" not in regular_line_data.get("usage", {})
|
||||
@@ -155,12 +155,6 @@ body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.pb-mobile-nav {
|
||||
padding-bottom: calc(5.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ModelsPage } from '@/components/models-page';
|
||||
|
||||
export default function ModelPage() {
|
||||
return <ModelsPage />;
|
||||
}
|
||||
+6
-9
@@ -449,7 +449,7 @@ function DashboardInsights({
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) =>
|
||||
labelFormatter={(_: React.ReactNode, payload) =>
|
||||
String(payload?.[0]?.payload?.type ?? '')
|
||||
}
|
||||
formatter={(value, name) => {
|
||||
@@ -916,8 +916,8 @@ export default function DashboardPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='min-w-0 flex-1 sm:max-w-[22rem]'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
||||
<div className='w-full max-w-[20rem] sm:max-w-[22rem]'>
|
||||
<div className='border-input bg-card/30 dark:bg-input/30 flex h-8 w-full min-w-0 items-stretch overflow-hidden rounded-lg border sm:h-9'>
|
||||
<Popover
|
||||
open={isCustomRangePickerOpen}
|
||||
@@ -982,18 +982,15 @@ export default function DashboardPage() {
|
||||
onClick={handleRefresh}
|
||||
variant='outline'
|
||||
disabled={isManualRefreshing}
|
||||
aria-label='Refresh analytics'
|
||||
className='h-8 w-8 shrink-0 rounded-lg p-0 sm:h-9 sm:w-auto sm:px-3'
|
||||
className='h-8 w-full px-2.5 text-xs sm:ml-auto sm:w-auto'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 sm:mr-1',
|
||||
'mr-1 h-3 w-3',
|
||||
isManualRefreshing && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span className='hidden sm:inline'>
|
||||
{isManualRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</span>
|
||||
{isManualRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function ProvidersPage() {
|
||||
api_version: null,
|
||||
enabled: true,
|
||||
provider_fee: 1.06,
|
||||
provider_settings: {},
|
||||
});
|
||||
|
||||
const getProviderFeePlaceholder = (type: string) => {
|
||||
@@ -101,6 +102,12 @@ export default function ProvidersPage() {
|
||||
[providerTypes]
|
||||
);
|
||||
|
||||
const { data: globalSettings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => AdminService.getSettings(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: providers = [],
|
||||
isLoading,
|
||||
@@ -213,6 +220,8 @@ export default function ProvidersPage() {
|
||||
api_key: '',
|
||||
api_version: null,
|
||||
enabled: true,
|
||||
provider_fee: 1.06,
|
||||
provider_settings: {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,6 +242,7 @@ export default function ProvidersPage() {
|
||||
api_version: provider.api_version || null,
|
||||
enabled: provider.enabled,
|
||||
provider_fee: provider.provider_fee,
|
||||
provider_settings: provider.provider_settings || {},
|
||||
});
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
@@ -245,6 +255,7 @@ export default function ProvidersPage() {
|
||||
api_version: formData.api_version,
|
||||
enabled: formData.enabled,
|
||||
provider_fee: formData.provider_fee,
|
||||
provider_settings: formData.provider_settings,
|
||||
};
|
||||
if (formData.api_key) {
|
||||
updateData.api_key = formData.api_key;
|
||||
@@ -331,6 +342,8 @@ export default function ProvidersPage() {
|
||||
setBatchOverrideProviderId(providerId);
|
||||
};
|
||||
|
||||
const availableMints = (globalSettings?.cashu_mints as string[]) || [];
|
||||
|
||||
return (
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
|
||||
<div className='@container/main flex flex-col gap-4 md:gap-8'>
|
||||
@@ -366,6 +379,7 @@ export default function ProvidersPage() {
|
||||
onCancel={() => setIsCreateDialogOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={createMutation.isPending}
|
||||
availableMints={availableMints}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
@@ -431,6 +445,13 @@ export default function ProvidersPage() {
|
||||
onOverrideModel={(model) =>
|
||||
handleOverrideModel(provider.id, model)
|
||||
}
|
||||
onUpdateApiKey={(newKey) => {
|
||||
updateMutation.mutate({
|
||||
id: provider.id,
|
||||
data: { api_key: newKey },
|
||||
});
|
||||
}}
|
||||
availableMints={availableMints}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -457,6 +478,7 @@ export default function ProvidersPage() {
|
||||
onCancel={() => setIsEditDialogOpen(false)}
|
||||
onSubmit={handleUpdate}
|
||||
isSubmitting={updateMutation.isPending}
|
||||
availableMints={availableMints}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
Check,
|
||||
Receipt,
|
||||
Key,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const STORAGE_KEY = 'routstr-transaction-filters';
|
||||
|
||||
function TransactionTable({
|
||||
transactions,
|
||||
copiedId,
|
||||
onCopy,
|
||||
getStatusBadge,
|
||||
}: {
|
||||
transactions: Transaction[];
|
||||
copiedId: string | null;
|
||||
onCopy: (text: string, id: string) => void;
|
||||
getStatusBadge: (tx: Transaction) => React.ReactNode;
|
||||
}) {
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Receipt className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No transactions found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Try adjusting your filters or check back later.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Request ID</TableHead>
|
||||
<TableHead>Mint</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className='text-right'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((tx) => (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{tx.type === 'in' ? (
|
||||
<ArrowDownLeft className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<ArrowUpRight className='h-4 w-4 text-blue-500' />
|
||||
)}
|
||||
<span className='capitalize'>{tx.type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-mono'>
|
||||
{tx.amount} {tx.unit}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(tx)}</TableCell>
|
||||
<TableCell>
|
||||
{tx.request_id ? (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<span className='max-w-[150px] truncate font-mono'>
|
||||
{tx.request_id}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4'
|
||||
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
|
||||
>
|
||||
{copiedId === tx.id + '-req' ? (
|
||||
<Check className='h-3 w-3' />
|
||||
) : (
|
||||
<Copy className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs'>—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
|
||||
<span className='truncate'>{tx.mint_url}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-xs whitespace-nowrap'>
|
||||
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => onCopy(tx.token, tx.id + '-token')}
|
||||
title='Copy Token'
|
||||
>
|
||||
{copiedId === tx.id + '-token' ? (
|
||||
<Check className='h-4 w-4' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = useState<string>('all');
|
||||
const [status, setStatus] = useState<string>('all');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
// Load filters from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.search) setSearch(parsed.search);
|
||||
if (parsed.type) setType(parsed.type);
|
||||
if (parsed.status) setStatus(parsed.status);
|
||||
} catch (e) {
|
||||
console.error('Failed to load filters from localStorage', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save filters to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
const filters = { search, type, status };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||
}, [search, type, status]);
|
||||
|
||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['transactions', type, status, search],
|
||||
queryFn: () =>
|
||||
AdminService.getTransactions(
|
||||
type === 'all' ? undefined : type,
|
||||
status === 'all' ? undefined : status,
|
||||
search || undefined,
|
||||
100
|
||||
),
|
||||
});
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearch('');
|
||||
setType('all');
|
||||
setStatus('all');
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const getStatusBadge = (tx: Transaction) => {
|
||||
if (tx.swept)
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
|
||||
>
|
||||
Swept
|
||||
</Badge>
|
||||
);
|
||||
if (tx.collected)
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-green-500/20 bg-green-500/10 text-green-500'
|
||||
>
|
||||
Collected
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
type !== 'all' || status !== 'all' || Boolean(search);
|
||||
|
||||
const activeFilterDescription = [
|
||||
type !== 'all' ? `type ${type === 'in' ? 'incoming' : 'outgoing'}` : null,
|
||||
status !== 'all' ? `status ${status}` : null,
|
||||
search ? `search "${search}"` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
const xcashuTxs =
|
||||
data?.transactions.filter((tx) => !tx.source || tx.source === 'x-cashu') ??
|
||||
[];
|
||||
const apikeyTxs =
|
||||
data?.transactions.filter((tx) => tx.source === 'apikey') ?? [];
|
||||
|
||||
const renderCardContent = (txs: Transaction[]) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`tx-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TransactionTable
|
||||
transactions={txs}
|
||||
copiedId={copiedId}
|
||||
onCopy={copyToClipboard}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='Cashu Transactions'
|
||||
description='View all incoming and outgoing Cashu token transactions.'
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Filter transactions by type, status, or search text
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='search'>Search</Label>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
id='search'
|
||||
placeholder='Search by ID, token or request ID...'
|
||||
className='pl-8'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='type'>Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Types</SelectItem>
|
||||
<SelectItem value='in'>Incoming (Payments)</SelectItem>
|
||||
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='status'>Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Status' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Statuses</SelectItem>
|
||||
<SelectItem value='pending'>Pending</SelectItem>
|
||||
<SelectItem value='collected'>Collected</SelectItem>
|
||||
<SelectItem value='swept'>Swept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
|
||||
<Button
|
||||
onClick={handleClearFilters}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue='x-cashu'>
|
||||
<TabsList className='mb-4'>
|
||||
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
|
||||
<Zap className='h-4 w-4' />
|
||||
X-Cashu
|
||||
{data && (
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{xcashuTxs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='apikey' className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
API Key Refunds
|
||||
{data && (
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{apikeyTxs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='x-cashu'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>X-Cashu Transaction History</CardTitle>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{renderCardContent(xcashuTxs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='apikey'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>API Key Refund History</CardTitle>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{renderCardContent(apikeyTxs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { Check, Copy, Loader2, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||
|
||||
@@ -64,6 +64,7 @@ const FormSchema = z.object({
|
||||
instruct_type: z.string().default(''),
|
||||
canonical_slug: z.string().default(''),
|
||||
alias_ids_raw: z.string().default(''),
|
||||
forwarded_model_id: z.string().default(''),
|
||||
upstream_provider_id: z.string().default(''),
|
||||
input_cost: z.coerce.number().min(0).default(0),
|
||||
output_cost: z.coerce.number().min(0).default(0),
|
||||
@@ -104,6 +105,7 @@ export function AddProviderModelDialog({
|
||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||
useState('Select a preset');
|
||||
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(FormSchema) as never,
|
||||
@@ -119,6 +121,7 @@ export function AddProviderModelDialog({
|
||||
instruct_type: '',
|
||||
canonical_slug: '',
|
||||
alias_ids_raw: '',
|
||||
forwarded_model_id: '',
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
@@ -180,6 +183,7 @@ export function AddProviderModelDialog({
|
||||
: '',
|
||||
canonical_slug: initialData.canonical_slug || '',
|
||||
alias_ids_raw: listToString(initialData.alias_ids),
|
||||
forwarded_model_id: initialData.forwarded_model_id || initialData.id,
|
||||
upstream_provider_id:
|
||||
typeof initialData.upstream_provider_id === 'string'
|
||||
? initialData.upstream_provider_id
|
||||
@@ -223,6 +227,7 @@ export function AddProviderModelDialog({
|
||||
instruct_type: '',
|
||||
canonical_slug: '',
|
||||
alias_ids_raw: '',
|
||||
forwarded_model_id: '',
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
@@ -280,6 +285,7 @@ export function AddProviderModelDialog({
|
||||
);
|
||||
form.setValue('canonical_slug', model.canonical_slug || '');
|
||||
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
||||
form.setValue('forwarded_model_id', model.forwarded_model_id || model.id);
|
||||
form.setValue(
|
||||
'upstream_provider_id',
|
||||
typeof model.upstream_provider_id === 'string'
|
||||
@@ -385,6 +391,7 @@ export function AddProviderModelDialog({
|
||||
canonical_slug: data.canonical_slug?.trim() || null,
|
||||
alias_ids: listFromString(data.alias_ids_raw || ''),
|
||||
enabled: data.enabled,
|
||||
forwarded_model_id: data.forwarded_model_id?.trim() || data.id,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
@@ -520,6 +527,53 @@ export function AddProviderModelDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='forwarded_model_id'
|
||||
render={({ field }) => {
|
||||
const handleCopy = () => {
|
||||
const value = field.value || form.getValues('id');
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value);
|
||||
setForwardedModelIdCopied(true);
|
||||
setTimeout(() => setForwardedModelIdCopied(false), 1500);
|
||||
};
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Upstream Model ID</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
placeholder={
|
||||
form.watch('id') || 'e.g., openai/gpt-4o'
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleCopy}
|
||||
title='Copy model ID'
|
||||
>
|
||||
{forwardedModelIdCopied ? (
|
||||
<Check className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Model ID sent to the upstream provider. Defaults to the
|
||||
model's own ID.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ApiKeyInputProps extends React.ComponentProps<'input'> {
|
||||
onApiKeyChange: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
export function ApiKeyInput({
|
||||
value,
|
||||
onApiKeyChange,
|
||||
...props
|
||||
}: ApiKeyInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || '');
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
onApiKeyChange(internalValue as string);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [internalValue, onApiKeyChange]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={internalValue}
|
||||
onChange={(e) => setInternalValue(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
ArrowRightLeftIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
@@ -39,8 +40,9 @@ const NAV_ITEMS = [
|
||||
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
|
||||
{ title: 'Balances', url: '/balances', icon: WalletIcon },
|
||||
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
||||
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
||||
{ title: 'Models', url: '/model', icon: DatabaseIcon },
|
||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
|
||||
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
ArrowRightLeftIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -45,6 +46,11 @@ const data = {
|
||||
url: '/balances',
|
||||
icon: WalletIcon,
|
||||
},
|
||||
{
|
||||
title: 'Transactions',
|
||||
url: '/transactions',
|
||||
icon: ArrowRightLeftIcon,
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
url: '/logs',
|
||||
@@ -52,7 +58,7 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: 'Models',
|
||||
url: '/models',
|
||||
url: '/model',
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
import { ApiKeyInput } from './api-key-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,17 +16,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Key,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KeyOptions } from './key-options';
|
||||
|
||||
interface KeyConfig {
|
||||
@@ -42,6 +35,14 @@ interface ChildKeyCreatorProps {
|
||||
costPerKeyMsats?: number;
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
export function ChildKeyCreator({
|
||||
baseUrl,
|
||||
apiKey: propApiKey,
|
||||
@@ -50,6 +51,7 @@ export function ChildKeyCreator({
|
||||
}: ChildKeyCreatorProps) {
|
||||
const [internalApiKey, setInternalApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [configs, setConfigs] = useState<KeyConfig[]>([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
@@ -59,15 +61,15 @@ export function ChildKeyCreator({
|
||||
validityDate: '',
|
||||
},
|
||||
]);
|
||||
const [childKeyToCheck, setChildKeyToCheck] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [keyStatus, setKeyStatus] = useState<{
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
validity_date: number | null;
|
||||
is_expired: boolean;
|
||||
is_drained: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const activeApiKey = propApiKey ?? internalApiKey;
|
||||
const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey);
|
||||
|
||||
const handleApiKeyChange = (val: string) => {
|
||||
setInternalApiKey(val);
|
||||
onApiKeyChange?.(val);
|
||||
};
|
||||
|
||||
const [newKeys, setNewKeys] = useState<string[]>([]);
|
||||
const [resultInfo, setResultInfo] = useState<{
|
||||
cost_msats: number;
|
||||
@@ -75,13 +77,6 @@ export function ChildKeyCreator({
|
||||
} | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const activeApiKey = propApiKey ?? internalApiKey;
|
||||
|
||||
const handleApiKeyChange = (val: string) => {
|
||||
setInternalApiKey(val);
|
||||
onApiKeyChange?.(val);
|
||||
};
|
||||
|
||||
const addConfig = () => {
|
||||
setConfigs([
|
||||
...configs,
|
||||
@@ -112,6 +107,7 @@ export function ChildKeyCreator({
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let allNewKeys: string[] = [];
|
||||
let totalCost = 0;
|
||||
@@ -152,55 +148,21 @@ export function ChildKeyCreator({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create child key:', error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create child key'
|
||||
);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to create child key';
|
||||
try {
|
||||
const parsed = JSON.parse(errorMessage);
|
||||
errorMessage =
|
||||
parsed.detail?.error?.message ||
|
||||
(typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
|
||||
} catch {}
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckKey = async () => {
|
||||
if (!childKeyToCheck) {
|
||||
toast.error('Please provide a Child API key to check');
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
setKeyStatus(null);
|
||||
try {
|
||||
const baseUrlToUse = baseUrl || '';
|
||||
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${childKeyToCheck}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
|
||||
const info = await response.json();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
setKeyStatus({
|
||||
total_spent: info.total_spent,
|
||||
balance_limit: info.balance_limit,
|
||||
validity_date: info.validity_date,
|
||||
is_expired: info.validity_date ? now > info.validity_date : false,
|
||||
is_drained: info.balance_limit
|
||||
? info.total_spent >= info.balance_limit
|
||||
: false,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to check child key'
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
@@ -243,12 +205,55 @@ export function ChildKeyCreator({
|
||||
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
|
||||
Parent API Key
|
||||
</Label>
|
||||
<Input
|
||||
value={activeApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex-1'>
|
||||
<ApiKeyInput
|
||||
value={activeApiKey}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => navigator.clipboard.writeText(activeApiKey)}
|
||||
disabled={!activeApiKey}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
{walletInfo && (
|
||||
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spendable Balance
|
||||
</span>
|
||||
<span className='text-primary font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{walletInfo.totalRequests}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Spent
|
||||
</span>
|
||||
<div className='text-right'>
|
||||
<p className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.totalSpent)} sats
|
||||
</p>
|
||||
<p className='text-muted-foreground font-mono text-[0.6rem]'>
|
||||
{formatMsats(walletInfo.totalSpent)} msats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -362,11 +367,18 @@ export function ChildKeyCreator({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Each key creation has a small one-time fee.
|
||||
</p>
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Each key creation has a small one-time fee.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{newKeys.length > 0 && (
|
||||
<div className='mt-6 space-y-4'>
|
||||
@@ -458,89 +470,6 @@ export function ChildKeyCreator({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
|
||||
<CardDescription>
|
||||
View the current spending, limit, and expiration status of any child
|
||||
key.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
|
||||
Child API Key
|
||||
</Label>
|
||||
<Input
|
||||
value={childKeyToCheck}
|
||||
onChange={(e) => setChildKeyToCheck(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCheckKey}
|
||||
disabled={checking || !childKeyToCheck}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Check Status
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{keyStatus && (
|
||||
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Total Spent:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{keyStatus.total_spent} mSats
|
||||
</span>
|
||||
</div>
|
||||
{keyStatus.balance_limit !== null && (
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Limit:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{keyStatus.balance_limit} mSats
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{keyStatus.validity_date !== null && (
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Expires:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{new Date(
|
||||
keyStatus.validity_date * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex gap-2 pt-2'>
|
||||
{keyStatus.is_drained && (
|
||||
<Badge variant='destructive'>Drained</Badge>
|
||||
)}
|
||||
{keyStatus.is_expired && (
|
||||
<Badge variant='destructive'>Expired</Badge>
|
||||
)}
|
||||
{!keyStatus.is_drained && !keyStatus.is_expired && (
|
||||
<Badge>Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { type Model } from '@/lib/api/schemas/models';
|
||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Edit3, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
const EditModelFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
context_length: z.number().min(0),
|
||||
prompt: z.number().min(0),
|
||||
completion: z.number().min(0),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type EditModelFormData = z.infer<typeof EditModelFormSchema>;
|
||||
|
||||
const roundToFiveDecimals = (value: number | undefined | null): number => {
|
||||
if (value === undefined || value === null || isNaN(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(value * 100000) / 100000;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const toStringArray = (value: unknown, fallback: string[]): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const filtered = value.filter(
|
||||
(item): item is string => typeof item === 'string'
|
||||
);
|
||||
return filtered.length > 0 ? filtered : fallback;
|
||||
};
|
||||
|
||||
interface EditModelFormProps {
|
||||
model: Model;
|
||||
providerId?: number;
|
||||
onModelUpdate?: () => void;
|
||||
onCancel?: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface AdminModelData {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
created: number;
|
||||
context_length: number;
|
||||
architecture: {
|
||||
modality: string;
|
||||
input_modalities: string[];
|
||||
output_modalities: string[];
|
||||
tokenizer: string;
|
||||
instruct_type: string | null;
|
||||
};
|
||||
pricing: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
request: number;
|
||||
image: number;
|
||||
web_search: number;
|
||||
internal_reasoning: number;
|
||||
};
|
||||
per_request_limits: null | undefined;
|
||||
top_provider: null | undefined;
|
||||
upstream_provider_id: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const normalizeAdminModelData = (
|
||||
adminModel: AdminModel,
|
||||
fallbackModel: Model,
|
||||
providerId: number
|
||||
): AdminModelData => {
|
||||
const pricingRecord =
|
||||
adminModel.pricing && typeof adminModel.pricing === 'object'
|
||||
? (adminModel.pricing as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const architectureRecord =
|
||||
adminModel.architecture && typeof adminModel.architecture === 'object'
|
||||
? (adminModel.architecture as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: adminModel.id,
|
||||
name: adminModel.name,
|
||||
description: adminModel.description || '',
|
||||
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
|
||||
context_length: Math.max(
|
||||
0,
|
||||
Math.trunc(
|
||||
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
|
||||
)
|
||||
),
|
||||
architecture: {
|
||||
modality:
|
||||
typeof architectureRecord.modality === 'string'
|
||||
? architectureRecord.modality
|
||||
: fallbackModel.modelType || 'text',
|
||||
input_modalities: toStringArray(architectureRecord.input_modalities, [
|
||||
fallbackModel.modelType || 'text',
|
||||
]),
|
||||
output_modalities: toStringArray(architectureRecord.output_modalities, [
|
||||
fallbackModel.modelType || 'text',
|
||||
]),
|
||||
tokenizer:
|
||||
typeof architectureRecord.tokenizer === 'string'
|
||||
? architectureRecord.tokenizer
|
||||
: '',
|
||||
instruct_type:
|
||||
typeof architectureRecord.instruct_type === 'string'
|
||||
? architectureRecord.instruct_type
|
||||
: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: roundToFiveDecimals(
|
||||
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
|
||||
),
|
||||
completion: roundToFiveDecimals(
|
||||
toNumber(pricingRecord.completion, fallbackModel.output_cost)
|
||||
),
|
||||
request: toNumber(pricingRecord.request, 0),
|
||||
image: toNumber(pricingRecord.image, 0),
|
||||
web_search: toNumber(pricingRecord.web_search, 0),
|
||||
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
|
||||
},
|
||||
per_request_limits:
|
||||
adminModel.per_request_limits === null ||
|
||||
adminModel.per_request_limits === undefined
|
||||
? adminModel.per_request_limits
|
||||
: null,
|
||||
top_provider:
|
||||
adminModel.top_provider === null || adminModel.top_provider === undefined
|
||||
? adminModel.top_provider
|
||||
: null,
|
||||
upstream_provider_id:
|
||||
typeof adminModel.upstream_provider_id === 'number'
|
||||
? adminModel.upstream_provider_id
|
||||
: providerId,
|
||||
enabled: adminModel.enabled !== false,
|
||||
};
|
||||
};
|
||||
|
||||
export function EditModelForm({
|
||||
model,
|
||||
providerId,
|
||||
onModelUpdate,
|
||||
onCancel,
|
||||
isOpen,
|
||||
}: EditModelFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [adminModelData, setAdminModelData] = useState<AdminModelData | null>(
|
||||
null
|
||||
);
|
||||
const [isNewOverride, setIsNewOverride] = useState(false);
|
||||
|
||||
const form = useForm<EditModelFormData>({
|
||||
resolver: zodResolver(EditModelFormSchema),
|
||||
defaultValues: {
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
context_length: model.contextLength || 4096,
|
||||
prompt: roundToFiveDecimals(model.input_cost),
|
||||
completion: roundToFiveDecimals(model.output_cost),
|
||||
enabled: model.isEnabled !== false,
|
||||
},
|
||||
});
|
||||
|
||||
const loadAdminModel = useCallback(async () => {
|
||||
if (!providerId) {
|
||||
console.error('loadAdminModel called without providerId');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adminModel = await AdminService.getProviderModel(
|
||||
providerId,
|
||||
model.id
|
||||
);
|
||||
|
||||
const normalizedAdminModel = normalizeAdminModelData(
|
||||
adminModel,
|
||||
model,
|
||||
providerId
|
||||
);
|
||||
setAdminModelData(normalizedAdminModel);
|
||||
setIsNewOverride(false);
|
||||
|
||||
form.reset({
|
||||
name: normalizedAdminModel.name,
|
||||
description: normalizedAdminModel.description || '',
|
||||
context_length: normalizedAdminModel.context_length,
|
||||
prompt: normalizedAdminModel.pricing.prompt,
|
||||
completion: normalizedAdminModel.pricing.completion,
|
||||
enabled: normalizedAdminModel.enabled !== false,
|
||||
});
|
||||
} catch {
|
||||
setIsNewOverride(true);
|
||||
setAdminModelData({
|
||||
id: model.full_name,
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
context_length: model.contextLength || 4096,
|
||||
architecture: {
|
||||
modality: model.modelType || 'text',
|
||||
input_modalities: [model.modelType || 'text'],
|
||||
output_modalities: [model.modelType || 'text'],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: roundToFiveDecimals(model.input_cost),
|
||||
completion: roundToFiveDecimals(model.output_cost),
|
||||
request: 0,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
internal_reasoning: 0,
|
||||
},
|
||||
per_request_limits: null,
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled !== false,
|
||||
});
|
||||
|
||||
form.reset({
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
context_length: model.contextLength || 4096,
|
||||
prompt: roundToFiveDecimals(model.input_cost),
|
||||
completion: roundToFiveDecimals(model.output_cost),
|
||||
enabled: model.isEnabled !== false,
|
||||
});
|
||||
}
|
||||
}, [providerId, model, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && providerId) {
|
||||
loadAdminModel();
|
||||
} else if (isOpen && !providerId) {
|
||||
console.error('EditModelForm opened without providerId', {
|
||||
model,
|
||||
providerId,
|
||||
});
|
||||
toast.error('Missing provider information for this model');
|
||||
}
|
||||
}, [isOpen, providerId, model, loadAdminModel]);
|
||||
|
||||
const onSubmit = async (data: EditModelFormData) => {
|
||||
if (!providerId) {
|
||||
console.error('onSubmit called without providerId', {
|
||||
model,
|
||||
providerId,
|
||||
});
|
||||
toast.error('Missing provider ID - cannot update model');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adminModelData) {
|
||||
console.error('onSubmit called without adminModelData', {
|
||||
model,
|
||||
providerId,
|
||||
adminModelData,
|
||||
});
|
||||
toast.error('Model data not loaded - please try reopening the form');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
id: adminModelData.id,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
created: adminModelData.created || Math.floor(Date.now() / 1000),
|
||||
context_length: data.context_length,
|
||||
architecture: adminModelData.architecture || {
|
||||
modality: 'text',
|
||||
input_modalities: ['text'],
|
||||
output_modalities: ['text'],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: roundToFiveDecimals(data.prompt),
|
||||
completion: roundToFiveDecimals(data.completion),
|
||||
request: 0,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
internal_reasoning: 0,
|
||||
},
|
||||
per_request_limits: adminModelData.per_request_limits,
|
||||
top_provider: adminModelData.top_provider,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: data.enabled,
|
||||
};
|
||||
|
||||
if (isNewOverride) {
|
||||
await AdminService.createProviderModel(providerId, payload);
|
||||
toast.success('Model override created successfully!');
|
||||
} else {
|
||||
await AdminService.updateProviderModel(
|
||||
providerId,
|
||||
adminModelData.id,
|
||||
payload
|
||||
);
|
||||
toast.success('Model updated successfully!');
|
||||
}
|
||||
|
||||
onModelUpdate?.();
|
||||
onCancel?.();
|
||||
} catch (error) {
|
||||
const action = isNewOverride ? 'create' : 'update';
|
||||
toast.error(`Failed to ${action} model. Please try again.`);
|
||||
console.error(`Error ${action}ing model:`, error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSubmitting) {
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Edit3 className='h-5 w-5' />
|
||||
{isNewOverride ? 'Create Model Override' : 'Edit Model Override'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isNewOverride
|
||||
? `Create an override for "${model.name}"`
|
||||
: `Update the model override for "${model.name}"`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='e.g., GPT-4'
|
||||
{...field}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom display name for the model
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='context_length'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Context Length *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min='0'
|
||||
placeholder='4096'
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(
|
||||
value === '' ? 0 : parseInt(value, 10) || 0
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(
|
||||
Number.isNaN(value) ? 0 : Math.max(0, value)
|
||||
);
|
||||
}}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum context window size
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Brief description of the model...'
|
||||
{...field}
|
||||
rows={3}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional description or notes about the model
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='prompt'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Input Cost (per 1M tokens) *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.00001'
|
||||
min='0'
|
||||
placeholder='5.00000'
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? 0 : parseFloat(value));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
field.onChange(roundToFiveDecimals(value));
|
||||
}}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Cost in USD per 1,000,000 input tokens (max 5 decimals)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='completion'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Output Cost (per 1M tokens) *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.00001'
|
||||
min='0'
|
||||
placeholder='15.00000'
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? 0 : parseFloat(value));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
field.onChange(roundToFiveDecimals(value));
|
||||
}}
|
||||
className='w-full'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Cost in USD per 1,000,000 output tokens (max 5 decimals)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>Model Enabled</FormLabel>
|
||||
<FormDescription>
|
||||
Enable or disable this model override
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{isNewOverride ? 'Creating...' : 'Updating...'}
|
||||
</>
|
||||
) : isNewOverride ? (
|
||||
'Create Override'
|
||||
) : (
|
||||
'Update Model'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function ApiKeyManager({
|
||||
className='gap-2'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing...' : 'Refund & Delete Key'}
|
||||
{isRefunding ? 'Processing...' : 'Refund Key'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { Copy, RefreshCcw } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import { WalletBalanceStats } from './wallet-balance-stats';
|
||||
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
|
||||
import { ApiKeyInput } from '../api-key-input';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
|
||||
export type RefundReceipt = {
|
||||
token?: string;
|
||||
@@ -29,80 +30,41 @@ interface CashuPaymentWorkflowProps {
|
||||
onRefundComplete?: (receipt: RefundReceipt) => void;
|
||||
}
|
||||
|
||||
async function fetchWalletInfo(
|
||||
baseUrl: string,
|
||||
apiKey: string
|
||||
): Promise<WalletSnapshot> {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Unable to load wallet info');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
api_key: string;
|
||||
balance: number;
|
||||
reserved?: number;
|
||||
is_child: boolean;
|
||||
parent_key: string | null;
|
||||
total_requests: number;
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
balance_limit_reset: string | null;
|
||||
validity_date: number | null;
|
||||
child_keys?: ChildKeyInfo[];
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey: payload.api_key || apiKey,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
export function CashuPaymentWorkflow({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
walletInfo: propWalletInfo = null,
|
||||
onApiKeyCreated,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
onRefundComplete,
|
||||
}: CashuPaymentWorkflowProps): JSX.Element {
|
||||
const [initialToken, setInitialToken] = useState('');
|
||||
const [topupToken, setTopupToken] = useState('');
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isCreatingKey, setIsCreatingKey] = useState(false);
|
||||
const [isTopupLoading, setIsTopupLoading] = useState(false);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
|
||||
const [hasInteractedManage, setHasInteractedManage] = useState(false);
|
||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||
const [balanceLimit, setBalanceLimit] = useState<string>('');
|
||||
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
|
||||
const [validityDate, setValidityDate] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
|
||||
const {
|
||||
data: queryWalletInfo,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useWalletInfo(baseUrl, activeApiKey);
|
||||
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
||||
|
||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
@@ -203,20 +165,18 @@ export function CashuPaymentWorkflow({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncingBalance(true);
|
||||
setError(null);
|
||||
try {
|
||||
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
await refetch();
|
||||
toast.success('Balance synced');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to sync balance'
|
||||
);
|
||||
} finally {
|
||||
setIsSyncingBalance(false);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to sync balance';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, onWalletInfoUpdated]);
|
||||
}, [activeApiKey, refetch]);
|
||||
|
||||
const handleTopup = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
@@ -245,59 +205,25 @@ export function CashuPaymentWorkflow({
|
||||
const payload = (await response.json()) as { msats: number };
|
||||
toast.success(`Added ${formatSats(payload.msats)} sats`);
|
||||
setTopupToken('');
|
||||
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
|
||||
onApiKeyCreated?.(snapshot.apiKey, snapshot);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Top-up failed');
|
||||
} finally {
|
||||
setIsTopupLoading(false);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefunding(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${activeApiKey}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Refund failed');
|
||||
}
|
||||
const payload = (await response.json()) as RefundReceipt;
|
||||
onRefundComplete?.(payload);
|
||||
onWalletInfoUpdated?.(null);
|
||||
setApiKeyInput('');
|
||||
toast.success('Refund requested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]);
|
||||
}, [activeApiKey, baseUrl, topupToken, refetch]);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
onApiKeyChanged?.(newKey);
|
||||
if (newKey !== apiKey) {
|
||||
onWalletInfoUpdated?.(null);
|
||||
}
|
||||
},
|
||||
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
|
||||
[apiKey, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
|
||||
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
|
||||
const canTopup = Boolean(activeApiKey);
|
||||
const showCreateDetails = initialToken.trim().length > 0;
|
||||
@@ -365,40 +291,72 @@ export function CashuPaymentWorkflow({
|
||||
)}
|
||||
</header>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
<ApiKeyInput
|
||||
value={apiKeyInput}
|
||||
onChange={(event) => handleApiKeyChange(event.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
onFocus={() => setHasInteractedManage(true)}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-10 w-10'
|
||||
onClick={() => handleCopy(activeApiKey)}
|
||||
disabled={!activeApiKey}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='gap-1'
|
||||
onClick={handleSyncBalance}
|
||||
disabled={isSyncingBalance || !activeApiKey}
|
||||
disabled={isFetching || !activeApiKey}
|
||||
>
|
||||
<RefreshCcw className='h-4 w-4' />
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showManageDetails && (
|
||||
<WalletBalanceStats
|
||||
balanceMsats={walletInfo?.balanceMsats}
|
||||
reservedMsats={walletInfo?.reservedMsats}
|
||||
/>
|
||||
{walletInfo && (
|
||||
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spendable Balance
|
||||
</span>
|
||||
<span className='text-primary font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{walletInfo.totalRequests}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Spent
|
||||
</span>
|
||||
<div className='text-right'>
|
||||
<p className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.totalSpent)} sats
|
||||
</p>
|
||||
<p className='text-muted-foreground font-mono text-[0.6rem]'>
|
||||
{formatMsats(walletInfo.totalSpent)} msats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant='destructive' className='mt-2'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -444,28 +402,6 @@ export function CashuPaymentWorkflow({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>4 · Refund</span>
|
||||
</header>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
onClick={handleRefund}
|
||||
disabled={isRefunding || !activeApiKey}
|
||||
variant='destructive'
|
||||
className='gap-2'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type RefundReceipt,
|
||||
} from './cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||
import { ApiKeyManager } from './api-key-manager';
|
||||
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
|
||||
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||
|
||||
@@ -135,8 +134,8 @@ export function CheatSheet(): JSX.Element {
|
||||
|
||||
const handleRefundComplete = useCallback((receipt: RefundReceipt) => {
|
||||
setRefundReceipt(receipt);
|
||||
setWalletInfo(null);
|
||||
setApiKeyInput('');
|
||||
// setWalletInfo(null); // Keep info
|
||||
// setApiKeyInput(''); // Keep input
|
||||
}, []);
|
||||
|
||||
const handleRefreshInfo = useCallback(async (): Promise<void> => {
|
||||
@@ -391,12 +390,11 @@ export function CheatSheet(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue='cashu' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-5'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
|
||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||
<TabsTrigger value='details'>Key Details</TabsTrigger>
|
||||
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||
<TabsTrigger value='management'>Key Management</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='cashu' className='space-y-4'>
|
||||
@@ -413,8 +411,8 @@ export function CheatSheet(): JSX.Element {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='manage' className='space-y-4'>
|
||||
<ApiKeyManager
|
||||
<TabsContent value='management' className='space-y-4'>
|
||||
<KeyInfoDetails
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
@@ -445,7 +443,7 @@ export function CheatSheet(): JSX.Element {
|
||||
<Textarea
|
||||
value={refundToken}
|
||||
readOnly
|
||||
rows={4}
|
||||
rows={15}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
@@ -454,16 +452,6 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='details' className='space-y-4'>
|
||||
<KeyInfoDetails
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
onApiKeyChanged={handleApiKeyChanged}
|
||||
onWalletInfoUpdated={handleWalletInfoUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='child-keys' className='space-y-4'>
|
||||
<ChildKeyCreator
|
||||
baseUrl={normalizedBaseUrl}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiKeyInput } from '../api-key-input';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,8 +14,9 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
|
||||
|
||||
export type ChildKeyInfo = {
|
||||
api_key: string;
|
||||
@@ -44,68 +47,44 @@ interface KeyInfoDetailsProps {
|
||||
walletInfo?: WalletSnapshot | null;
|
||||
onApiKeyChanged?: (apiKey: string) => void;
|
||||
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||
onRefundComplete?: (receipt: RefundReceipt) => void;
|
||||
}
|
||||
|
||||
export function KeyInfoDetails({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
walletInfo: propWalletInfo = null,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
}: KeyInfoDetailsProps): JSX.Element {
|
||||
onRefundComplete,
|
||||
}: KeyInfoDetailsProps): React.ReactNode {
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: queryWalletInfo,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useWalletInfo(baseUrl, apiKeyInput);
|
||||
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
||||
|
||||
// Sync internal state with props if they change
|
||||
useEffect(() => {
|
||||
setApiKeyInput(apiKey);
|
||||
}, [apiKey]);
|
||||
|
||||
const fetchDetails = useCallback(
|
||||
async (keyToFetch: string) => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
headers: { Authorization: `Bearer ${keyToFetch}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
const payload = await response.json();
|
||||
const snapshot: WalletSnapshot = {
|
||||
apiKey: payload.api_key || keyToFetch,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
toast.success('Key details synced');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to fetch details'
|
||||
);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
[baseUrl, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const handleRefresh = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!apiKeyInput) return;
|
||||
await fetchDetails(apiKeyInput);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleKeyChange = (newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
setError(null);
|
||||
onApiKeyChanged?.(newKey);
|
||||
// Optionally clear info when key changes
|
||||
if (newKey !== apiKey) {
|
||||
@@ -125,7 +104,7 @@ export function KeyInfoDetails({
|
||||
try {
|
||||
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
|
||||
toast.success('Child key spent reset');
|
||||
await fetchDetails(apiKeyInput);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to reset child key'
|
||||
@@ -135,6 +114,36 @@ export function KeyInfoDetails({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
if (!apiKeyInput) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefunding(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKeyInput}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Refund failed');
|
||||
}
|
||||
const receipt = (await response.json()) as RefundReceipt;
|
||||
onRefundComplete?.(receipt);
|
||||
toast.success('Refund completed');
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [apiKeyInput, baseUrl, onRefundComplete, refetch]);
|
||||
|
||||
const formatSats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
const formatMsats = (msats: number) =>
|
||||
@@ -153,17 +162,11 @@ export function KeyInfoDetails({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<ApiKeyInput value={apiKeyInput} onApiKeyChange={handleKeyChange} />
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-10 w-10 shrink-0'
|
||||
onClick={() => handleCopy(apiKeyInput)}
|
||||
disabled={!apiKeyInput}
|
||||
>
|
||||
@@ -174,15 +177,22 @@ export function KeyInfoDetails({
|
||||
size='sm'
|
||||
className='min-w-[80px] gap-1'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || !apiKeyInput}
|
||||
disabled={isFetching || !apiKeyInput}
|
||||
type='button'
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
className={`h-8 w-4 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isRefreshing ? 'Syncing...' : 'Sync'}
|
||||
{isFetching ? 'Syncing...' : 'Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='mt-2'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -228,6 +238,14 @@ export function KeyInfoDetails({
|
||||
{formatDate(walletInfo.validityDate)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Infos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spendable Balance
|
||||
@@ -236,14 +254,6 @@ export function KeyInfoDetails({
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Consumption</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
@@ -390,18 +400,16 @@ export function KeyInfoDetails({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleRefund}
|
||||
disabled={isRefunding || !apiKeyInput}
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className='text-muted-foreground'
|
||||
className='gap-2'
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Last synced: {new Date().toLocaleTimeString()}
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing...' : 'Refund Key'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, RotateCcw } from 'lucide-react';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface KeyInfoDisplayProps {
|
||||
walletInfo: WalletSnapshot;
|
||||
onResetSpent?: (childKey: string) => Promise<void>;
|
||||
isResetting?: string | null;
|
||||
}
|
||||
|
||||
const formatSats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
const formatMsats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(msats);
|
||||
const formatDate = (timestamp: number | null) =>
|
||||
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
|
||||
|
||||
export function KeyInfoDisplay({
|
||||
walletInfo,
|
||||
onResetSpent,
|
||||
isResetting,
|
||||
}: KeyInfoDisplayProps) {
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Status & Identity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Type</span>
|
||||
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
|
||||
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
|
||||
</Badge>
|
||||
</div>
|
||||
{walletInfo.parentKey && (
|
||||
<div className='space-y-1'>
|
||||
<span className='text-muted-foreground text-xs tracking-wider'>
|
||||
Parent Key
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||
{walletInfo.parentKey}
|
||||
</code>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(walletInfo.parentKey!)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Validity</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{formatDate(walletInfo.validityDate)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Infos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spendable Balance
|
||||
</span>
|
||||
<span className='text-primary font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{walletInfo.totalRequests}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>Total Spent</span>
|
||||
<div className='text-right'>
|
||||
<p className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.totalSpent)} sats
|
||||
</p>
|
||||
<p className='text-muted-foreground font-mono text-[0.6rem]'>
|
||||
{formatMsats(walletInfo.totalSpent)} msats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{walletInfo.balanceLimit !== null && (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Spend Limit
|
||||
</span>
|
||||
<span className='font-mono text-sm font-medium'>
|
||||
{formatSats(walletInfo.balanceLimit)} sats
|
||||
</span>
|
||||
</div>
|
||||
{walletInfo.balanceLimitReset && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Reset Policy
|
||||
</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{walletInfo.balanceLimitReset}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{!walletInfo.isChild &&
|
||||
walletInfo.childKeys &&
|
||||
walletInfo.childKeys.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>
|
||||
Child Keys ({walletInfo.childKeys.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Secondary keys using this account's balance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{walletInfo.childKeys.map((ck) => (
|
||||
<div
|
||||
key={ck.api_key}
|
||||
className='space-y-3 rounded-lg border p-4'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
|
||||
{ck.api_key}
|
||||
</code>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() => handleCopy(ck.api_key)}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
{onResetSpent && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-destructive h-8 w-8'
|
||||
title='Reset consumption'
|
||||
disabled={isResetting === ck.api_key}
|
||||
onClick={() => onResetSpent(ck.api_key)}
|
||||
>
|
||||
{isResetting === ck.api_key ? (
|
||||
<RotateCcw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RotateCcw className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function ModelsPage() {
|
||||
export function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||
undefined
|
||||
);
|
||||
@@ -23,11 +23,15 @@ import {
|
||||
interface ProviderBalanceProps {
|
||||
providerId: number;
|
||||
platformUrl?: string | null;
|
||||
isRoutstr?: boolean;
|
||||
nodeUrl?: string;
|
||||
}
|
||||
|
||||
export function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
isRoutstr = false,
|
||||
nodeUrl,
|
||||
}: ProviderBalanceProps) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
@@ -100,14 +104,23 @@ export function ProviderBalance({
|
||||
});
|
||||
|
||||
const handleTopup = () => {
|
||||
const amount = parseFloat(topupAmount);
|
||||
const amount = Number(topupAmount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
setTopupError('Please enter a valid amount');
|
||||
if (Number.isNaN(amount)) {
|
||||
setTopupError(
|
||||
isRoutstr
|
||||
? 'Please enter a valid amount in sats'
|
||||
: 'Please enter a valid amount'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < 1 || amount > 500) {
|
||||
if (isRoutstr) {
|
||||
if (!Number.isInteger(amount) || amount < 1) {
|
||||
setTopupError('Amount must be a whole number of sats');
|
||||
return;
|
||||
}
|
||||
} else if (amount < 1 || amount > 500) {
|
||||
setTopupError('Amount must be between $1 and $500');
|
||||
return;
|
||||
}
|
||||
@@ -153,15 +166,24 @@ export function ProviderBalance({
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance === 'number') {
|
||||
displayValue = `$${balance.toFixed(2)}`;
|
||||
displayValue = isRoutstr
|
||||
? `${balance.toLocaleString()} sats`
|
||||
: `$${balance.toFixed(2)}`;
|
||||
} else if (balance && typeof balance === 'object') {
|
||||
const b = balance as Record<string, unknown>;
|
||||
if (typeof b.balance === 'number') {
|
||||
displayValue = `$${b.balance.toFixed(2)}`;
|
||||
displayValue = isRoutstr
|
||||
? `${b.balance.toLocaleString()} sats`
|
||||
: `$${b.balance.toFixed(2)}`;
|
||||
} else if (typeof b.balance === 'string') {
|
||||
displayValue = b.balance;
|
||||
} else if (b.amount !== undefined) {
|
||||
displayValue = `$${Number(b.amount).toFixed(2)}`;
|
||||
const amount = Number(b.amount);
|
||||
if (!Number.isNaN(amount)) {
|
||||
displayValue = isRoutstr
|
||||
? `${amount.toLocaleString()} sats`
|
||||
: `$${amount.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +215,9 @@ export function ProviderBalance({
|
||||
? 'Your account balance has been updated.'
|
||||
: invoiceData
|
||||
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
||||
: 'Enter the amount you want to add to your account balance.'}
|
||||
: isRoutstr
|
||||
? `Top up your balance on node ${nodeUrl || ''}`.trim()
|
||||
: 'Enter the amount you want to add to your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -254,20 +278,29 @@ export function ProviderBalance({
|
||||
) : (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='topup_amount'>Amount (USD)</Label>
|
||||
<Label htmlFor='topup_amount'>
|
||||
{isRoutstr ? 'Amount (sats)' : 'Amount (USD)'}
|
||||
</Label>
|
||||
<Input
|
||||
id='topup_amount'
|
||||
type='number'
|
||||
placeholder='Enter amount (1-500)'
|
||||
placeholder={
|
||||
isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)'
|
||||
}
|
||||
value={topupAmount}
|
||||
onChange={(e) => {
|
||||
setTopupAmount(e.target.value);
|
||||
setTopupError('');
|
||||
}}
|
||||
min='1'
|
||||
max='500'
|
||||
step='0.01'
|
||||
max={isRoutstr ? undefined : '500'}
|
||||
step={isRoutstr ? '1' : '0.01'}
|
||||
/>
|
||||
{isRoutstr && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
The invoice amount will be created in sats.
|
||||
</p>
|
||||
)}
|
||||
{topupError && (
|
||||
<p className='text-destructive text-sm'>{topupError}</p>
|
||||
)}
|
||||
|
||||
@@ -12,9 +12,30 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { ChevronDown, ChevronUp, Database, Pencil, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Database,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { ProviderBalance } from '@/components/provider-balance';
|
||||
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: UpstreamProvider;
|
||||
@@ -32,6 +53,8 @@ interface ProviderCardProps {
|
||||
onEditModel: (model: AdminModel) => void;
|
||||
onDeleteModel: (modelId: string) => void;
|
||||
onOverrideModel: (model: AdminModel) => void;
|
||||
onUpdateApiKey: (newKey: string) => void;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderCard({
|
||||
@@ -50,8 +73,30 @@ export function ProviderCard({
|
||||
onEditModel,
|
||||
onDeleteModel,
|
||||
onOverrideModel,
|
||||
onUpdateApiKey,
|
||||
}: ProviderCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
||||
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
||||
const isRoutstr = provider.provider_type === 'routstr';
|
||||
|
||||
const refundMutation = useMutation({
|
||||
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||
onSuccess: (data) => {
|
||||
if (data.ok) {
|
||||
toast.success('Refund successful', { description: data.message });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
||||
} else {
|
||||
toast.error('Refund failed', { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Refund error: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -76,14 +121,57 @@ export function ProviderCard({
|
||||
|
||||
<div className='grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:justify-end'>
|
||||
{canShowBalance && provider.api_key && (
|
||||
<div className='col-span-2 sm:col-auto'>
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-2 sm:col-auto',
|
||||
provider.provider_type === 'routstr' && 'col-span-1'
|
||||
)}
|
||||
>
|
||||
<ProviderBalance
|
||||
providerId={provider.id}
|
||||
platformUrl={platformUrl}
|
||||
isRoutstr={provider.provider_type === 'routstr'}
|
||||
nodeUrl={provider.base_url}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoutstr && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsKeyModalOpen(true)}
|
||||
className={cn(
|
||||
'justify-center gap-1.5',
|
||||
canShowBalance && provider.api_key
|
||||
? 'col-span-1'
|
||||
: 'col-span-2 sm:col-auto'
|
||||
)}
|
||||
>
|
||||
<Key className='h-4 w-4' />
|
||||
<span>New Key</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isRoutstr && provider.api_key && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => refundMutation.mutate()}
|
||||
disabled={refundMutation.isPending}
|
||||
className='justify-center gap-1.5 text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||
title='Refund balance to local wallet'
|
||||
>
|
||||
<RotateCcw
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
refundMutation.isPending && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span>Refund</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -124,6 +212,52 @@ export function ProviderCard({
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{provider.api_key
|
||||
? 'Create New Key on Upstream Node'
|
||||
: 'Create API Key'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{provider.api_key
|
||||
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
|
||||
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='py-4'>
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={provider.base_url || ''}
|
||||
onApiKeyCreated={async (newApiKey) => {
|
||||
if (provider.api_key) {
|
||||
try {
|
||||
const result = await RoutstrProviderService.refundBalance(
|
||||
provider.id
|
||||
);
|
||||
if (result.ok) {
|
||||
toast.success('Old key refunded', {
|
||||
description: result.message,
|
||||
});
|
||||
} else {
|
||||
toast.warning('Refund skipped', {
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.warning(
|
||||
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
onUpdateApiKey(newApiKey);
|
||||
setIsKeyModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{hasDetails ? (
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
|
||||
@@ -30,6 +30,7 @@ interface ProviderFormDialogContentProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderFormDialogContent({
|
||||
@@ -49,6 +50,7 @@ export function ProviderFormDialogContent({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
availableMints,
|
||||
}: ProviderFormDialogContentProps) {
|
||||
return (
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
||||
@@ -66,6 +68,7 @@ export function ProviderFormDialogContent({
|
||||
canCreateAccount={canCreateAccount}
|
||||
isCreatingAccount={isCreatingAccount}
|
||||
onCreateAccount={onCreateAccount}
|
||||
availableMints={availableMints}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings';
|
||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||
|
||||
interface ProviderFormFieldsProps {
|
||||
mode: 'create' | 'edit';
|
||||
@@ -28,6 +30,7 @@ interface ProviderFormFieldsProps {
|
||||
canCreateAccount: boolean;
|
||||
isCreatingAccount: boolean;
|
||||
onCreateAccount: () => void;
|
||||
availableMints: string[];
|
||||
}
|
||||
|
||||
export function ProviderFormFields({
|
||||
@@ -40,6 +43,7 @@ export function ProviderFormFields({
|
||||
canCreateAccount,
|
||||
isCreatingAccount,
|
||||
onCreateAccount,
|
||||
availableMints,
|
||||
}: ProviderFormFieldsProps) {
|
||||
const idPrefix = mode === 'edit' ? 'edit_' : '';
|
||||
const providerType = providerTypes.find(
|
||||
@@ -47,6 +51,12 @@ export function ProviderFormFields({
|
||||
);
|
||||
const hasFixedBaseUrl = providerType?.fixed_base_url || false;
|
||||
const platformUrl = providerType?.platform_url || null;
|
||||
|
||||
const getDefaultBaseUrl = (type: string) => {
|
||||
const selectedType = providerTypes.find((pt) => pt.id === type);
|
||||
return selectedType?.default_base_url || '';
|
||||
};
|
||||
|
||||
const isGenericType = (type: ProviderType) =>
|
||||
type.id.toLowerCase() === 'generic';
|
||||
const nonGenericTypes = providerTypes.filter((type) => !isGenericType(type));
|
||||
@@ -63,11 +73,10 @@ export function ProviderFormFields({
|
||||
<Select
|
||||
value={formData.provider_type}
|
||||
onValueChange={(value) => {
|
||||
const selectedType = providerTypes.find((pt) => pt.id === value);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_type: value,
|
||||
base_url: selectedType?.default_base_url || '',
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
||||
}));
|
||||
}}
|
||||
@@ -95,6 +104,20 @@ export function ProviderFormFields({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.provider_type === 'routstr' && (
|
||||
<RoutstrNodeSettings
|
||||
settings={formData.provider_settings || {}}
|
||||
onSettingsChange={(settings) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_settings: settings,
|
||||
}))
|
||||
}
|
||||
availableMints={availableMints}
|
||||
idPrefix={mode === 'edit' ? 'edit' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
|
||||
<Input
|
||||
@@ -109,43 +132,45 @@ export function ProviderFormFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<Label htmlFor={`${idPrefix}api_key`}>{apiKeyLabel}</Label>
|
||||
{mode === 'create' && canCreateAccount ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onCreateAccount}
|
||||
disabled={isCreatingAccount}
|
||||
className='h-6 w-full text-xs sm:w-auto'
|
||||
>
|
||||
{isCreatingAccount ? 'Creating...' : 'Create Account'}
|
||||
</Button>
|
||||
) : (
|
||||
platformUrl && (
|
||||
<a
|
||||
href={platformUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${docsLinkClassName} break-all`}
|
||||
{formData.provider_type !== 'routstr' && (
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<Label htmlFor={`${idPrefix}api_key`}>{apiKeyLabel}</Label>
|
||||
{mode === 'create' && canCreateAccount ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onCreateAccount}
|
||||
disabled={isCreatingAccount}
|
||||
className='h-6 w-full text-xs sm:w-auto'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
{isCreatingAccount ? 'Creating...' : 'Create Account'}
|
||||
</Button>
|
||||
) : (
|
||||
platformUrl && (
|
||||
<a
|
||||
href={platformUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`${docsLinkClassName} break-all`}
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id={`${idPrefix}api_key`}
|
||||
type='password'
|
||||
value={formData.api_key}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, api_key: e.target.value }))
|
||||
}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id={`${idPrefix}api_key`}
|
||||
type='password'
|
||||
value={formData.api_key}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, api_key: e.target.value }))
|
||||
}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.provider_type === 'azure' && (
|
||||
<div className='grid gap-2'>
|
||||
@@ -199,6 +224,18 @@ export function ProviderFormFields({
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === 'create' && formData.provider_type === 'routstr' && (
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={formData.base_url || ''}
|
||||
onApiKeyCreated={(newApiKey) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
api_key: newApiKey,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SimpleLightningTopup } from './SimpleLightningTopup';
|
||||
import { SimpleCashuTopup } from './SimpleCashuTopup';
|
||||
|
||||
interface ProviderBalanceProps {
|
||||
providerId: number;
|
||||
platformUrl?: string | null;
|
||||
isRoutstr?: boolean;
|
||||
nodeUrl?: string;
|
||||
}
|
||||
|
||||
export function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
isRoutstr = false,
|
||||
nodeUrl,
|
||||
}: ProviderBalanceProps) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: balanceData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const handleTopUpClick = () => {
|
||||
if (
|
||||
platformUrl &&
|
||||
(platformUrl.includes('openrouter.ai') ||
|
||||
platformUrl.includes('openai.com'))
|
||||
) {
|
||||
window.open(platformUrl, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTopupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsTopupDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-9 w-24' />;
|
||||
}
|
||||
|
||||
if (
|
||||
error ||
|
||||
!balanceData?.ok ||
|
||||
balanceData.balance_data === undefined ||
|
||||
balanceData.balance_data === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const balance = balanceData.balance_data;
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance === 'number') {
|
||||
displayValue = isRoutstr
|
||||
? `${balance.toLocaleString()} sats`
|
||||
: `$${balance.toFixed(2)}`;
|
||||
} else if (balance && typeof balance === 'object') {
|
||||
const b = balance as Record<string, unknown>;
|
||||
if (typeof b.balance === 'number') {
|
||||
displayValue = `$${b.balance.toFixed(2)}`;
|
||||
} else if (typeof b.balance === 'string') {
|
||||
displayValue = b.balance;
|
||||
} else if (b.amount !== undefined) {
|
||||
displayValue = `$${Number(b.amount).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleTopUpClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='w-full font-mono sm:w-auto'
|
||||
>
|
||||
{isHovered ? 'Top Up' : displayValue}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Top Up Balance</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isRoutstr
|
||||
? `Top up your balance on node ${nodeUrl}`
|
||||
: 'Choose a payment method to top up your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-6 py-4'>
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Lightning Top-up
|
||||
</Label>
|
||||
<SimpleLightningTopup
|
||||
providerId={providerId}
|
||||
baseUrl={nodeUrl || ''}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
Cashu Token Top-up
|
||||
</Label>
|
||||
<SimpleCashuTopup
|
||||
providerId={providerId}
|
||||
baseUrl={nodeUrl || ''}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCloseDialog}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface RoutstrCreateKeySectionProps {
|
||||
baseUrl: string;
|
||||
onApiKeyCreated: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
async function generateQR(text: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(text, {
|
||||
type: 'image/png',
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function RoutstrCreateKeySection({
|
||||
baseUrl,
|
||||
onApiKeyCreated,
|
||||
}: RoutstrCreateKeySectionProps) {
|
||||
// Lightning state
|
||||
const [lnAmount, setLnAmount] = useState('');
|
||||
const [lnInvoice, setLnInvoice] = useState<{
|
||||
bolt11: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [lnQrCode, setLnQrCode] = useState('');
|
||||
const [isCreatingLn, setIsCreatingLn] = useState(false);
|
||||
const [isWaitingLn, setIsWaitingLn] = useState(false);
|
||||
|
||||
// Cashu state
|
||||
const [cashuToken, setCashuToken] = useState('');
|
||||
const [isCreatingCashu, setIsCreatingCashu] = useState(false);
|
||||
|
||||
if (!baseUrl) {
|
||||
return (
|
||||
<div className='bg-muted/30 rounded-lg border p-4'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Enter the upstream node Base URL above to enable key creation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const pollInvoiceStatus = (invoiceId: string) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${cleanUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
|
||||
);
|
||||
if (!resp.ok) throw new Error('Failed to check status');
|
||||
|
||||
const status = await resp.json();
|
||||
|
||||
if (status.status === 'paid' && status.api_key) {
|
||||
onApiKeyCreated(status.api_key);
|
||||
setLnInvoice(null);
|
||||
setLnQrCode('');
|
||||
setIsWaitingLn(false);
|
||||
setLnAmount('');
|
||||
toast.success('Payment received! API key created.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'expired' || status.status === 'cancelled') {
|
||||
toast.error('Invoice expired or cancelled');
|
||||
setIsWaitingLn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
toast.error('Payment timeout');
|
||||
setIsWaitingLn(false);
|
||||
}
|
||||
} catch {
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
setIsWaitingLn(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleCreateLightning = async () => {
|
||||
const amount = parseInt(lnAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
toast.error('Enter a valid amount in sats');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingLn(true);
|
||||
try {
|
||||
const resp = await fetch(`${cleanUrl}/v1/balance/lightning/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount_sats: amount, purpose: 'create' }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
throw new Error(errorText || 'Failed to create invoice');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
setLnInvoice({ bolt11: data.bolt11, invoice_id: data.invoice_id });
|
||||
|
||||
const qr = await generateQR(data.bolt11);
|
||||
setLnQrCode(qr);
|
||||
setIsWaitingLn(true);
|
||||
|
||||
pollInvoiceStatus(data.invoice_id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create invoice');
|
||||
} finally {
|
||||
setIsCreatingLn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCashu = async () => {
|
||||
if (!cashuToken.trim()) {
|
||||
toast.error('Paste a Cashu token');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingCashu(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
initial_balance_token: cashuToken.trim(),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${cleanUrl}/v1/balance/create?${params.toString()}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
throw new Error(errorText || 'Failed to create API key');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
onApiKeyCreated(data.api_key);
|
||||
setCashuToken('');
|
||||
toast.success('API key created');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create key');
|
||||
} finally {
|
||||
setIsCreatingCashu(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 space-y-4 rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-sm font-semibold'>Create API Key</Label>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
External Node
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Create an API key on the upstream Routstr node by paying with Lightning
|
||||
or Cashu.
|
||||
</p>
|
||||
|
||||
<Tabs defaultValue='lightning' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='lightning' className='gap-1 text-xs'>
|
||||
<Zap className='h-3 w-3' />
|
||||
Lightning
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='cashu' className='gap-1 text-xs'>
|
||||
<KeyRound className='h-3 w-3' />
|
||||
Cashu
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='lightning' className='mt-3 space-y-3'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='Amount in sats'
|
||||
value={lnAmount}
|
||||
onChange={(e) => setLnAmount(e.target.value)}
|
||||
className='h-9'
|
||||
disabled={isWaitingLn}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateLightning}
|
||||
disabled={isCreatingLn || isWaitingLn}
|
||||
size='sm'
|
||||
>
|
||||
{isCreatingLn ? 'Creating...' : 'Get Invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lnInvoice && (
|
||||
<div className='space-y-2 border-t pt-2'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-xs'>
|
||||
<span>Pay this invoice to create your key</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6'
|
||||
onClick={() => handleCopy(lnInvoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
{lnQrCode && (
|
||||
<div className='flex justify-center py-2'>
|
||||
<Image
|
||||
src={lnQrCode}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-48 w-48'
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='bg-muted rounded border p-2 font-mono text-[10px] break-all'>
|
||||
{lnInvoice.bolt11}
|
||||
</div>
|
||||
{isWaitingLn && (
|
||||
<div className='flex animate-pulse items-center gap-2 text-xs text-orange-600'>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Waiting for payment...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='cashu' className='mt-3 space-y-3'>
|
||||
<Textarea
|
||||
placeholder='Paste Cashu token (cashuA1...)'
|
||||
value={cashuToken}
|
||||
onChange={(e) => setCashuToken(e.target.value)}
|
||||
rows={3}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateCashu}
|
||||
disabled={isCreatingCashu}
|
||||
size='sm'
|
||||
className='w-full'
|
||||
>
|
||||
{isCreatingCashu ? 'Creating...' : 'Create API Key'}
|
||||
</Button>
|
||||
<p className='text-muted-foreground text-[10px]'>
|
||||
Redeems the token instantly and returns an API key.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface ProviderSettings {
|
||||
topup_mint_url?: string;
|
||||
auto_topup?: boolean;
|
||||
topup_threshold?: number;
|
||||
topup_amount_limit?: number;
|
||||
refund_on_expiry?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RoutstrNodeSettingsProps {
|
||||
settings: ProviderSettings;
|
||||
onSettingsChange: (settings: ProviderSettings) => void;
|
||||
availableMints: string[];
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function RoutstrNodeSettings({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
availableMints,
|
||||
idPrefix = '',
|
||||
}: RoutstrNodeSettingsProps) {
|
||||
const prefix = idPrefix ? `${idPrefix}_` : '';
|
||||
|
||||
const update = (patch: Partial<ProviderSettings>) => {
|
||||
onSettingsChange({ ...settings, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
|
||||
<Label className='text-sm font-semibold'>Routstr Node Settings</Label>
|
||||
|
||||
<div className='grid gap-3'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={`${prefix}topup_mint_url`} className='text-xs'>
|
||||
Top-up Mint
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.topup_mint_url || ''}
|
||||
onValueChange={(value) => update({ topup_mint_url: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`${prefix}topup_mint_url`}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
<SelectValue placeholder='Select a mint from your node configuration' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableMints.length > 0 ? (
|
||||
availableMints.map((mint) => (
|
||||
<SelectItem key={mint} value={mint} className='text-xs'>
|
||||
{mint}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value='none' disabled className='text-xs'>
|
||||
No mints configured in global settings
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-[10px]'>
|
||||
The token for top-up will be created from this mint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor={`${prefix}auto_topup`} className='text-sm'>
|
||||
Enable Auto Top-up
|
||||
</Label>
|
||||
<Switch
|
||||
id={`${prefix}auto_topup`}
|
||||
checked={!!settings.auto_topup}
|
||||
onCheckedChange={(checked) => update({ auto_topup: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.auto_topup && (
|
||||
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label
|
||||
htmlFor={`${prefix}topup_threshold`}
|
||||
className='text-xs font-medium'
|
||||
>
|
||||
When credits are below (Sats)
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}topup_threshold`}
|
||||
type='number'
|
||||
className='h-9'
|
||||
placeholder='e.g. 1000'
|
||||
value={settings.topup_threshold || ''}
|
||||
onChange={(e) =>
|
||||
update({ topup_threshold: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label
|
||||
htmlFor={`${prefix}topup_amount_limit`}
|
||||
className='text-xs font-medium'
|
||||
>
|
||||
Purchase this amount (Sats)
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}topup_amount_limit`}
|
||||
type='number'
|
||||
className='h-9'
|
||||
placeholder='e.g. 5000'
|
||||
value={settings.topup_amount_limit || ''}
|
||||
onChange={(e) =>
|
||||
update({ topup_amount_limit: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Database,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdminService,
|
||||
UpstreamProvider,
|
||||
UpdateUpstreamProvider,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||
import { RoutstrCreateKeySection } from './RoutstrCreateKeySection';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RoutstrProviderCardProps {
|
||||
provider: UpstreamProvider;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdateKey?: () => void;
|
||||
balanceComponent: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RoutstrProviderCard({
|
||||
provider,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
balanceComponent,
|
||||
children,
|
||||
}: RoutstrProviderCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
|
||||
|
||||
const hasMint = !!provider.provider_settings?.topup_mint_url;
|
||||
const hasApiKey = !!provider.api_key;
|
||||
|
||||
const refundMutation = useMutation({
|
||||
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||
onSuccess: (data) => {
|
||||
if (data.ok) {
|
||||
toast.success('Refund successful', {
|
||||
description: data.message,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['balances'] });
|
||||
} else {
|
||||
toast.error('Refund failed', {
|
||||
description: data.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Refund error: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateKeyMutation = useMutation({
|
||||
mutationFn: (data: { id: number; data: UpdateUpstreamProvider }) =>
|
||||
AdminService.updateUpstreamProvider(data.id, data.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', provider.id],
|
||||
});
|
||||
setIsKeyDialogOpen(false);
|
||||
toast.success('API key saved to provider');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to save key: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyCreated = async (newApiKey: string) => {
|
||||
if (hasApiKey) {
|
||||
try {
|
||||
const result = await RoutstrProviderService.refundBalance(provider.id);
|
||||
if (result.ok) {
|
||||
toast.success('Old key refunded', {
|
||||
description: result.message,
|
||||
});
|
||||
} else {
|
||||
toast.warning('Refund skipped', {
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.warning(
|
||||
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
updateKeyMutation.mutate({
|
||||
id: provider.id,
|
||||
data: { api_key: newApiKey },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
||||
<CardTitle className='truncate text-lg'>Routstr Node</CardTitle>
|
||||
<Badge
|
||||
variant={provider.enabled ? 'default' : 'secondary'}
|
||||
className='w-fit sm:ml-2'
|
||||
>
|
||||
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{!hasApiKey && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
No API Key
|
||||
</Badge>
|
||||
)}
|
||||
{!hasMint && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
|
||||
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
|
||||
>
|
||||
<AlertTriangle className='h-3 w-3' />
|
||||
Top-up Disabled: No Mint Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className='mt-1 break-all'>
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{hasApiKey && (
|
||||
<div className='flex flex-col gap-1'>{balanceComponent}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsKeyDialogOpen(true)}
|
||||
className='w-full sm:w-auto'
|
||||
title={
|
||||
hasApiKey
|
||||
? 'Create a new key on the upstream node'
|
||||
: 'Create an API key on the upstream node'
|
||||
}
|
||||
>
|
||||
<KeyRound className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
{hasApiKey ? 'New Key' : 'Create Key'}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => refundMutation.mutate()}
|
||||
disabled={refundMutation.isPending}
|
||||
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
|
||||
title='Refund balance to local wallet'
|
||||
>
|
||||
<RotateCcw
|
||||
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<span className='hidden sm:inline'>Refund</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onToggleExpand}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Database className='mr-1 h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Models</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className='ml-1 h-4 w-4' />
|
||||
) : (
|
||||
<ChevronDown className='ml-1 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onEdit}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onDelete}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{children}
|
||||
</Card>
|
||||
|
||||
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasApiKey ? 'Create New Key on Upstream Node' : 'Create API Key'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasApiKey
|
||||
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
|
||||
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RoutstrCreateKeySection
|
||||
baseUrl={provider.base_url}
|
||||
onApiKeyCreated={handleKeyCreated}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setIsKeyDialogOpen(false)}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
|
||||
interface SimpleCashuTopupProps {
|
||||
providerId: number;
|
||||
baseUrl: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function SimpleCashuTopup({
|
||||
providerId,
|
||||
onSuccess,
|
||||
}: SimpleCashuTopupProps): JSX.Element {
|
||||
const [token, setToken] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleTopup = async () => {
|
||||
if (!token.trim()) {
|
||||
toast.error('Enter a Cashu token');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Use the backend to proxy the token topup
|
||||
// This is safer as the backend has the actual API key
|
||||
const response = await AdminService.topupProviderWithToken(
|
||||
providerId,
|
||||
token.trim()
|
||||
);
|
||||
if (!response.ok) throw new Error(response.message || 'Top-up failed');
|
||||
|
||||
toast.success('Token redeemed successfully!');
|
||||
setToken('');
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
toast.error(error.message || 'Top-up failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
placeholder='Paste Cashu token here...'
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
rows={2}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
onClick={handleTopup}
|
||||
disabled={isLoading}
|
||||
size='sm'
|
||||
className='w-full'
|
||||
>
|
||||
{isLoading ? 'Redeeming...' : 'Redeem Token'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Copy, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
|
||||
async function generateQRCodeSVG(text: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(text, {
|
||||
type: 'image/png',
|
||||
width: 400,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface SimpleLightningTopupProps {
|
||||
providerId: number;
|
||||
baseUrl: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function SimpleLightningTopup({
|
||||
providerId,
|
||||
onSuccess,
|
||||
}: SimpleLightningTopupProps): JSX.Element {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [invoice, setInvoice] = useState<{
|
||||
bolt11: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [qrCode, setQrCode] = useState<string>('');
|
||||
const [isWaiting, setIsWaiting] = useState(false);
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const pollStatus = useCallback(
|
||||
async (invoiceId: string) => {
|
||||
const maxAttempts = 60; // 5 minutes with 5 second intervals
|
||||
let attempts = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await AdminService.checkTopupStatus(
|
||||
providerId,
|
||||
invoiceId
|
||||
);
|
||||
|
||||
if (response.paid) {
|
||||
toast.success('Payment received!');
|
||||
setInvoice(null);
|
||||
setQrCode('');
|
||||
setIsWaiting(false);
|
||||
onSuccess?.();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
toast.error('Payment timeout - please check manually');
|
||||
setIsWaiting(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to poll topup status:', e);
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 5000);
|
||||
} else {
|
||||
toast.error('Failed to check payment status');
|
||||
setIsWaiting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
poll();
|
||||
},
|
||||
[providerId, onSuccess]
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const amt = parseInt(amount);
|
||||
if (!amt) {
|
||||
toast.error('Enter a valid amount');
|
||||
return;
|
||||
}
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await AdminService.initiateProviderTopup(
|
||||
providerId,
|
||||
amt
|
||||
);
|
||||
if (!response.ok || !response.topup_data)
|
||||
throw new Error('Failed to create invoice');
|
||||
|
||||
const bolt11 = response.topup_data.payment_request as string;
|
||||
setInvoice({
|
||||
bolt11,
|
||||
invoice_id: response.topup_data.invoice_id as string,
|
||||
});
|
||||
|
||||
const qr = await generateQRCodeSVG(bolt11);
|
||||
setQrCode(qr);
|
||||
|
||||
setIsWaiting(true);
|
||||
pollStatus(response.topup_data.invoice_id as string);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
toast.error(error.message || 'Failed to request invoice from backend');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='Amount in sats'
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className='h-9'
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || isWaiting}
|
||||
size='sm'
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Get Invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoice && (
|
||||
<div className='space-y-2 border-t pt-2'>
|
||||
<div className='text-muted-foreground flex items-center justify-between text-xs'>
|
||||
<span>Invoice Generated</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6'
|
||||
onClick={() => handleCopy(invoice.bolt11)}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
{qrCode && (
|
||||
<div className='flex justify-center py-2'>
|
||||
<Image
|
||||
src={qrCode}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-60 w-60'
|
||||
width={320}
|
||||
height={320}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='bg-muted rounded border p-2 font-mono text-[10px] break-all'>
|
||||
{invoice.bolt11}
|
||||
</div>
|
||||
{isWaiting && (
|
||||
<div className='flex animate-pulse items-center gap-2 text-xs text-orange-600'>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Waiting for payment...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
import { ModelRevenueData } from '@/lib/api/services/admin';
|
||||
import { convertToMsat, formatFromMsat } from '@/lib/currency';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
|
||||
interface RevenueByModelTableProps {
|
||||
models: ModelRevenueData[];
|
||||
displayUnit: DisplayUnit;
|
||||
usdPerSat: number | null;
|
||||
}
|
||||
|
||||
function truncateModelName(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function RevenueByModelTable({
|
||||
models,
|
||||
displayUnit,
|
||||
usdPerSat,
|
||||
}: RevenueByModelTableProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
|
||||
if (displayUnit === 'usd' && usdPerSat === null) {
|
||||
return 'sat';
|
||||
}
|
||||
return displayUnit;
|
||||
}, [displayUnit, usdPerSat]);
|
||||
const unitLabel = revenueDisplayUnit === 'usd' ? 'USD' : revenueDisplayUnit;
|
||||
|
||||
const compactNumber = useMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const convertSatsToDisplay = useCallback(
|
||||
(sats: number): number => {
|
||||
if (revenueDisplayUnit === 'msat') {
|
||||
return sats * 1000;
|
||||
}
|
||||
if (revenueDisplayUnit === 'usd') {
|
||||
return sats * (usdPerSat ?? 0);
|
||||
}
|
||||
return sats;
|
||||
},
|
||||
[revenueDisplayUnit, usdPerSat]
|
||||
);
|
||||
|
||||
const formatAmount = (sats: number) =>
|
||||
formatFromMsat(convertToMsat(sats, 'sat'), revenueDisplayUnit, usdPerSat);
|
||||
|
||||
const formatCompactAmount = (value: number): string => {
|
||||
const compact = compactNumber.format(value);
|
||||
if (revenueDisplayUnit === 'usd') {
|
||||
return `$${compact}`;
|
||||
}
|
||||
return `${compact} ${unitLabel}`;
|
||||
};
|
||||
|
||||
const totalCollectedRevenue = models.reduce(
|
||||
(sum, model) => sum + model.revenue_sats,
|
||||
0
|
||||
);
|
||||
const totalOperationalNet = models.reduce(
|
||||
(sum, model) => sum + model.net_revenue_sats,
|
||||
0
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
[...models]
|
||||
.sort((a, b) => b.revenue_sats - a.revenue_sats)
|
||||
.slice(0, 12)
|
||||
.map((model) => ({
|
||||
model: model.model,
|
||||
modelLabel: truncateModelName(model.model, isMobile ? 16 : 28),
|
||||
revenueDisplay: convertSatsToDisplay(model.revenue_sats),
|
||||
})),
|
||||
[models, isMobile, convertSatsToDisplay]
|
||||
);
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
revenueDisplay: {
|
||||
label: 'Revenue',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
};
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='text-muted-foreground text-sm'>
|
||||
No model data available
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Total Collected Revenue:{' '}
|
||||
<span className='text-foreground font-mono font-medium'>
|
||||
{formatAmount(totalCollectedRevenue)}
|
||||
</span>
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Operational Net:{' '}
|
||||
<span className='text-foreground font-mono'>
|
||||
{formatAmount(totalOperationalNet)}
|
||||
</span>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer className='h-[360px] w-full' config={chartConfig}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout='vertical'
|
||||
margin={{
|
||||
top: 8,
|
||||
right: isMobile ? 8 : 28,
|
||||
left: isMobile ? 8 : 28,
|
||||
bottom: 8,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid horizontal={false} className='stroke-muted/30' />
|
||||
<XAxis
|
||||
type='number'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
compactNumber.format(
|
||||
typeof value === 'number' ? value : Number(value || 0)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
type='category'
|
||||
dataKey='modelLabel'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={isMobile ? 110 : 220}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(label) => String(label)}
|
||||
formatter={(value, name) => {
|
||||
const numericValue =
|
||||
typeof value === 'number' ? value : Number(value || 0);
|
||||
return (
|
||||
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto] gap-x-4'>
|
||||
<span className='text-muted-foreground truncate pr-1'>
|
||||
{name}
|
||||
</span>
|
||||
<span className='text-foreground text-right font-mono font-medium tabular-nums'>
|
||||
{Number.isFinite(numericValue)
|
||||
? formatCompactAmount(numericValue)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='revenueDisplay'
|
||||
name={`Revenue (${unitLabel})`}
|
||||
fill='var(--color-revenueDisplay)'
|
||||
radius={[0, 6, 6, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ interface SettingsData {
|
||||
description?: string;
|
||||
npub?: string;
|
||||
nsec?: string;
|
||||
enable_analytics_sharing?: boolean;
|
||||
upstream_api_key?: string;
|
||||
http_url?: string;
|
||||
onion_url?: string;
|
||||
@@ -43,6 +44,7 @@ const HANDLED_KEYS = [
|
||||
'nsec',
|
||||
'cashu_mints',
|
||||
'relays',
|
||||
'enable_analytics_sharing',
|
||||
'admin_password',
|
||||
'id',
|
||||
'updated_at',
|
||||
@@ -366,6 +368,7 @@ export function AdminSettings() {
|
||||
const nostrChanged = ['npub', 'nsec'].some(hasFieldChanged);
|
||||
const cashuMintsChanged = hasFieldChanged('cashu_mints');
|
||||
const relaysChanged = hasFieldChanged('relays');
|
||||
const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing');
|
||||
const advancedKeys = Object.keys(settings).filter(
|
||||
(key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
|
||||
);
|
||||
@@ -397,6 +400,7 @@ export function AdminSettings() {
|
||||
resetFields(['relays']);
|
||||
setNewRelay('');
|
||||
};
|
||||
const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']);
|
||||
const resetAdvanced = () => resetFields(advancedKeys);
|
||||
|
||||
if (loading) {
|
||||
@@ -686,6 +690,52 @@ export function AdminSettings() {
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* Analytics Sharing */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analytics Sharing</CardTitle>
|
||||
<CardDescription>
|
||||
Publish aggregate usage stats to Nostr for external dashboards
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex items-center justify-between space-y-0 py-1'>
|
||||
<div className='space-y-1'>
|
||||
<Label htmlFor='enable_analytics_sharing'>
|
||||
Share analytics to Nostr
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
When enabled, Routstr periodically publishes aggregate model
|
||||
usage and revenue stats.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id='enable_analytics_sharing'
|
||||
checked={Boolean(settings.enable_analytics_sharing ?? true)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleInputChange('enable_analytics_sharing', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
{analyticsSharingChanged ? (
|
||||
<CardFooter className='justify-start'>
|
||||
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={resetAnalyticsSharing}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* Other Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -23,7 +23,7 @@ const PAGE_META: Record<string, { title: string; description: string }> = {
|
||||
title: 'System Logs',
|
||||
description: 'Inspect request and application logs.',
|
||||
},
|
||||
'/models': {
|
||||
'/model': {
|
||||
title: 'Models',
|
||||
description: 'Manage model catalog and provider mappings.',
|
||||
},
|
||||
|
||||
@@ -247,7 +247,6 @@ export function TopModelsUsageChart({
|
||||
const [hoveredSeriesKey, setHoveredSeriesKey] = useState<string | null>(null);
|
||||
const [isChartPointerInside, setIsChartPointerInside] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showAllMobileRows, setShowAllMobileRows] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const compactNumber = useMemo(
|
||||
@@ -549,21 +548,6 @@ export function TopModelsUsageChart({
|
||||
|
||||
return rows;
|
||||
}, [leaderboardModels, mixMetrics, mode, series]);
|
||||
const visibleLeaderboardRows = useMemo(
|
||||
() =>
|
||||
isMobile && !showAllMobileRows
|
||||
? leaderboardRows.slice(0, 10)
|
||||
: leaderboardRows,
|
||||
[isMobile, leaderboardRows, showAllMobileRows]
|
||||
);
|
||||
const hiddenLeaderboardCount = Math.max(
|
||||
0,
|
||||
leaderboardRows.length - visibleLeaderboardRows.length
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllMobileRows(false);
|
||||
}, [isMobile, mode, mixMetrics.length]);
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
@@ -797,14 +781,14 @@ export function TopModelsUsageChart({
|
||||
<p className='text-muted-foreground text-xs font-medium'>
|
||||
Top models
|
||||
</p>
|
||||
<p className='text-muted-foreground hidden text-xs sm:block'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Change vs prior period
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{leaderboardRows.length > 0 ? (
|
||||
<div className='divide-border/40 divide-y'>
|
||||
{visibleLeaderboardRows.map((row) => {
|
||||
{leaderboardRows.map((row) => {
|
||||
const rowIsLinked = Boolean(row.chartDataKey);
|
||||
const rowIsActive =
|
||||
row.chartDataKey !== null &&
|
||||
@@ -836,10 +820,7 @@ export function TopModelsUsageChart({
|
||||
<div
|
||||
key={row.model}
|
||||
className={cn(
|
||||
'grid rounded-md px-2 py-2 text-xs',
|
||||
isMobile
|
||||
? 'grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5'
|
||||
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-3 rounded-md px-2 py-2 text-xs',
|
||||
rowIsLinked &&
|
||||
'hover:bg-muted/25 cursor-pointer transition',
|
||||
rowIsActive && 'bg-muted/30',
|
||||
@@ -857,60 +838,23 @@ export function TopModelsUsageChart({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground w-5 text-right font-mono tabular-nums',
|
||||
isMobile && 'pt-0.5 text-[11px]'
|
||||
)}
|
||||
>
|
||||
<span className='text-muted-foreground w-5 text-right font-mono tabular-nums'>
|
||||
{row.rank}.
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className='min-w-0 pt-0.5'>
|
||||
<p className='leading-snug break-words'>
|
||||
<span className='font-medium'>
|
||||
{row.displayName}
|
||||
</span>{' '}
|
||||
<span className='text-muted-foreground'>
|
||||
by {row.provider}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex min-w-[5.75rem] flex-col items-end gap-1 text-right'>
|
||||
<span className='text-foreground font-mono tabular-nums'>
|
||||
{formatLeaderboardTotal(row.totalRaw)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-[11px] font-medium',
|
||||
trendClass
|
||||
)}
|
||||
>
|
||||
{trendLabel}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='min-w-0'>
|
||||
<span className='truncate font-medium'>
|
||||
{row.displayName}
|
||||
</span>{' '}
|
||||
<span className='text-muted-foreground truncate'>
|
||||
by {row.provider}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-foreground font-mono tabular-nums'>
|
||||
{formatLeaderboardTotal(row.totalRaw)}
|
||||
</span>
|
||||
<span
|
||||
className={cn('text-right font-medium', trendClass)}
|
||||
>
|
||||
{trendLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className='min-w-0'>
|
||||
<span className='truncate font-medium'>
|
||||
{row.displayName}
|
||||
</span>{' '}
|
||||
<span className='text-muted-foreground truncate'>
|
||||
by {row.provider}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-foreground font-mono tabular-nums'>
|
||||
{formatLeaderboardTotal(row.totalRaw)}
|
||||
</span>
|
||||
<span className={cn('font-medium', trendClass)}>
|
||||
{trendLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -920,30 +864,6 @@ export function TopModelsUsageChart({
|
||||
No model totals available for this range.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isMobile && hiddenLeaderboardCount > 0 ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-medium'
|
||||
onClick={() => setShowAllMobileRows(true)}
|
||||
>
|
||||
Show {hiddenLeaderboardCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isMobile && showAllMobileRows && leaderboardRows.length > 10 ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-medium'
|
||||
onClick={() => setShowAllMobileRows(false)}
|
||||
>
|
||||
Show less
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -260,7 +260,7 @@ function ChartTooltipContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
key={key}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center'
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { WalletSnapshot } from '@/components/landing/key-info-details';
|
||||
|
||||
export function useWalletInfo(baseUrl: string, apiKey: string) {
|
||||
return useQuery({
|
||||
queryKey: ['walletInfo', baseUrl, apiKey],
|
||||
queryFn: async (): Promise<WalletSnapshot> => {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Unable to load wallet info');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return {
|
||||
apiKey: payload.api_key || apiKey,
|
||||
balanceMsats: payload.balance ?? 0,
|
||||
reservedMsats: payload.reserved ?? 0,
|
||||
isChild: payload.is_child,
|
||||
parentKey: payload.parent_key,
|
||||
totalRequests: payload.total_requests,
|
||||
totalSpent: payload.total_spent,
|
||||
balanceLimit: payload.balance_limit,
|
||||
balanceLimitReset: payload.balance_limit_reset,
|
||||
validityDate: payload.validity_date,
|
||||
childKeys: payload.child_keys,
|
||||
};
|
||||
},
|
||||
enabled: !!baseUrl && !!apiKey,
|
||||
staleTime: 5000, // Consider data stale after 5 seconds
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,11 @@ class ApiClient {
|
||||
private handleAuthError(error: unknown): void {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError;
|
||||
if (axiosError.response?.status === 401) {
|
||||
const status = axiosError.response?.status;
|
||||
const requestUrl = axiosError.config?.url ?? '';
|
||||
const isAdminRequest = requestUrl.includes('/admin/');
|
||||
|
||||
if (status === 401 || (status === 403 && isAdminRequest)) {
|
||||
ConfigurationService.clearToken();
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
|
||||
@@ -20,6 +20,7 @@ export const UpstreamProviderSchema = z.object({
|
||||
api_version: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const CreateUpstreamProviderSchema = z.object({
|
||||
@@ -29,6 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({
|
||||
api_version: z.string().nullable().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const UpdateUpstreamProviderSchema = z.object({
|
||||
@@ -38,6 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({
|
||||
api_version: z.string().nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const AdminModelPricingSchema = z.object({
|
||||
@@ -73,6 +76,7 @@ export const AdminModelSchema = z.object({
|
||||
canonical_slug: z.string().nullable().optional(),
|
||||
alias_ids: z.array(z.string()).nullable().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
forwarded_model_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ProviderModelsSchema = z.object({
|
||||
@@ -883,6 +887,23 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
static async getTransactions(
|
||||
type?: string,
|
||||
status?: string,
|
||||
search?: string,
|
||||
limit: number = 100
|
||||
): Promise<TransactionsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.append('type', type);
|
||||
if (status) params.append('status', status);
|
||||
if (search) params.append('search', search);
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
return await apiClient.get<TransactionsResponse>(
|
||||
`/admin/api/transactions?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
static async createProviderAccountByType(providerType: string): Promise<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
@@ -909,9 +930,17 @@ export class AdminService {
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
|
||||
amount: amount,
|
||||
});
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, { amount });
|
||||
}
|
||||
|
||||
static async topupProviderWithToken(
|
||||
providerId: number,
|
||||
token: string
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
return await apiClient.post<{ ok: boolean; message?: string }>(
|
||||
`/admin/api/upstream-providers/${providerId}/topup-token`,
|
||||
{ token }
|
||||
);
|
||||
}
|
||||
|
||||
static async checkTopupStatus(
|
||||
@@ -1094,3 +1123,22 @@ export interface LogResponse {
|
||||
search: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
token: string;
|
||||
amount: number;
|
||||
unit: string;
|
||||
mint_url: string;
|
||||
type: 'in' | 'out';
|
||||
request_id?: string;
|
||||
created_at: number;
|
||||
collected: boolean;
|
||||
swept: boolean;
|
||||
source: 'x-cashu' | 'apikey';
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export class RoutstrProviderService {
|
||||
static async refundBalance(
|
||||
providerId: number
|
||||
): Promise<{ ok: boolean; message: string; refund_id?: string }> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
refund_id?: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/routstr/refund`, {});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^10.0.1",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
|
||||
Generated
+471
-415
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user