mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
193 Commits
fix-serial
...
rollback-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9dced8148 | ||
|
|
bf05c96dfc | ||
|
|
ed6e0c0189 | ||
|
|
412bfe479c | ||
|
|
861fda21b7 | ||
|
|
919dcf5535 | ||
|
|
28c4008892 | ||
|
|
a16bc1220c | ||
|
|
5d3e687876 | ||
|
|
68a7cd1dfb | ||
|
|
d96dc20e86 | ||
|
|
934afaa091 | ||
|
|
9ef01acac9 | ||
|
|
cbd38c15fe | ||
|
|
06c8a071a5 | ||
|
|
9e9c2bde57 | ||
|
|
222fd6ed45 | ||
|
|
4abd751f5f | ||
|
|
8f89171db1 | ||
|
|
5dc9d60bec | ||
|
|
feb76bc89d | ||
|
|
3b7f96560b | ||
|
|
23e206c93a | ||
|
|
3e0ca0daf9 | ||
|
|
d7bf1d6582 | ||
|
|
955090bd91 | ||
|
|
379c319e0d | ||
|
|
aaa80d47bc | ||
|
|
9633483fa4 | ||
|
|
9aa3408905 | ||
|
|
31ff1fd90c | ||
|
|
b9622689ea | ||
|
|
f9e8a3250d | ||
|
|
ade2d19be6 | ||
|
|
c4d0a1afba | ||
|
|
81817355cc | ||
|
|
d345d3b53f | ||
|
|
9fe13e9733 | ||
|
|
ee79a305ba | ||
|
|
ed2c8c9fe2 | ||
|
|
9161256d31 | ||
|
|
1a8f407142 | ||
|
|
eddc070628 | ||
|
|
f7bd250c97 | ||
|
|
f2ef63da62 | ||
|
|
29cfeaed9a | ||
|
|
e2b46dab9f | ||
|
|
632244e54f | ||
|
|
3251664513 | ||
|
|
ee16cd0495 | ||
|
|
f80d59182f | ||
|
|
70ef3c357c | ||
|
|
e4165f1dab | ||
|
|
41edae52b0 | ||
|
|
cb16da5543 | ||
|
|
d52b727bce | ||
|
|
6a2abd3439 | ||
|
|
1e4ed75179 | ||
|
|
efb5719679 | ||
|
|
3a1902b2b5 | ||
|
|
ba1978f830 | ||
|
|
2f51d62a46 | ||
|
|
1ca344ba7b | ||
|
|
dda9b848ba | ||
|
|
033d8c5a38 | ||
|
|
e73cdffdc5 | ||
|
|
e32b332eb5 | ||
|
|
89b84392a4 | ||
|
|
f37ff30598 | ||
|
|
547f45c185 | ||
|
|
a6e81a83cb | ||
|
|
e2143aa173 | ||
|
|
2eb257c2a6 | ||
|
|
490687bb71 | ||
|
|
966613847e | ||
|
|
d030d86f9a | ||
|
|
d9ab46b3bb | ||
|
|
6e596e3860 | ||
|
|
3c13be20cb | ||
|
|
9d5e14903b | ||
|
|
9e33d3b100 | ||
|
|
c88665f6d1 | ||
|
|
a8157b3e2d | ||
|
|
a07e6723d9 | ||
|
|
5bc7b741bc | ||
|
|
1db37cf084 | ||
|
|
6c5b103149 | ||
|
|
27ec052c17 | ||
|
|
00b813f6a8 | ||
|
|
91e5198a94 | ||
|
|
27f1cc3c42 | ||
|
|
a4d048f2b5 | ||
|
|
752d4f3803 | ||
|
|
164ed775c8 | ||
|
|
9d905758ec | ||
|
|
0d1cb66855 | ||
|
|
6e9932e0ac | ||
|
|
59773e972b | ||
|
|
7fd0cdf987 | ||
|
|
b1947d660a | ||
|
|
d45a9edcba | ||
|
|
4dbbb45240 | ||
|
|
a286b5efa0 | ||
|
|
26a0b04aef | ||
|
|
f7ccc25a7f | ||
|
|
8dd0ece501 | ||
|
|
10969e0719 | ||
|
|
b633455071 | ||
|
|
e280d1f8b1 | ||
|
|
81ad9f604b | ||
|
|
2345691176 | ||
|
|
a7615bc827 | ||
|
|
befdc5307e | ||
|
|
145777ffd0 | ||
|
|
37c2bea93d | ||
|
|
985e765285 | ||
|
|
a4b1330627 | ||
|
|
7b69284812 | ||
|
|
7fb1da7b58 | ||
|
|
38f9923469 | ||
|
|
5b986a3e15 | ||
|
|
56d9ff6b3f | ||
|
|
15b31e7c53 | ||
|
|
d0dcc3219d | ||
|
|
52db6fd308 | ||
|
|
ceca0e8efc | ||
|
|
309bc873e6 | ||
|
|
89109dc209 | ||
|
|
234ea19cad | ||
|
|
489b6eba14 | ||
|
|
66421cd17f | ||
|
|
cd7de3958c | ||
|
|
8c0ac499ef | ||
|
|
1d22155e05 | ||
|
|
ab2fb2afc5 | ||
|
|
884bae9fc4 | ||
|
|
92d581573d | ||
|
|
85a3d3adc0 | ||
|
|
2d7f03b2ed | ||
|
|
37bce70f76 | ||
|
|
038bc14757 | ||
|
|
287cbac5f9 | ||
|
|
28d91227af | ||
|
|
846d894a13 | ||
|
|
a2db3e2d57 | ||
|
|
e43ceb2e43 | ||
|
|
689a07f562 | ||
|
|
da487a850e | ||
|
|
fa6d3c76d0 | ||
|
|
ede1804d4b | ||
|
|
3392e8d4cb | ||
|
|
b5174d9753 | ||
|
|
1f2ff8a99c | ||
|
|
0c60644ba2 | ||
|
|
aca8d43a61 | ||
|
|
7fe4c1963b | ||
|
|
9a0919f149 | ||
|
|
d69ab913d4 | ||
|
|
42b8c332df | ||
|
|
aa682bf8ec | ||
|
|
c53e72e80a | ||
|
|
afd81aeca2 | ||
|
|
4e03145323 | ||
|
|
169686681f | ||
|
|
fa7d2804bb | ||
|
|
ccabc5d06d | ||
|
|
cb68227c88 | ||
|
|
c72fc7dd56 | ||
|
|
8c6d1f89dc | ||
|
|
1ab7e54bd7 | ||
|
|
42dceb0cd6 | ||
|
|
05115c3387 | ||
|
|
c0c8cafd00 | ||
|
|
16d6d66d17 | ||
|
|
3681fc8aab | ||
|
|
d95c09e00c | ||
|
|
3cdef5ec17 | ||
|
|
58e1620347 | ||
|
|
42b5a5e6c8 | ||
|
|
d84d249f2c | ||
|
|
8b6393f794 | ||
|
|
81146710ba | ||
|
|
25f39897d7 | ||
|
|
45bdfaee58 | ||
|
|
a1ae6e94e9 | ||
|
|
eebcc67c85 | ||
|
|
3f9e7f7728 | ||
|
|
a5b5549edd | ||
|
|
22eec0162c | ||
|
|
453337cb2c | ||
|
|
236854bfe4 | ||
|
|
a7886c528f | ||
|
|
a7b815b29f |
12
.github/workflows/container.yml
vendored
12
.github/workflows/container.yml
vendored
@@ -14,6 +14,14 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve git metadata
|
||||
id: gitmeta
|
||||
run: |
|
||||
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
@@ -29,7 +37,11 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.full
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ steps.gitmeta.outputs.sha }}
|
||||
GIT_TAG=${{ steps.gitmeta.outputs.tag }}
|
||||
tags: |
|
||||
ghcr.io/routstr/proxy:latest
|
||||
ghcr.io/routstr/core:latest
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,26 +1,29 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
# Install system dependencies required for secp256k1
|
||||
RUN apk add --no-cache \
|
||||
pkgconf \
|
||||
build-base \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4 \
|
||||
perl
|
||||
RUN apk add git
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libsecp256k1-dev \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY uv.lock pyproject.toml ./
|
||||
RUN mkdir -p /routstr
|
||||
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
# RUN uv sync
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG GIT_COMMIT=""
|
||||
ARG GIT_TAG=""
|
||||
ENV GIT_COMMIT=${GIT_COMMIT}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM node:23-alpine AS ui-builder
|
||||
WORKDIR /app/ui
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
# Copy UI source
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ./
|
||||
@@ -16,35 +16,38 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Build the Routstr Node
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
pkgconf \
|
||||
build-base \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4 \
|
||||
perl \
|
||||
git
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libsecp256k1-dev \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY uv.lock pyproject.toml ./
|
||||
|
||||
RUN uv sync --no-dev --no-install-project
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the rest of the application (required for uv sync to find the package)
|
||||
COPY . .
|
||||
|
||||
# Install dependencies including the specific secp256k1 branch
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
RUN uv sync --no-dev
|
||||
|
||||
# Copy the built UI from the ui-builder stage
|
||||
COPY --from=ui-builder /app/ui/out ./ui_out
|
||||
|
||||
ARG GIT_COMMIT=""
|
||||
ARG GIT_TAG=""
|
||||
ENV GIT_COMMIT=${GIT_COMMIT}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -8,8 +8,9 @@ services:
|
||||
args:
|
||||
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
|
||||
user: root
|
||||
volumes:
|
||||
- ./ui_out:/output
|
||||
- ./ui_out:/output:z
|
||||
command:
|
||||
["sh", "-c", "mkdir -p /output && cp -r /app/built/. /output/ && echo 'UI build copied to mounted volume' && ls -la /output/ && echo 'UI built and ready' && tail -f /dev/null"]
|
||||
|
||||
@@ -18,10 +19,10 @@ services:
|
||||
depends_on:
|
||||
- ui
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
- .:/app:z
|
||||
- ./logs:/app/logs:z
|
||||
- tor-data:/var/lib/tor:ro
|
||||
- ./ui_out:/app/ui_out:ro
|
||||
- ./ui_out:/app/ui_out:ro,z
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -396,6 +396,8 @@ Authorization: Bearer sk-...
|
||||
}
|
||||
```
|
||||
|
||||
`balance` is the spendable balance used by request admission.
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
|
||||
@@ -155,7 +155,7 @@ The response includes your change in the same header:
|
||||
X-Cashu: cashuA7k2mNp4...
|
||||
```
|
||||
|
||||
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
|
||||
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated. If you lose the `X-Cashu` response header before claiming your change, you can reclaim the refund via `POST /v1/wallet/refund` by supplying the original payment token in the `x-cashu` header.
|
||||
|
||||
## Response Headers
|
||||
|
||||
|
||||
@@ -53,9 +53,11 @@ If your balance runs low, you don't need a new key. You can top up the existing
|
||||
|
||||
### Via Lightning
|
||||
|
||||
`POST /lightning/invoice` with `{"amount_sats": 1000, "purpose": "topup", "api_key": "sk-..."}`.
|
||||
`POST /lightning/invoice` with `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`.
|
||||
*Once paid, the funds are added to your existing key.*
|
||||
|
||||
> Legacy: the endpoint is also exposed at `/v1/balance/lightning/invoice`, and accepts an `api_key` field in the body as a fallback for older clients. New integrations should use the RIP-08 path with the `Authorization` header.
|
||||
|
||||
### Via Cashu
|
||||
|
||||
`POST /v1/balance/topup` with `{"cashu_token": "..."}` and `Authorization: Bearer sk-...`.
|
||||
|
||||
@@ -78,9 +78,15 @@ Which mints to accept payments from:
|
||||
|
||||
Automatic profit withdrawal:
|
||||
|
||||
| Setting | Description |
|
||||
| --------------------- | ------------------------------- |
|
||||
| **Lightning Address** | Your LN address for withdrawals |
|
||||
| Setting | Description | Default |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| **Lightning Address** | Your LN address for withdrawals | — |
|
||||
| **Minimum Payout (sat)** | Min available balance (in sats) before profit is paid out. Applies to both `sat` and `msat` mints (auto-converted). | `210` |
|
||||
| **Payout Interval (seconds)** | How often the payout loop wakes up and checks balances | `900` |
|
||||
|
||||
All payout amounts must be positive. Set the minimums above your wallet's
|
||||
minimum-invoice constraint (typically 1 sat) and high enough to amortise
|
||||
routing fees.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -126,6 +132,8 @@ Use environment variables for:
|
||||
| `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 | — |
|
||||
| `MIN_PAYOUT_SAT` | Min payout balance in sats (applies to all mints) | `210` |
|
||||
| `PAYOUT_INTERVAL_SECONDS` | Payout loop interval (seconds) | `900` |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
|
||||
Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
## All-in-One Docker Image (Preferred)
|
||||
|
||||
The easiest way to deploy Routstr is using the all-in-one Docker image from Docker Hub, which includes both the FastAPI backend and the Next.js admin dashboard in a single container.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
-v routstr-data:/app/data \
|
||||
-e DATABASE_URL="sqlite:////app/data/routstr.db" \
|
||||
9qeklajc/routstr:latest
|
||||
```
|
||||
|
||||
Access your node:
|
||||
- **API & Admin Dashboard**: http://localhost:8000
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
image: 9qeklajc/routstr:latest
|
||||
container_name: routstr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- routstr-data:/app/data
|
||||
environment:
|
||||
DATABASE_URL: "sqlite:////app/data/routstr.db"
|
||||
ADMIN_KEY: "your-secure-admin-key"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
volumes:
|
||||
routstr-data:
|
||||
```
|
||||
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose (Recommended)
|
||||
|
||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||
|
||||
32
migrations/versions/02650cd6f028_add_routstr_fees_table.py
Normal file
32
migrations/versions/02650cd6f028_add_routstr_fees_table.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""add routstr_fees table
|
||||
|
||||
Revision ID: 02650cd6f028
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "02650cd6f028"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"routstr_fees",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("accumulated_msats", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("total_paid_msats", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# Seed with a single row
|
||||
op.execute("INSERT INTO routstr_fees (id, accumulated_msats, total_paid_msats) VALUES (1, 0, 0)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("routstr_fees")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Add balance_limit, balance_limit_reset, validity_date to lightning_invoices
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2026-06-03 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a2b3c4d5e6f7"
|
||||
down_revision = "f1a2b3c4d5e6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("lightning_invoices", sa.Column("balance_limit", sa.Integer(), nullable=True))
|
||||
op.add_column("lightning_invoices", sa.Column("balance_limit_reset", sa.String(), nullable=True))
|
||||
op.add_column("lightning_invoices", sa.Column("validity_date", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("lightning_invoices", "validity_date")
|
||||
op.drop_column("lightning_invoices", "balance_limit_reset")
|
||||
op.drop_column("lightning_invoices", "balance_limit")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add reserved_at to api_keys
|
||||
|
||||
Revision ID: b5e7c9d1f3a2
|
||||
Revises: a2b3c4d5e6f7
|
||||
Create Date: 2026-06-12 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b5e7c9d1f3a2"
|
||||
down_revision = "a2b3c4d5e6f7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# existing keys keep NULL
|
||||
# New reservations populate it via pay_for_request.
|
||||
op.add_column("api_keys", sa.Column("reserved_at", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("api_keys", "reserved_at")
|
||||
34
migrations/versions/cli_tokens_table.py
Normal file
34
migrations/versions/cli_tokens_table.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""add cli_tokens table
|
||||
|
||||
Revision ID: cli_tokens_001
|
||||
Revises: e8f9a0b1c2d3
|
||||
Create Date: 2026-04-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "cli_tokens_001"
|
||||
down_revision = "e8f9a0b1c2d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cli_tokens",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("token", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("last_used_at", sa.Integer(), nullable=True),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_cli_tokens_token", "cli_tokens", ["token"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cli_tokens_token", table_name="cli_tokens")
|
||||
op.drop_table("cli_tokens")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add api key link to cashu_transactions
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-04-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d4e5f6a7b8c9"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
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")]
|
||||
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
|
||||
|
||||
if "api_key_hashed_key" not in columns:
|
||||
op.add_column(
|
||||
"cashu_transactions",
|
||||
sa.Column(
|
||||
"api_key_hashed_key",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
|
||||
op.create_index(
|
||||
"ix_cashu_transactions_api_key_hashed_key",
|
||||
"cashu_transactions",
|
||||
["api_key_hashed_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
|
||||
op.drop_column("cashu_transactions", "api_key_hashed_key")
|
||||
20
migrations/versions/e8f9a0b1c2d3_merge_heads.py
Normal file
20
migrations/versions/e8f9a0b1c2d3_merge_heads.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""merge heads: routstr_fees + api_key_to_cashu_transactions
|
||||
|
||||
Revision ID: e8f9a0b1c2d3
|
||||
Revises: 02650cd6f028, d4e5f6a7b8c9
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e8f9a0b1c2d3"
|
||||
down_revision = ("02650cd6f028", "d4e5f6a7b8c9")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add created_at to api_keys
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: cli_tokens_001
|
||||
Create Date: 2026-06-01 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f1a2b3c4d5e6"
|
||||
down_revision = "cli_tokens_001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Nullable on purpose: existing keys keep NULL (unknown creation time) and
|
||||
# sort last; new keys get populated by the model's default_factory.
|
||||
op.add_column("api_keys", sa.Column("created_at", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("api_keys", "created_at")
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.4.1"
|
||||
version = "0.4.3"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -13,14 +13,14 @@ dependencies = [
|
||||
"greenlet>=3.2.1",
|
||||
"alembic>=1.13",
|
||||
"python-json-logger>=2.0.0",
|
||||
"cashu",
|
||||
"secp256k1",
|
||||
"cashu>=0.20",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
"websockets>=12.0",
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
"litellm>=1.55.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -86,4 +86,3 @@ disallow_untyped_decorators = true
|
||||
|
||||
[tool.uv.sources]
|
||||
routstr = { workspace = true }
|
||||
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
|
||||
|
||||
@@ -195,14 +195,15 @@ def create_model_mappings(
|
||||
|
||||
# Add to unique models
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_key = model_to_use.forwarded_model_id or base_id
|
||||
if not is_openrouter or unique_key not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
unique_models[unique_key] = unique_model
|
||||
|
||||
# Get all aliases for this model
|
||||
aliases = resolve_model_alias(
|
||||
@@ -272,18 +273,19 @@ def create_model_mappings(
|
||||
continue
|
||||
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
unique_key = model_to_use.forwarded_model_id or base_id
|
||||
is_openrouter = (
|
||||
getattr(upstream_for_override, "base_url", "")
|
||||
== "https://openrouter.ai/api/v1"
|
||||
)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
if not is_openrouter or unique_key not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream_for_override.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
unique_models[unique_key] = unique_model
|
||||
|
||||
try:
|
||||
aliases = resolve_model_alias(
|
||||
@@ -322,7 +324,26 @@ def create_model_mappings(
|
||||
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
|
||||
def alias_priority(model: "Model", alias: str) -> int:
|
||||
"""Rank how strong the mapping of alias->model is."""
|
||||
"""Rank how strong the mapping of alias->model is.
|
||||
|
||||
forwarded_model_id is the most specific identifier (set per-provider
|
||||
instance), so a match there should beat a model_id match. This way,
|
||||
when multiple providers have the same model_id but different
|
||||
forwarded_model_ids, the one whose forwarded_model_id equals the
|
||||
requested alias wins.
|
||||
"""
|
||||
if (
|
||||
model.forwarded_model_id
|
||||
and model.forwarded_model_id.lower() == alias
|
||||
):
|
||||
return 5
|
||||
|
||||
if (
|
||||
model.id
|
||||
and model.id.lower() == alias
|
||||
):
|
||||
return 4
|
||||
|
||||
model_base = get_base_model_id(model.id)
|
||||
if model_base == alias:
|
||||
return 3
|
||||
|
||||
117
routstr/auth.py
117
routstr/auth.py
@@ -12,7 +12,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import ApiKey, AsyncSession
|
||||
from .core.db import ApiKey, AsyncSession, accumulate_routstr_fee
|
||||
from .core.settings import settings
|
||||
from .payment.cost_calculation import (
|
||||
CostData,
|
||||
@@ -25,6 +25,12 @@ from .wallet import credit_balance, deserialize_token_from_string
|
||||
logger = get_logger(__name__)
|
||||
payments_logger = get_logger("routstr.payments")
|
||||
|
||||
# Routstr platform fee constants
|
||||
ROUTSTR_FEE_PERCENT: float = 2.1
|
||||
ROUTSTR_LN_ADDRESS: str = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s@npub.cash"
|
||||
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS: int = 900
|
||||
ROUTSTR_FEE_DEFAULT_PAYOUT: int = 200
|
||||
|
||||
# TODO: implement prepaid api key (not like it was before)
|
||||
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
||||
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
|
||||
@@ -311,13 +317,13 @@ async def validate_bearer_key(
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"AUTH: About to call credit_balance",
|
||||
extra={"token_preview": bearer_key[:50]},
|
||||
)
|
||||
try:
|
||||
msats = await credit_balance(bearer_key, new_key, session)
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"AUTH: credit_balance returned successfully", extra={"msats": msats}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
@@ -335,6 +341,11 @@ async def validate_bearer_key(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
# Defense-in-depth: credit_balance now refuses to commit on a
|
||||
# zero/negative redemption, but if a row was nonetheless
|
||||
# persisted, drop it so we never leave an orphan zero-balance key.
|
||||
await session.delete(new_key)
|
||||
await session.commit()
|
||||
raise Exception("Token redemption failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
@@ -353,6 +364,7 @@ async def validate_bearer_key(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
@@ -523,31 +535,19 @@ async def pay_for_request(
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
reserved_at_now = int(time.time())
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
reserved_at=reserved_at_now,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also increment total_requests 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)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
@@ -559,7 +559,6 @@ async def pay_for_request(
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
@@ -571,6 +570,44 @@ async def pay_for_request(
|
||||
},
|
||||
)
|
||||
|
||||
# Also increment total_requests and reserved_balance on the child key if it's different.
|
||||
# The balance_limit guard is enforced atomically here — the Python pre-check above
|
||||
# is a fast-path rejection only and provides no concurrency guarantee.
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(
|
||||
(col(ApiKey.balance_limit).is_(None))
|
||||
| (
|
||||
col(ApiKey.total_spent)
|
||||
+ col(ApiKey.reserved_balance)
|
||||
+ cost_per_request
|
||||
<= col(ApiKey.balance_limit)
|
||||
)
|
||||
)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
reserved_at=reserved_at_now,
|
||||
)
|
||||
)
|
||||
child_result = await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
if child_result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "balance_limit_exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
@@ -609,12 +646,19 @@ async def revert_pay_for_request(
|
||||
False if the reservation was already released (prevents negative reserved_balance)."""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
# Keep reserved_at while other reservations remain
|
||||
cleared_reserved_at = case(
|
||||
(col(ApiKey.reserved_balance) - cost_per_request > 0, col(ApiKey.reserved_at)),
|
||||
else_=None,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
reserved_at=cleared_reserved_at,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
@@ -630,6 +674,7 @@ async def revert_pay_for_request(
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
reserved_at=cleared_reserved_at,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
@@ -741,6 +786,17 @@ async def adjust_payment_for_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
async def _accumulate_fee(total_cost_msats: int) -> None:
|
||||
if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
|
||||
fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
|
||||
try:
|
||||
await accumulate_routstr_fee(session, fee_msats)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to accumulate Routstr fee",
|
||||
extra={"error": str(e), "fee_msats": fee_msats},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
@@ -832,6 +888,7 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await _accumulate_fee(cost.total_msats)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
@@ -935,6 +992,7 @@ async def adjust_payment_for_tokens(
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
await _accumulate_fee(total_cost_msats)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
@@ -1005,6 +1063,7 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await _accumulate_fee(total_cost_msats)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
@@ -1130,6 +1189,7 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await _accumulate_fee(total_cost_msats)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
@@ -1237,3 +1297,24 @@ async def periodic_key_reset() -> None:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in periodic_key_reset: {e}")
|
||||
|
||||
|
||||
STALE_RESERVATION_SWEEP_INTERVAL_SECONDS: int = 60
|
||||
|
||||
|
||||
async def periodic_stale_reservation_sweep() -> None:
|
||||
"""Background task that releases reservations leaked by client disconnects,
|
||||
crashes or abandoned streams.
|
||||
"""
|
||||
from .core.db import create_session, release_stale_reservations
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
await release_stale_reservations(
|
||||
session, settings.stale_reservation_timeout_seconds
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error in periodic_stale_reservation_sweep")
|
||||
|
||||
await asyncio.sleep(STALE_RESERVATION_SWEEP_INTERVAL_SECONDS)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 sqlmodel import col, or_, select, update
|
||||
|
||||
from .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import (
|
||||
@@ -45,10 +45,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
info = {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": billing_key.balance,
|
||||
"balance": billing_key.total_balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
"is_child": key.parent_key_hash is not None,
|
||||
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||
"total_requests": key.total_requests,
|
||||
"total_spent": key.total_spent,
|
||||
"balance_limit": key.balance_limit,
|
||||
@@ -56,7 +55,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
"validity_date": key.validity_date,
|
||||
}
|
||||
|
||||
if not key.parent_key_hash:
|
||||
if key.parent_key_hash:
|
||||
info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
|
||||
else:
|
||||
# Fetch child keys if this is a parent key
|
||||
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
|
||||
results = await session.exec(statement)
|
||||
@@ -211,6 +212,38 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
|
||||
_refund_cache[key] = (expiry, value)
|
||||
|
||||
|
||||
async def _lookup_key_no_create(
|
||||
bearer_value: str, session: AsyncSession
|
||||
) -> ApiKey | None:
|
||||
"""Look up an existing API key without creating one Used by the refund endpoint"""
|
||||
if bearer_value.startswith("sk-"):
|
||||
return await session.get(ApiKey, bearer_value[3:])
|
||||
if bearer_value.startswith("cashu"):
|
||||
hashed = hashlib.sha256(bearer_value.encode()).hexdigest()
|
||||
return await session.get(ApiKey, hashed)
|
||||
return None
|
||||
|
||||
|
||||
async def _restore_balance(
|
||||
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str
|
||||
) -> None:
|
||||
"""Restore balance after a failed refund mint attempt."""
|
||||
restore_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + balance,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + reserved_balance,
|
||||
)
|
||||
)
|
||||
await session.exec(restore_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: balance restored after mint failure",
|
||||
extra={"hashed_key": hashed_key, "restored_balance": balance, "mint_url": mint_url},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refund", response_model=None)
|
||||
async def refund_wallet_endpoint(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
@@ -262,7 +295,12 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
|
||||
bearer_value: str = authorization[7:]
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
key: ApiKey | None = await _lookup_key_no_create(bearer_value, session)
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Key not found. Deposit first via /v1/wallet/create before requesting a refund.",
|
||||
)
|
||||
|
||||
if key.total_balance <= 0:
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
@@ -275,9 +313,36 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
|
||||
if key.reserved_balance > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
# Release the reservation if it is stale
|
||||
cutoff = int(time.time()) - settings.stale_reservation_timeout_seconds
|
||||
stale_release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) > 0)
|
||||
.where(
|
||||
or_(
|
||||
col(ApiKey.reserved_at).is_(None),
|
||||
col(ApiKey.reserved_at) < cutoff,
|
||||
)
|
||||
)
|
||||
.values(reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
stale_result = await session.exec(stale_release_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if stale_result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
logger.warning(
|
||||
"refund_wallet_endpoint: released stale reservation before refund",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"stale_timeout_seconds": settings.stale_reservation_timeout_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
@@ -292,22 +357,51 @@ async def refund_wallet_endpoint(
|
||||
elif remaining_balance <= 0:
|
||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||
|
||||
# Perform refund operation first, before modifying balance
|
||||
# Capture values before debit — the session may refresh key after commit
|
||||
pre_debit_balance = key.balance
|
||||
pre_debit_reserved = key.reserved_balance
|
||||
|
||||
# --- DEBIT FIRST: atomically zero the balance before minting tokens ---
|
||||
# This prevents the race where a concurrent topup/spend happens between
|
||||
# reading the balance and minting the refund token (double-spend).
|
||||
debit_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) == pre_debit_balance)
|
||||
.where(col(ApiKey.reserved_balance) == pre_debit_reserved)
|
||||
.values(balance=0, reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if debit_result.rowcount == 0:
|
||||
# Balance changed between read and debit — another request is active
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Balance changed concurrently. Please retry the refund.",
|
||||
)
|
||||
|
||||
# --- MINT: balance is locked at zero, safe to create the refund token ---
|
||||
# Proofs from untrusted mints are swapped to primary_mint on receive.
|
||||
# Use primary_mint unless key.refund_mint_url is an explicitly trusted mint.
|
||||
effective_refund_mint = (
|
||||
key.refund_mint_url
|
||||
if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints
|
||||
else settings.primary_mint
|
||||
)
|
||||
try:
|
||||
if key.refund_address:
|
||||
from .core.settings import settings as global_settings
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
effective_refund_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
remaining_balance, refund_currency, effective_refund_mint
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
@@ -328,29 +422,36 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
# Minting failed — restore the debited balance
|
||||
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
|
||||
raise
|
||||
except Exception as e:
|
||||
# If refund fails, don't modify the database
|
||||
# Minting failed — restore the debited balance
|
||||
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
|
||||
error_msg = str(e)
|
||||
logger.error(
|
||||
"refund_wallet_endpoint: mint/send failed",
|
||||
extra={
|
||||
"error": error_msg,
|
||||
"error_type": type(e).__name__,
|
||||
"hashed_key": key.hashed_key,
|
||||
"remaining_balance": remaining_balance,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
"has_refund_address": bool(key.refund_address),
|
||||
},
|
||||
)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
or "connection" in error_msg.lower()
|
||||
or isinstance(e, Exception)
|
||||
and "ConnectError" in str(type(e))
|
||||
or "ConnectError" in str(type(e))
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
|
||||
|
||||
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(
|
||||
@@ -361,6 +462,7 @@ async def refund_wallet_endpoint(
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
except Exception:
|
||||
pass # store_cashu_transaction already logs
|
||||
@@ -369,13 +471,48 @@ async def refund_wallet_endpoint(
|
||||
"refund_wallet_endpoint: refund successful",
|
||||
extra={
|
||||
"refunded_msats": remaining_balance_msats,
|
||||
"previous_reserved_balance": previous_reserved_balance,
|
||||
"previous_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def wallet_history(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, list[dict[str, str | int | bool | None]]]:
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot view child key history. Please use the parent key instead.",
|
||||
)
|
||||
|
||||
result = await session.exec(
|
||||
select(CashuTransaction)
|
||||
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
|
||||
.order_by(col(CashuTransaction.created_at).desc())
|
||||
)
|
||||
transactions = result.all()
|
||||
return {
|
||||
"transactions": [
|
||||
{
|
||||
"id": tx.id,
|
||||
"type": tx.type,
|
||||
"source": tx.source,
|
||||
"amount": tx.amount,
|
||||
"unit": tx.unit,
|
||||
"mint_url": tx.mint_url,
|
||||
"created_at": tx.created_at,
|
||||
"collected": tx.collected,
|
||||
"swept": tx.swept,
|
||||
}
|
||||
for tx in transactions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/donate")
|
||||
async def donate(token: str, ref: str | None = None) -> str:
|
||||
try:
|
||||
@@ -426,10 +563,24 @@ async def create_child_key(
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Deduct cost from parent
|
||||
key.balance -= total_cost
|
||||
key.total_spent += total_cost
|
||||
session.add(key)
|
||||
# Deduct cost from parent atomically — guards against concurrent requests
|
||||
# that both pass the balance check above on stale in-memory state.
|
||||
deduct_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - total_cost,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost,
|
||||
)
|
||||
)
|
||||
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Generate new keys
|
||||
import secrets
|
||||
@@ -454,6 +605,7 @@ async def create_child_key(
|
||||
new_keys.append("sk-" + new_key_hash)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
response_data = {
|
||||
"api_keys": new_keys,
|
||||
@@ -500,26 +652,6 @@ 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}",
|
||||
@@ -533,7 +665,7 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router)
|
||||
balance_router.include_router(lightning_router, include_in_schema=False)
|
||||
balance_router.include_router(router)
|
||||
|
||||
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
|
||||
|
||||
@@ -5,7 +5,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, RootModel
|
||||
from pydantic.v1 import ValidationError as PydanticValidationError
|
||||
from sqlmodel import select
|
||||
|
||||
from ..payment.models import _row_to_model, list_models
|
||||
@@ -20,6 +21,8 @@ from ..wallet import (
|
||||
from .db import (
|
||||
ApiKey,
|
||||
CashuTransaction,
|
||||
CliToken,
|
||||
LightningInvoice,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
@@ -38,38 +41,116 @@ ADMIN_SESSION_DURATION = 3600
|
||||
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
|
||||
|
||||
|
||||
def require_admin_api(request: Request) -> None:
|
||||
async def require_admin_api(request: Request) -> None:
|
||||
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()):
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
now_ts = int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
# 1) Short-lived session token (in-memory)
|
||||
expiry = admin_sessions.get(token)
|
||||
if expiry and expiry > now_ts:
|
||||
return
|
||||
|
||||
# 2) Long-lived CLI token (DB-backed)
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(CliToken).where(CliToken.token == token))
|
||||
cli_token = result.first()
|
||||
if cli_token and (cli_token.expires_at is None or cli_token.expires_at > now_ts):
|
||||
cli_token.last_used_at = now_ts
|
||||
session.add(cli_token)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
|
||||
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
||||
async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]:
|
||||
async def get_temporary_balances_api(
|
||||
request: Request,
|
||||
search: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, object]:
|
||||
from sqlalchemy import case
|
||||
from sqlmodel import col, func
|
||||
|
||||
filters = []
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
filters.append(
|
||||
col(ApiKey.hashed_key).like(pattern)
|
||||
| col(ApiKey.refund_address).like(pattern)
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ApiKey))
|
||||
base = select(ApiKey).where(*filters)
|
||||
|
||||
count_result = await session.exec(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
)
|
||||
total = count_result.one()
|
||||
|
||||
# Aggregate totals across the whole (search-filtered) set, not just the
|
||||
# current page. Balance counts only parent (non-child) keys to avoid
|
||||
# double-counting, since child keys draw from their parent's balance.
|
||||
totals_result = await session.exec(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(col(ApiKey.parent_key_hash).is_(None), ApiKey.balance),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(func.sum(ApiKey.total_spent), 0),
|
||||
func.coalesce(func.sum(ApiKey.total_requests), 0),
|
||||
).where(*filters)
|
||||
)
|
||||
total_balance, total_spent, total_requests = totals_result.one()
|
||||
|
||||
# Latest created first; keys with no created_at (legacy rows) sort last.
|
||||
# Use an explicit CASE rather than relying on dialect NULL-ordering so
|
||||
# the behaviour is identical on SQLite and Postgres.
|
||||
stmt = (
|
||||
base.order_by(
|
||||
case((col(ApiKey.created_at).is_(None), 1), else_=0),
|
||||
col(ApiKey.created_at).desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
api_keys = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
"parent_key_hash": key.parent_key_hash,
|
||||
"balance_limit": key.balance_limit,
|
||||
"balance_limit_reset": key.balance_limit_reset,
|
||||
"validity_date": key.validity_date,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
return {
|
||||
"balances": [
|
||||
{
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
"parent_key_hash": key.parent_key_hash,
|
||||
"balance_limit": key.balance_limit,
|
||||
"balance_limit_reset": key.balance_limit_reset,
|
||||
"validity_date": key.validity_date,
|
||||
"created_at": key.created_at,
|
||||
}
|
||||
for key in api_keys
|
||||
],
|
||||
"total": total,
|
||||
"totals": {
|
||||
"total_balance": total_balance,
|
||||
"total_spent": total_spent,
|
||||
"total_requests": total_requests,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyUpdate(BaseModel):
|
||||
@@ -126,8 +207,8 @@ async def get_settings(request: Request) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
__root__: dict[str, object]
|
||||
class SettingsUpdate(RootModel[dict[str, object]]):
|
||||
pass
|
||||
|
||||
|
||||
class PasswordUpdate(BaseModel):
|
||||
@@ -138,14 +219,19 @@ class PasswordUpdate(BaseModel):
|
||||
@admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)])
|
||||
async def update_settings(request: Request, update: SettingsUpdate) -> dict:
|
||||
# Remove sensitive fields from general settings update
|
||||
settings_data = update.__root__.copy()
|
||||
settings_data = update.root.copy()
|
||||
sensitive_fields = ["admin_password", "upstream_api_key", "nsec"]
|
||||
for field in sensitive_fields:
|
||||
if field in settings_data:
|
||||
del settings_data[field]
|
||||
|
||||
async with create_session() as session:
|
||||
new_settings = await SettingsService.update(settings_data, session)
|
||||
try:
|
||||
async with create_session() as session:
|
||||
new_settings = await SettingsService.update(settings_data, session)
|
||||
except PydanticValidationError as e:
|
||||
# Surface validation issues (e.g. non-positive payout amounts)
|
||||
# as a clean 400 instead of a 500.
|
||||
raise HTTPException(status_code=400, detail=e.errors()) from e
|
||||
data = new_settings.dict()
|
||||
if "upstream_api_key" in data:
|
||||
data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else ""
|
||||
@@ -242,6 +328,73 @@ async def admin_logout(request: Request) -> dict[str, object]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ─── CLI Tokens (long-lived bearer tokens for CLI/agent use) ───
|
||||
|
||||
|
||||
class CliTokenCreate(BaseModel):
|
||||
name: str
|
||||
expires_in_days: int | None = None
|
||||
|
||||
|
||||
@admin_router.get("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
|
||||
async def list_cli_tokens() -> list[dict[str, object]]:
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(CliToken))
|
||||
tokens = result.all()
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"token_preview": f"{t.token[:8]}...{t.token[-4:]}",
|
||||
"created_at": t.created_at,
|
||||
"last_used_at": t.last_used_at,
|
||||
"expires_at": t.expires_at,
|
||||
}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@admin_router.post("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
|
||||
async def create_cli_token(payload: CliTokenCreate) -> dict[str, object]:
|
||||
name = (payload.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
expires_at: int | None = None
|
||||
if payload.expires_in_days is not None and payload.expires_in_days > 0:
|
||||
expires_at = int(datetime.now(timezone.utc).timestamp()) + (
|
||||
payload.expires_in_days * 86400
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
cli_token = CliToken(token=raw_token, name=name, expires_at=expires_at)
|
||||
session.add(cli_token)
|
||||
await session.commit()
|
||||
await session.refresh(cli_token)
|
||||
|
||||
return {
|
||||
"id": cli_token.id,
|
||||
"name": cli_token.name,
|
||||
"token": raw_token, # full token returned only on creation
|
||||
"created_at": cli_token.created_at,
|
||||
"expires_at": cli_token.expires_at,
|
||||
}
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/cli-tokens/{token_id}", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def revoke_cli_token(token_id: str) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
cli_token = await session.get(CliToken, token_id)
|
||||
if not cli_token:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
await session.delete(cli_token)
|
||||
await session.commit()
|
||||
return {"ok": True, "deleted_id": token_id}
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
amount: int
|
||||
mint_url: str | None = None
|
||||
@@ -1335,41 +1488,102 @@ async def get_transactions_api(
|
||||
type: str | None = None,
|
||||
status: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 100,
|
||||
source: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
async with create_session() as session:
|
||||
from sqlmodel import col
|
||||
from sqlmodel import col, func
|
||||
|
||||
stmt = select(CashuTransaction)
|
||||
base = select(CashuTransaction)
|
||||
if type:
|
||||
stmt = stmt.where(CashuTransaction.type == type)
|
||||
base = base.where(CashuTransaction.type == type)
|
||||
if source:
|
||||
if source == "x-cashu":
|
||||
base = base.where(
|
||||
(CashuTransaction.source == "x-cashu")
|
||||
| (CashuTransaction.source == None) # noqa: E711
|
||||
)
|
||||
else:
|
||||
base = base.where(CashuTransaction.source == source)
|
||||
if status:
|
||||
if status == "collected":
|
||||
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
|
||||
base = base.where(CashuTransaction.collected == True) # noqa: E712
|
||||
elif status == "swept":
|
||||
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
|
||||
base = base.where(CashuTransaction.swept == True) # noqa: E712
|
||||
elif status == "pending":
|
||||
stmt = stmt.where(
|
||||
base = base.where(
|
||||
CashuTransaction.collected == False, # noqa: E712
|
||||
CashuTransaction.swept == False, # noqa: E712
|
||||
)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
stmt = stmt.where(
|
||||
base = base.where(
|
||||
(col(CashuTransaction.id).like(search_pattern))
|
||||
| (col(CashuTransaction.token).like(search_pattern))
|
||||
| (col(CashuTransaction.request_id).like(search_pattern))
|
||||
| (col(CashuTransaction.api_key_hashed_key).like(search_pattern))
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
|
||||
count_result = await session.exec(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
)
|
||||
total = count_result.one()
|
||||
|
||||
stmt = base.order_by(col(CashuTransaction.created_at).desc()).offset(offset).limit(limit)
|
||||
results = await session.exec(stmt)
|
||||
transactions = results.all()
|
||||
|
||||
return {
|
||||
"transactions": [tx.dict() for tx in transactions],
|
||||
"total": len(transactions),
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/lightning-invoices", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def get_lightning_invoices_api(
|
||||
status: str | None = None,
|
||||
purpose: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
async with create_session() as session:
|
||||
from sqlmodel import col, func
|
||||
|
||||
base = select(LightningInvoice)
|
||||
if status:
|
||||
base = base.where(LightningInvoice.status == status)
|
||||
if purpose:
|
||||
base = base.where(LightningInvoice.purpose == purpose)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base = base.where(
|
||||
(col(LightningInvoice.id).like(pattern))
|
||||
| (col(LightningInvoice.bolt11).like(pattern))
|
||||
| (col(LightningInvoice.payment_hash).like(pattern))
|
||||
| (col(LightningInvoice.api_key_hash).like(pattern))
|
||||
)
|
||||
|
||||
count_result = await session.exec(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
)
|
||||
total = count_result.one()
|
||||
|
||||
stmt = (
|
||||
base.order_by(col(LightningInvoice.created_at).desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
results = await session.exec(stmt)
|
||||
invoices = results.all()
|
||||
|
||||
return {
|
||||
"invoices": [inv.dict() for inv in invoices],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 import Field, Relationship, SQLModel, col, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -33,6 +33,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
reserved_balance: int = Field(
|
||||
default=0, description="Reserved balance in millisatoshis (msats)"
|
||||
)
|
||||
reserved_at: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Unix timestamp of the most recent balance reservation. Used to "
|
||||
"detect and release stale reservations (e.g. after client "
|
||||
"disconnects). NULL when no reservation has been made yet."
|
||||
),
|
||||
)
|
||||
refund_address: str | None = Field(
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
@@ -45,6 +53,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=0, description="Total spent in millisatoshis (msats)"
|
||||
)
|
||||
total_requests: int = Field(default=0)
|
||||
created_at: int | None = Field(
|
||||
default_factory=lambda: int(time.time()),
|
||||
nullable=True,
|
||||
description=(
|
||||
"Unix timestamp when the key was created. Nullable: keys created "
|
||||
"before this column existed have no value and sort last."
|
||||
),
|
||||
)
|
||||
refund_mint_url: str | None = Field(
|
||||
default=None,
|
||||
description="URL of the mint used to create the cashu-token",
|
||||
@@ -79,11 +95,34 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
logger.info("Resetting all reserved balances to 0")
|
||||
stmt = update(ApiKey).values(reserved_balance=0)
|
||||
stmt = update(ApiKey).values(reserved_balance=0, reserved_at=None)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info("Reserved balances reset successfully")
|
||||
logger.info("Reset reserved balances on startup")
|
||||
|
||||
|
||||
async def release_stale_reservations(
|
||||
session: AsyncSession, max_age_seconds: int
|
||||
) -> int:
|
||||
"""Release reservations whose last reserve is older than max_age_seconds.
|
||||
"""
|
||||
cutoff = int(time.time()) - max_age_seconds
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.reserved_balance) > 0)
|
||||
.where(col(ApiKey.reserved_at).is_not(None))
|
||||
.where(col(ApiKey.reserved_at) < cutoff)
|
||||
.values(reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
released = int(result.rowcount or 0)
|
||||
if released:
|
||||
logger.warning(
|
||||
"Released stale balance reservations",
|
||||
extra={"released_keys": released, "max_age_seconds": max_age_seconds},
|
||||
)
|
||||
return released
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
@@ -133,6 +172,18 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
)
|
||||
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
balance_limit: int | None = Field(
|
||||
default=None,
|
||||
description="Max spendable msats for the created key",
|
||||
)
|
||||
balance_limit_reset: str | None = Field(
|
||||
default=None,
|
||||
description="Reset policy for balance limit (daily, weekly, monthly)",
|
||||
)
|
||||
validity_date: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp after which the created key expires",
|
||||
)
|
||||
|
||||
|
||||
class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||
@@ -159,6 +210,12 @@ class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||
default="x-cashu",
|
||||
description="Payment source: x-cashu or apikey",
|
||||
)
|
||||
api_key_hashed_key: str | None = Field(
|
||||
default=None,
|
||||
foreign_key="api_keys.hashed_key",
|
||||
index=True,
|
||||
description="Associated API key hash for wallet history",
|
||||
)
|
||||
|
||||
|
||||
async def store_cashu_transaction(
|
||||
@@ -171,6 +228,7 @@ async def store_cashu_transaction(
|
||||
collected: bool = False,
|
||||
created_at: int | None = None,
|
||||
source: str = "x-cashu",
|
||||
api_key_hashed_key: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
@@ -184,6 +242,7 @@ async def store_cashu_transaction(
|
||||
collected=collected,
|
||||
created_at=created_at or int(time.time()),
|
||||
source=source,
|
||||
api_key_hashed_key=api_key_hashed_key,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
@@ -223,6 +282,66 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
)
|
||||
|
||||
|
||||
class RoutstrFee(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "routstr_fees"
|
||||
id: int = Field(default=1, primary_key=True)
|
||||
accumulated_msats: int = Field(default=0)
|
||||
total_paid_msats: int = Field(default=0)
|
||||
last_paid_at: int | None = Field(default=None)
|
||||
|
||||
|
||||
class CliToken(SQLModel, table=True): # type: ignore
|
||||
"""Long-lived authorization token for CLI/agent use against admin endpoints."""
|
||||
|
||||
__tablename__ = "cli_tokens"
|
||||
id: str = Field(
|
||||
primary_key=True, default_factory=lambda: uuid.uuid4().hex
|
||||
)
|
||||
token: str = Field(unique=True, index=True, description="Bearer token value")
|
||||
name: str = Field(description="Human-readable label for this token")
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
last_used_at: int | None = Field(default=None)
|
||||
expires_at: int | None = Field(
|
||||
default=None, description="Optional expiry unix timestamp; null = never expires"
|
||||
)
|
||||
|
||||
|
||||
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
|
||||
stmt = (
|
||||
update(RoutstrFee)
|
||||
.where(col(RoutstrFee.id) == 1)
|
||||
.values(accumulated_msats=RoutstrFee.accumulated_msats + amount_msats)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
if result.rowcount == 0:
|
||||
session.add(RoutstrFee(id=1, accumulated_msats=amount_msats))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
|
||||
fee = await session.get(RoutstrFee, 1)
|
||||
if fee is None:
|
||||
fee = RoutstrFee(id=1, accumulated_msats=0, total_paid_msats=0)
|
||||
session.add(fee)
|
||||
await session.commit()
|
||||
await session.refresh(fee)
|
||||
return fee
|
||||
|
||||
|
||||
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> None:
|
||||
stmt = (
|
||||
update(RoutstrFee)
|
||||
.where(col(RoutstrFee.id) == 1)
|
||||
.values(
|
||||
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
|
||||
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
|
||||
last_paid_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
) -> int:
|
||||
|
||||
@@ -22,16 +22,20 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
|
||||
# Get status code and detail - works for both FastAPI and Starlette HTTPException
|
||||
status_code = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
path = request.url.path
|
||||
|
||||
logger.warning(
|
||||
"HTTP exception",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"status_code": status_code,
|
||||
"detail": detail,
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
# 4xx is client behaviour; the uvicorn access log already records it.
|
||||
# Only 5xx warrants a server-side warning/error log here.
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
f"HTTP {status_code} on {path}: {detail}",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"status_code": status_code,
|
||||
"detail": detail,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
|
||||
@@ -41,14 +41,23 @@ import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pythonjsonlogger import jsonlogger
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts
|
||||
# (docker logs, pipes, CI) Rich pads every line to width and wraps long
|
||||
# records, producing visually-empty trailing whitespace and split records.
|
||||
# A plain StreamHandler avoids both problems.
|
||||
_stdout_is_tty = sys.stdout.isatty()
|
||||
_console = Console(soft_wrap=True) if _stdout_is_tty else None
|
||||
|
||||
# Define custom TRACE level
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, "TRACE")
|
||||
@@ -261,6 +270,26 @@ def setup_logging() -> None:
|
||||
if console_enabled:
|
||||
handlers.append("console")
|
||||
|
||||
if _stdout_is_tty:
|
||||
console_handler: dict[str, Any] = {
|
||||
"()": RichHandler,
|
||||
"level": log_level,
|
||||
"show_time": False,
|
||||
"show_path": False,
|
||||
"rich_tracebacks": True,
|
||||
"markup": True,
|
||||
"console": _console,
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
}
|
||||
else:
|
||||
console_handler = {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": log_level,
|
||||
"formatter": "plain",
|
||||
"stream": "ext://sys.stdout",
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
}
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
@@ -270,6 +299,10 @@ def setup_logging() -> None:
|
||||
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"plain": {
|
||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"version_filter": {"()": VersionFilter},
|
||||
@@ -277,15 +310,7 @@ def setup_logging() -> None:
|
||||
"security_filter": {"()": SecurityFilter},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"()": RichHandler,
|
||||
"level": log_level,
|
||||
"show_time": False,
|
||||
"show_path": False,
|
||||
"rich_tracebacks": True,
|
||||
"markup": True,
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
},
|
||||
"console": console_handler,
|
||||
"file": {
|
||||
"()": DailyRotatingFileHandler,
|
||||
"level": log_level,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
@@ -9,9 +8,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.types import Scope
|
||||
|
||||
from ..auth import periodic_key_reset
|
||||
from ..auth import periodic_key_reset, periodic_stale_reservation_sweep
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..lightning import lightning_router, periodic_invoice_watcher
|
||||
from ..nostr import (
|
||||
announce_provider,
|
||||
providers_cache_refresher,
|
||||
@@ -22,24 +24,22 @@ 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 ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep
|
||||
from ..upstream.litellm_routing import configure_litellm
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
from .logging import get_logger, setup_logging
|
||||
from .middleware import LoggingMiddleware
|
||||
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
from .version import __version__
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.4.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
@@ -54,10 +54,17 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
stale_reservation_task = None
|
||||
auto_topup_task = None
|
||||
refund_sweep_task = None
|
||||
routstr_fee_task = None
|
||||
invoice_watcher_task = None
|
||||
|
||||
try:
|
||||
# Apply litellm-wide settings (drop_params, chat-completions URL,
|
||||
# debug logging) before any upstream provider dispatches a request.
|
||||
configure_litellm()
|
||||
|
||||
# Run database migrations on startup
|
||||
run_migrations()
|
||||
|
||||
@@ -102,8 +109,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
# Pass the accessor (not its current value) so the loop sees providers
|
||||
# added/changed via reinitialize_upstreams() instead of staying pinned
|
||||
# to the startup snapshot.
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
refresh_upstreams_models_periodically(get_upstreams)
|
||||
)
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
@@ -113,8 +123,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
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())
|
||||
stale_reservation_task = asyncio.create_task(
|
||||
periodic_stale_reservation_sweep()
|
||||
)
|
||||
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
||||
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
|
||||
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
|
||||
invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher())
|
||||
|
||||
yield
|
||||
|
||||
@@ -148,10 +163,16 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
model_maps_refresh_task.cancel()
|
||||
if key_reset_task is not None:
|
||||
key_reset_task.cancel()
|
||||
if stale_reservation_task is not None:
|
||||
stale_reservation_task.cancel()
|
||||
if auto_topup_task is not None:
|
||||
auto_topup_task.cancel()
|
||||
if refund_sweep_task is not None:
|
||||
refund_sweep_task.cancel()
|
||||
if routstr_fee_task is not None:
|
||||
routstr_fee_task.cancel()
|
||||
if invoice_watcher_task is not None:
|
||||
invoice_watcher_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -173,10 +194,16 @@ 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 stale_reservation_task is not None:
|
||||
tasks_to_wait.append(stale_reservation_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 routstr_fee_task is not None:
|
||||
tasks_to_wait.append(routstr_fee_task)
|
||||
if invoice_watcher_task is not None:
|
||||
tasks_to_wait.append(invoice_watcher_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
@@ -188,6 +215,23 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
)
|
||||
|
||||
|
||||
class _ImmutableStaticFiles(StaticFiles):
|
||||
"""Static files with long Cache-Control for content-hashed Next.js assets.
|
||||
|
||||
Files under `/_next/static/` are emitted with content hashes in their
|
||||
filenames and never mutate, so we serve them with a one-year immutable
|
||||
cache header so browsers and CDNs stop revalidating on every reload.
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> StarletteResponse:
|
||||
response = await super().get_response(path, scope)
|
||||
if response.status_code == 200:
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
|
||||
|
||||
@@ -234,7 +278,7 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
_ImmutableStaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@@ -242,100 +286,70 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
# Serve the App Router RSC payload for the home page.
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
async def serve_root_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
# Next.js is built with `trailingSlash: true`, so all UI page URLs end
|
||||
# with a slash (e.g. `/login/`). The proxy router catches `/{path:path}`
|
||||
# before FastAPI's `redirect_slashes` logic can normalize the URL, so we
|
||||
# must register both the with-slash and without-slash variants here.
|
||||
UI_PAGES = (
|
||||
"dashboard",
|
||||
"login",
|
||||
"model",
|
||||
"providers",
|
||||
"settings",
|
||||
"transactions",
|
||||
"balances",
|
||||
"logs",
|
||||
"usage",
|
||||
"unauthorized",
|
||||
)
|
||||
|
||||
def _register_ui_page(name: str) -> None:
|
||||
page_dir = UI_DIST_PATH / name
|
||||
index_html = page_dir / "index.html"
|
||||
index_txt = page_dir / "index.txt"
|
||||
|
||||
async def serve_page() -> FileResponse:
|
||||
return FileResponse(index_html)
|
||||
|
||||
async def serve_page_rsc() -> FileResponse:
|
||||
return FileResponse(index_txt, media_type="text/x-component")
|
||||
|
||||
app.add_api_route(
|
||||
f"/{name}",
|
||||
serve_page,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
name=f"serve_{name}_ui",
|
||||
)
|
||||
app.add_api_route(
|
||||
f"/{name}/",
|
||||
serve_page,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
name=f"serve_{name}_ui_slash",
|
||||
)
|
||||
app.add_api_route(
|
||||
f"/{name}/index.txt",
|
||||
serve_page_rsc,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
name=f"serve_{name}_rsc",
|
||||
)
|
||||
|
||||
for _page in UI_PAGES:
|
||||
_register_ui_page(_page)
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@app.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@app.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@app.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@app.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@app.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
@@ -347,9 +361,6 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
@@ -369,6 +380,7 @@ else:
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(balance_router)
|
||||
app.include_router(lightning_router)
|
||||
app.include_router(deprecated_wallet_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(proxy_router)
|
||||
|
||||
@@ -14,8 +14,54 @@ logger = get_logger(__name__)
|
||||
request_id_context: ContextVar[str | None] = ContextVar("request_id")
|
||||
|
||||
|
||||
# Methods that are never logged: HEAD requests are health probes from
|
||||
# monitoring/load balancers, OPTIONS are CORS preflights — both are framework
|
||||
# chatter, not user-meaningful events.
|
||||
_SKIP_LOG_METHODS: frozenset[str] = frozenset({"HEAD", "OPTIONS"})
|
||||
|
||||
# Path prefixes to skip. Includes Next.js static chunks and the admin
|
||||
# dashboard's internal polling API (/admin/api/*) which the UI hits on a timer
|
||||
# to refresh balances, logs, providers, etc. — high volume, low diagnostic
|
||||
# value. Mutating admin actions are recorded separately in the audit log.
|
||||
_SKIP_LOG_PREFIXES: tuple[str, ...] = (
|
||||
"/_next/",
|
||||
"/admin/api/",
|
||||
)
|
||||
|
||||
# Exact paths to skip. RSC payload prefetches (`*/index.txt`) fire automatically
|
||||
# as the user hovers near `<Link>`s, and `/v1/wallet/info` is polled by the UI.
|
||||
_SKIP_LOG_EXACT: frozenset[str] = frozenset(
|
||||
{
|
||||
"/favicon.ico",
|
||||
"/icon.ico",
|
||||
"/v1/wallet/info",
|
||||
"/index.txt",
|
||||
"/login/index.txt",
|
||||
"/model/index.txt",
|
||||
"/providers/index.txt",
|
||||
"/settings/index.txt",
|
||||
"/transactions/index.txt",
|
||||
"/balances/index.txt",
|
||||
"/logs/index.txt",
|
||||
"/usage/index.txt",
|
||||
"/unauthorized/index.txt",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _should_log(method: str, path: str) -> bool:
|
||||
if method in _SKIP_LOG_METHODS:
|
||||
return False
|
||||
if path in _SKIP_LOG_EXACT:
|
||||
return False
|
||||
return not any(path.startswith(prefix) for prefix in _SKIP_LOG_PREFIXES)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to log detailed request and response information."""
|
||||
"""Middleware to log proxy interactions and page navigation.
|
||||
|
||||
Skips logging for static assets and Next.js chunks to avoid noise.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Generate request ID
|
||||
@@ -25,56 +71,20 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
# Set request ID in context for logging
|
||||
token = request_id_context.set(request_id)
|
||||
|
||||
path = request.url.path
|
||||
should_log = _should_log(request.method, path)
|
||||
|
||||
# Start timing
|
||||
start_time = time.time()
|
||||
|
||||
# Log request details
|
||||
request_body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
# Only read body for non-streaming requests
|
||||
if hasattr(request, "_body"):
|
||||
request_body = await request.body()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log incoming request
|
||||
logger.info(
|
||||
"Incoming request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower()
|
||||
not in [
|
||||
"authorization",
|
||||
"x-cashu",
|
||||
"cookie",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Log at TRACE level for full body (security filter will redact sensitive data)
|
||||
if request_body and hasattr(logger, "exception"):
|
||||
logger.exception(
|
||||
"Request body",
|
||||
if should_log:
|
||||
logger.info(
|
||||
"Incoming request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"body": request_body.decode("utf-8", errors="ignore")[
|
||||
:1000
|
||||
], # Limit size
|
||||
"path": path,
|
||||
"query_params": dict(request.query_params),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -82,36 +92,32 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Calculate duration
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log response
|
||||
logger.info(
|
||||
"Request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
},
|
||||
)
|
||||
if should_log:
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"Request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
},
|
||||
)
|
||||
if hasattr(response, "headers"):
|
||||
response.headers["x-routstr-request-id"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Calculate duration
|
||||
# Always log failures, even for skipped paths, so we don't lose errors.
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log error
|
||||
logger.error(
|
||||
"Request failed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"path": path,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
|
||||
55
routstr/core/not_found.py
Normal file
55
routstr/core/not_found.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Shared 404 handler used by the proxy catch-all and tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
|
||||
_NOT_FOUND_HTML_FILE = Path(__file__).parent.parent.parent / "ui_out" / "404.html"
|
||||
|
||||
|
||||
def _read_not_found_html() -> str | None:
|
||||
try:
|
||||
return _NOT_FOUND_HTML_FILE.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
_NOT_FOUND_HTML: str | None = _read_not_found_html()
|
||||
|
||||
|
||||
def build_not_found_response(request: Request, path: str) -> Response:
|
||||
"""Return a 404 response.
|
||||
|
||||
HTML 404 page only for GET requests from browsers (Accept: text/html).
|
||||
All POST requests and API clients receive a JSON 404.
|
||||
"""
|
||||
accept = request.headers.get("accept", "").lower()
|
||||
prefers_html = (
|
||||
request.method == "GET"
|
||||
and "text/html" in accept
|
||||
and "application/json" not in accept
|
||||
)
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
if prefers_html and _NOT_FOUND_HTML is not None:
|
||||
return HTMLResponse(content=_NOT_FOUND_HTML, status_code=404)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": {
|
||||
"message": f"Path '/{path}' not found",
|
||||
"type": "not_found",
|
||||
"code": 404,
|
||||
},
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def not_found_catch_all(request: Request, path: str) -> Response:
|
||||
"""ASGI handler form of :func:`build_not_found_response`."""
|
||||
return build_not_found_response(request, path)
|
||||
@@ -41,6 +41,15 @@ class Settings(BaseSettings):
|
||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||
|
||||
# Lightning payout configuration
|
||||
# Minimum available balance (in satoshis) before profit is paid out over
|
||||
# Lightning
|
||||
min_payout_sat: int = Field(default=210, gt=0, env="MIN_PAYOUT_SAT")
|
||||
# Interval (seconds) between periodic payout attempts. Must be positive.
|
||||
payout_interval_seconds: int = Field(
|
||||
default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS"
|
||||
)
|
||||
|
||||
# Pricing
|
||||
# Default behavior: derive pricing from MODELS
|
||||
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
|
||||
@@ -58,6 +67,12 @@ class Settings(BaseSettings):
|
||||
reset_reserved_balance_on_startup: bool = Field(
|
||||
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
|
||||
) # deactivate in horizontal scaling setups
|
||||
# Reservations older than this are considered leaked (client disconnect,
|
||||
# crash, abandoned stream) and released by the background sweeper and the
|
||||
# refund endpoint.
|
||||
stale_reservation_timeout_seconds: int = Field(
|
||||
default=300, env="STALE_RESERVATION_TIMEOUT_SECONDS"
|
||||
)
|
||||
|
||||
# Network
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||
|
||||
81
routstr/core/version.py
Normal file
81
routstr/core/version.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Application version resolution.
|
||||
|
||||
Priority order:
|
||||
|
||||
1. ``VERSION_SUFFIX`` env var (manual override; preserves prior behaviour).
|
||||
2. Bare base version when HEAD is on the matching release tag (detected via
|
||||
``GIT_TAG`` env or ``git describe --tags --exact-match HEAD``).
|
||||
3. ``GIT_COMMIT`` env var (build-time injection) -> ``<base>+g<sha>``.
|
||||
4. Local ``.git`` lookup (source checkouts) -> ``<base>+g<sha>``.
|
||||
5. Fallback: bare base version.
|
||||
|
||||
The ``+g<sha>`` form is PEP 440 local-version syntax so the result remains a
|
||||
valid package version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
BASE_VERSION = "0.4.3"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_GIT_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
def _run_git(*args: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603 - fixed argv, no shell
|
||||
["git", *args],
|
||||
cwd=_REPO_ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def _git_short_sha() -> str | None:
|
||||
sha = os.getenv("GIT_COMMIT", "").strip()
|
||||
if sha:
|
||||
return sha[:7]
|
||||
return _run_git("rev-parse", "--short=7", "HEAD")
|
||||
|
||||
|
||||
def _on_tagged_release() -> bool:
|
||||
tag = os.getenv("GIT_TAG", "").strip()
|
||||
if tag:
|
||||
return tag.lstrip("v") == BASE_VERSION
|
||||
described = _run_git("describe", "--tags", "--exact-match", "HEAD")
|
||||
if described and described.lstrip("v") == BASE_VERSION:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_version() -> str:
|
||||
suffix = os.getenv("VERSION_SUFFIX")
|
||||
if suffix is not None:
|
||||
return f"{BASE_VERSION}-{suffix}"
|
||||
|
||||
if _on_tagged_release():
|
||||
return BASE_VERSION
|
||||
|
||||
sha = _git_short_sha()
|
||||
if not sha:
|
||||
return BASE_VERSION
|
||||
|
||||
return f"{BASE_VERSION}+g{sha}"
|
||||
|
||||
|
||||
__version__ = get_version()
|
||||
|
||||
__all__ = ["BASE_VERSION", "__version__", "get_version"]
|
||||
@@ -1,13 +1,14 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, get_session
|
||||
from .core.db import ApiKey, LightningInvoice, create_session, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import get_wallet
|
||||
@@ -19,15 +20,29 @@ lightning_router = APIRouter(prefix="/lightning")
|
||||
|
||||
class InvoiceCreateRequest(BaseModel):
|
||||
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
|
||||
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
|
||||
purpose: str = Field(
|
||||
default="create",
|
||||
description="create or topup",
|
||||
pattern="^(create|topup)$",
|
||||
)
|
||||
api_key: str | None = Field(
|
||||
default=None, description="Required for topup operations"
|
||||
default=None,
|
||||
description="Deprecated: legacy field for topup. Prefer Authorization header.",
|
||||
)
|
||||
balance_limit: int | None = Field(default=None)
|
||||
balance_limit_reset: str | None = Field(default=None)
|
||||
validity_date: int | None = Field(default=None)
|
||||
|
||||
|
||||
def _extract_bearer_api_key(authorization: str | None) -> str | None:
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.strip()
|
||||
if token.lower().startswith("bearer "):
|
||||
token = token[7:].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
class InvoiceCreateResponse(BaseModel):
|
||||
invoice_id: str
|
||||
bolt11: str
|
||||
@@ -64,18 +79,21 @@ def generate_invoice_id() -> str:
|
||||
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||
async def create_invoice(
|
||||
request: InvoiceCreateRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceCreateResponse:
|
||||
if request.purpose == "topup" and not request.api_key:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="api_key is required for topup operations"
|
||||
)
|
||||
api_key_token = _extract_bearer_api_key(authorization) or request.api_key
|
||||
|
||||
if request.purpose == "topup" and request.api_key:
|
||||
if not request.api_key.startswith("sk-"):
|
||||
if request.purpose == "topup":
|
||||
if not api_key_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authorization bearer api key is required for topup",
|
||||
)
|
||||
if not api_key_token.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid API key format")
|
||||
|
||||
api_key = await session.get(ApiKey, request.api_key[3:])
|
||||
api_key = await session.get(ApiKey, api_key_token[3:])
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
@@ -95,7 +113,7 @@ async def create_invoice(
|
||||
description=description,
|
||||
payment_hash=payment_hash,
|
||||
status="pending",
|
||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||
api_key_hash=api_key_token[3:] if api_key_token else None,
|
||||
purpose=request.purpose,
|
||||
balance_limit=request.balance_limit,
|
||||
balance_limit_reset=request.balance_limit_reset,
|
||||
@@ -142,13 +160,13 @@ async def get_invoice_status(
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
|
||||
invoice.status = "expired"
|
||||
await session.commit()
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid" and invoice.purpose == "create":
|
||||
if invoice.api_key_hash:
|
||||
@@ -251,6 +269,9 @@ async def create_api_key_from_invoice(
|
||||
balance=invoice.amount_sats * 1000, # Convert to msats
|
||||
refund_currency="sat",
|
||||
refund_mint_url=settings.primary_mint,
|
||||
balance_limit=invoice.balance_limit,
|
||||
balance_limit_reset=invoice.balance_limit_reset,
|
||||
validity_date=invoice.validity_date,
|
||||
)
|
||||
|
||||
session.add(api_key)
|
||||
@@ -274,3 +295,41 @@ async def topup_api_key_from_invoice(
|
||||
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
await session.flush()
|
||||
|
||||
|
||||
INVOICE_WATCH_INTERVAL_SECONDS = 5
|
||||
INVOICE_WATCH_BATCH_LIMIT = 100
|
||||
|
||||
|
||||
async def periodic_invoice_watcher() -> None:
|
||||
"""Background task: detect paid Lightning invoices and credit balances.
|
||||
|
||||
Removes the need for clients to poll the status endpoint after paying.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
now = int(time.time())
|
||||
result = await session.exec(
|
||||
select(LightningInvoice)
|
||||
.where(
|
||||
LightningInvoice.status == "pending",
|
||||
col(LightningInvoice.expires_at) > now,
|
||||
)
|
||||
.limit(INVOICE_WATCH_BATCH_LIMIT)
|
||||
)
|
||||
pending = result.all()
|
||||
for invoice in pending:
|
||||
try:
|
||||
await check_invoice_payment(invoice, session)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Invoice watcher failed for invoice",
|
||||
extra={"invoice_id": invoice.id, "error": str(e)},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Invoice watcher loop error: {e}")
|
||||
|
||||
await asyncio.sleep(INVOICE_WATCH_INTERVAL_SECONDS)
|
||||
|
||||
@@ -26,7 +26,7 @@ logger = get_logger(__name__)
|
||||
|
||||
def get_app_version() -> str | None:
|
||||
try:
|
||||
from ..core.main import __version__ as imported_version
|
||||
from ..core.version import __version__ as imported_version
|
||||
|
||||
return imported_version
|
||||
except Exception:
|
||||
|
||||
@@ -18,6 +18,10 @@ class CostData(BaseModel):
|
||||
total_usd: float = 0.0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_read_input_tokens: int = 0
|
||||
cache_creation_input_tokens: int = 0
|
||||
cache_read_msats: int = 0
|
||||
cache_creation_msats: int = 0
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
@@ -29,11 +33,10 @@ class CostDataError(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
async def calculate_cost( # todo: can be sync
|
||||
async def calculate_cost(
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
"""Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
@@ -51,12 +54,20 @@ async def calculate_cost( # todo: can be sync
|
||||
},
|
||||
)
|
||||
|
||||
# Check for usage data
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
"No usage data in response — billing at MaxCostData with zero "
|
||||
"tokens. Dashboard will show this request as `(0+0)`. Most "
|
||||
"common cause: upstream stream did not include a final usage "
|
||||
"chunk (OpenAI-compat backends require "
|
||||
"`stream_options.include_usage=true`).",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
"response_keys": sorted(response_data.keys())
|
||||
if isinstance(response_data, dict)
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return MaxCostData(
|
||||
@@ -67,116 +78,58 @@ async def calculate_cost( # todo: can be sync
|
||||
total_usd=0.0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_msats=0,
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
def parse_token_count(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
# Extract token counts
|
||||
input_tokens = _extract_token_pair(usage_data, "prompt_tokens", "input_tokens")
|
||||
output_tokens = _extract_token_pair(usage_data, "completion_tokens", "output_tokens")
|
||||
|
||||
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
|
||||
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else parse_token_count(usage_data.get("input_tokens", 0))
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else parse_token_count(usage_data.get("output_tokens", 0))
|
||||
)
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
|
||||
)
|
||||
|
||||
usd_cost = 0.0
|
||||
input_usd = 0.0
|
||||
output_usd = 0.0
|
||||
|
||||
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:
|
||||
try:
|
||||
usd_cost = float(usage_data.get("cost", 0) or 0)
|
||||
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
|
||||
# Extract cache tokens (handles OpenAI vs Anthropic formats)
|
||||
cache_read_tokens, cache_creation_tokens, input_tokens = _extract_cache_tokens(
|
||||
usage_data, input_tokens
|
||||
)
|
||||
|
||||
# Try USD cost first
|
||||
usd_cost = _resolve_usd_cost(usage_data, response_data)
|
||||
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",
|
||||
if input_tokens == 0 and output_tokens == 0:
|
||||
logger.warning(
|
||||
"Upstream reported a USD cost but no token counts — "
|
||||
"billing the USD-derived cost while the dashboard will "
|
||||
"show this request as `(0+0)` tokens. Check that the "
|
||||
"upstream actually emits `usage.input_tokens` and "
|
||||
"`usage.output_tokens` (OpenAI-compat streams require "
|
||||
"`stream_options.include_usage=true`).",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
"usd_cost": usd_cost,
|
||||
"usage_keys": sorted(usage_data.keys())
|
||||
if isinstance(usage_data, dict)
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=input_msats,
|
||||
output_msats=output_msats,
|
||||
total_msats=cost_in_msats,
|
||||
total_usd=usd_cost,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
try:
|
||||
input_usd = _coerce_usd(
|
||||
usage_data.get("cost_details", {}).get("input_cost", 0)
|
||||
)
|
||||
output_usd = _coerce_usd(
|
||||
usage_data.get("cost_details", {}).get("output_cost", 0)
|
||||
)
|
||||
return _calculate_from_usd_cost(
|
||||
usd_cost,
|
||||
input_usd,
|
||||
output_usd,
|
||||
input_tokens,
|
||||
cache_read_tokens,
|
||||
cache_creation_tokens,
|
||||
output_tokens,
|
||||
response_data,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -187,62 +140,35 @@ async def calculate_cost( # todo: can be sync
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
if not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={"model": response_model},
|
||||
)
|
||||
# Fall back to token-based pricing
|
||||
try:
|
||||
pricing_rates = _get_pricing_rates(response_data)
|
||||
except ValueError as e:
|
||||
return CostDataError(message=str(e), code="pricing_error")
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
if pricing_rates is None:
|
||||
input_rate = float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
output_rate = float(settings.fixed_per_1k_output_tokens) * 1000.0
|
||||
cache_read_rate = input_rate
|
||||
cache_creation_rate = input_rate
|
||||
else:
|
||||
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
|
||||
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={"response_model": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
except Exception:
|
||||
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
if not (input_rate and output_rate):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
"No token pricing configured — billing at flat MaxCostData. "
|
||||
"Token counts %s in the upstream response but cannot be "
|
||||
"priced; the request will appear in dashboards with the "
|
||||
"raw counts and a fixed max-cost charge.",
|
||||
"are present"
|
||||
if (input_tokens > 0 or output_tokens > 0)
|
||||
else "are zero",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
},
|
||||
)
|
||||
return MaxCostData(
|
||||
@@ -252,12 +178,263 @@ async def calculate_cost( # todo: can be sync
|
||||
total_msats=max_cost,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_input_tokens=cache_read_tokens,
|
||||
cache_creation_input_tokens=cache_creation_tokens,
|
||||
cache_read_msats=0,
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
|
||||
calc_input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
return _calculate_from_tokens(
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_tokens,
|
||||
cache_creation_tokens,
|
||||
input_rate,
|
||||
output_rate,
|
||||
cache_read_rate,
|
||||
cache_creation_rate,
|
||||
response_data,
|
||||
)
|
||||
|
||||
calc_output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(calc_input_msats + calc_output_msats)
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions (ordered by call sequence in calculate_cost)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def parse_token_count(value: object) -> int:
|
||||
"""Parse a token count from various formats (int, float, str, bool)."""
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _coerce_usd(value: object) -> float:
|
||||
"""Coerce a value to USD float, handling various formats safely."""
|
||||
if value is None or isinstance(value, bool):
|
||||
return 0.0
|
||||
if not isinstance(value, (int, float, str)):
|
||||
return 0.0
|
||||
try:
|
||||
return max(0.0, float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _extract_token_pair(
|
||||
usage_data: dict, standard_field: str, alt_field: str
|
||||
) -> int:
|
||||
"""Extract token count trying two field names in order."""
|
||||
value = parse_token_count(usage_data.get(standard_field, 0))
|
||||
if value > 0:
|
||||
return value
|
||||
return parse_token_count(usage_data.get(alt_field, 0))
|
||||
|
||||
|
||||
def _extract_cache_tokens(usage_data: dict, input_tokens: int) -> tuple[int, int, int]:
|
||||
"""Extract cache tokens, handling OpenAI vs Anthropic formats.
|
||||
|
||||
Returns: (cache_read_tokens, cache_creation_tokens, adjusted_input_tokens)
|
||||
"""
|
||||
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
|
||||
cache_creation = parse_token_count(
|
||||
usage_data.get("cache_creation_input_tokens", 0)
|
||||
)
|
||||
|
||||
# OpenAI: cache is included in input_tokens, subtract it
|
||||
prompt_details = usage_data.get("prompt_tokens_details")
|
||||
if isinstance(prompt_details, dict) and not cache_read:
|
||||
openai_cached = parse_token_count(prompt_details.get("cached_tokens", 0))
|
||||
if openai_cached:
|
||||
cache_read = openai_cached
|
||||
input_tokens = max(0, input_tokens - cache_read)
|
||||
|
||||
return cache_read, cache_creation, input_tokens
|
||||
|
||||
|
||||
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
|
||||
"""Resolve USD cost with clear priority order.
|
||||
|
||||
Priority: cost_details.total_cost → total_cost → cost (in both usage and response).
|
||||
"""
|
||||
cost_details = usage_data.get("cost_details")
|
||||
if isinstance(cost_details, dict):
|
||||
cost = _coerce_usd(cost_details.get("total_cost"))
|
||||
if cost > 0:
|
||||
return cost
|
||||
|
||||
for source in [usage_data, response_data]:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
for field in ("total_cost", "cost"):
|
||||
cost = _coerce_usd(source.get(field))
|
||||
if cost > 0:
|
||||
return cost
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _get_pricing_rates(
|
||||
response_data: dict,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Get model-based pricing rates or None if using fixed pricing.
|
||||
|
||||
Returns: (input_rate, output_rate, cache_read_rate, cache_write_rate)
|
||||
"""
|
||||
if settings.fixed_pricing:
|
||||
return None
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
response_model = response_data.get("model", "")
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error("Invalid model in response", extra={"response_model": response_model})
|
||||
raise ValueError(f"Invalid model: {response_model}")
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
raise ValueError("Model pricing not defined")
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
mscr = float(model_obj.sats_pricing.input_cache_read or 0)
|
||||
mscw = float(model_obj.sats_pricing.input_cache_write or 0)
|
||||
|
||||
mspp_1k = mspp * 1_000_000.0
|
||||
mspc_1k = mspc * 1_000_000.0
|
||||
mscr_1k = mscr * 1_000_000.0 if mscr > 0 else mspp_1k
|
||||
mscw_1k = mscw * 1_000_000.0 if mscw > 0 else mspp_1k
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": mspp_1k,
|
||||
"output_price_msats_per_1k": mspc_1k,
|
||||
"cache_read_price_msats_per_1k": mscr_1k,
|
||||
"cache_write_price_msats_per_1k": mscw_1k,
|
||||
},
|
||||
)
|
||||
return mspp_1k, mspc_1k, mscr_1k, mscw_1k
|
||||
except Exception as e:
|
||||
logger.error("Invalid pricing data", extra={"error": str(e)})
|
||||
raise ValueError("Invalid pricing data") from e
|
||||
|
||||
|
||||
def _resolve_provider_fee(model_id: str) -> float:
|
||||
"""Resolve the provider fee multiplier for the given model id.
|
||||
|
||||
Falls back to 1.0 (no markup) when the provider cannot be resolved so
|
||||
the USD cost path never silently double-applies or omits the fee.
|
||||
"""
|
||||
from ..proxy import get_provider_for_model
|
||||
|
||||
if not model_id:
|
||||
return 1.0
|
||||
providers = get_provider_for_model(model_id)
|
||||
if not providers:
|
||||
return 1.0
|
||||
return float(providers[0].provider_fee)
|
||||
|
||||
|
||||
def _calculate_from_usd_cost(
|
||||
usd_cost: float,
|
||||
input_usd: float,
|
||||
output_usd: float,
|
||||
input_tokens: int,
|
||||
cache_read_tokens: int,
|
||||
cache_creation_tokens: int,
|
||||
output_tokens: int,
|
||||
response_data: dict,
|
||||
) -> CostData:
|
||||
"""Calculate cost from USD figures, deriving input/output split from tokens."""
|
||||
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
|
||||
usd_cost = usd_cost * provider_fee
|
||||
input_usd = input_usd * provider_fee
|
||||
output_usd = output_usd * provider_fee
|
||||
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)
|
||||
|
||||
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:
|
||||
effective_input_tokens = (
|
||||
input_tokens + cache_read_tokens + cache_creation_tokens
|
||||
)
|
||||
total_tokens = effective_input_tokens + output_tokens
|
||||
input_msats = (
|
||||
int(cost_in_msats * effective_input_tokens / total_tokens)
|
||||
if total_tokens > 0
|
||||
else 0
|
||||
)
|
||||
output_msats = cost_in_msats - input_msats
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=input_msats,
|
||||
output_msats=output_msats,
|
||||
total_msats=cost_in_msats,
|
||||
total_usd=usd_cost,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_input_tokens=cache_read_tokens,
|
||||
cache_creation_input_tokens=cache_creation_tokens,
|
||||
cache_read_msats=0,
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
|
||||
|
||||
def _calculate_from_tokens(
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cache_read_tokens: int,
|
||||
cache_creation_tokens: int,
|
||||
input_rate: float,
|
||||
output_rate: float,
|
||||
cache_read_rate: float,
|
||||
cache_creation_rate: float,
|
||||
response_data: dict,
|
||||
) -> CostData:
|
||||
"""Calculate cost from token counts using pricing rates."""
|
||||
calc_input_msats = round(input_tokens / 1000 * input_rate, 3)
|
||||
calc_output_msats = round(output_tokens / 1000 * output_rate, 3)
|
||||
calc_cache_read_msats = round(cache_read_tokens / 1000 * cache_read_rate, 3)
|
||||
calc_cache_write_msats = round(
|
||||
cache_creation_tokens / 1000 * cache_creation_rate, 3
|
||||
)
|
||||
token_based_cost = math.ceil(
|
||||
calc_input_msats
|
||||
+ calc_output_msats
|
||||
+ calc_cache_read_msats
|
||||
+ calc_cache_write_msats
|
||||
)
|
||||
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
|
||||
|
||||
logger.info(
|
||||
@@ -265,8 +442,12 @@ async def calculate_cost( # todo: can be sync
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": cache_read_tokens,
|
||||
"cache_creation_input_tokens": cache_creation_tokens,
|
||||
"input_cost_msats": calc_input_msats,
|
||||
"output_cost_msats": calc_output_msats,
|
||||
"cache_read_cost_msats": calc_cache_read_msats,
|
||||
"cache_creation_cost_msats": calc_cache_write_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"total_usd": total_usd,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
@@ -281,4 +462,8 @@ async def calculate_cost( # todo: can be sync
|
||||
total_usd=total_usd,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_input_tokens=cache_read_tokens,
|
||||
cache_creation_input_tokens=cache_creation_tokens,
|
||||
cache_read_msats=int(calc_cache_read_msats),
|
||||
cache_creation_msats=int(calc_cache_write_msats),
|
||||
)
|
||||
|
||||
@@ -3,11 +3,12 @@ import json
|
||||
import random
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel as V2BaseModel
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, get_session
|
||||
from ..core.db import ModelRow, UpstreamProviderRow, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
@@ -16,6 +17,24 @@ logger = get_logger(__name__)
|
||||
|
||||
models_router = APIRouter()
|
||||
|
||||
_MODEL_TEST_ENDPOINT_PATHS = {
|
||||
"chat-completions": "chat/completions",
|
||||
"completions": "completions",
|
||||
"embeddings": "embeddings",
|
||||
"responses": "responses",
|
||||
}
|
||||
|
||||
# Cap the caller-supplied test payload to avoid forwarding oversized bodies
|
||||
# upstream on the operator's credentials.
|
||||
_MODEL_TEST_MAX_REQUEST_BYTES = 64 * 1024
|
||||
|
||||
|
||||
async def _require_admin_api(request: Request) -> None:
|
||||
"""Require admin auth without creating an import-time cycle with core.admin."""
|
||||
from ..core.admin import require_admin_api
|
||||
|
||||
await require_admin_api(request)
|
||||
|
||||
|
||||
class Architecture(BaseModel):
|
||||
modality: str
|
||||
@@ -350,6 +369,9 @@ async def _update_sats_pricing_once() -> None:
|
||||
from ..proxy import get_upstreams, refresh_model_maps
|
||||
|
||||
upstreams = get_upstreams()
|
||||
if not upstreams:
|
||||
return
|
||||
|
||||
sats_to_usd = sats_usd_price()
|
||||
|
||||
updated_count = 0
|
||||
@@ -359,11 +381,14 @@ async def _update_sats_pricing_once() -> None:
|
||||
for m in upstream.get_cached_models()
|
||||
]
|
||||
upstream._models_cache = updated_models
|
||||
upstream._models_by_id = {m.id: m for m in updated_models}
|
||||
upstream._models_by_id = {m.forwarded_model_id or m.id: m for m in updated_models}
|
||||
updated_count += len(updated_models)
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
logger.info(
|
||||
f"Updated sats pricing for {updated_count} models",
|
||||
extra={"models_updated": updated_count},
|
||||
)
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
@@ -405,6 +430,97 @@ async def update_sats_pricing() -> None:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
class ModelTestRequest(V2BaseModel):
|
||||
model_id: str
|
||||
endpoint_type: str
|
||||
request_data: dict
|
||||
|
||||
|
||||
@models_router.post(
|
||||
"/api/models/test", dependencies=[Depends(_require_admin_api)]
|
||||
)
|
||||
async def test_model(
|
||||
payload: ModelTestRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Test a model by sending a request through its configured upstream provider."""
|
||||
from sqlmodel import select
|
||||
|
||||
result = await session.execute(
|
||||
select(ModelRow).where(ModelRow.id == payload.model_id)
|
||||
)
|
||||
model_row = result.scalars().first()
|
||||
|
||||
if not model_row:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Model '{payload.model_id}' not found in database",
|
||||
"status_code": 404,
|
||||
}
|
||||
|
||||
provider = await session.get(UpstreamProviderRow, model_row.upstream_provider_id)
|
||||
if not provider:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Upstream provider not found",
|
||||
"status_code": 404,
|
||||
}
|
||||
|
||||
endpoint_path = _MODEL_TEST_ENDPOINT_PATHS.get(payload.endpoint_type)
|
||||
if endpoint_path is None:
|
||||
raise HTTPException(status_code=400, detail="Unsupported endpoint_type")
|
||||
|
||||
actual_model_id = model_row.forwarded_model_id or model_row.id
|
||||
request_data = dict(payload.request_data)
|
||||
request_data["model"] = actual_model_id
|
||||
|
||||
try:
|
||||
request_size = len(json.dumps(request_data).encode("utf-8"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Invalid request_data")
|
||||
if request_size > _MODEL_TEST_MAX_REQUEST_BYTES:
|
||||
raise HTTPException(status_code=413, detail="request_data too large")
|
||||
|
||||
base_url = provider.base_url.rstrip("/")
|
||||
url = f"{base_url}/{endpoint_path}"
|
||||
|
||||
logger.info(
|
||||
"admin model test",
|
||||
extra={
|
||||
"model_id": payload.model_id,
|
||||
"forwarded_model_id": actual_model_id,
|
||||
"endpoint_type": payload.endpoint_type,
|
||||
"upstream_provider_id": model_row.upstream_provider_id,
|
||||
"request_bytes": request_size,
|
||||
},
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {provider.api_key}",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, json=request_data, headers=headers)
|
||||
try:
|
||||
response_data = response.json()
|
||||
except Exception:
|
||||
response_data = {"raw": response.text}
|
||||
|
||||
return {
|
||||
"success": response.status_code < 400,
|
||||
"data": response_data,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"status_code": 500,
|
||||
}
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/v1/models/", include_in_schema=False)
|
||||
@models_router.get("/models")
|
||||
|
||||
246
routstr/proxy.py
246
routstr/proxy.py
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
@@ -17,6 +18,7 @@ from .core.db import (
|
||||
get_session,
|
||||
)
|
||||
from .core.exceptions import UpstreamError
|
||||
from .core.not_found import build_not_found_response
|
||||
from .core.settings import settings
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
@@ -27,6 +29,7 @@ from .payment.helpers import (
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.helpers import init_upstreams
|
||||
from .upstream.request_correction import correct_request, extract_error_message
|
||||
|
||||
logger = get_logger(__name__)
|
||||
proxy_router = APIRouter()
|
||||
@@ -69,7 +72,25 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
def get_model_instance(model_id: str) -> Model | None:
|
||||
"""Get Model instance by ID from global cache."""
|
||||
return _model_instances.get(model_id.lower())
|
||||
if not model_id:
|
||||
return None
|
||||
|
||||
model_id_lower = model_id.lower()
|
||||
# Try exact match first
|
||||
if model := _model_instances.get(model_id_lower):
|
||||
return model
|
||||
|
||||
# Try stripping common version suffixes (e.g., -20251222)
|
||||
# This handles cases where upstream returns a specific version
|
||||
# but we only track the base model name.
|
||||
import re
|
||||
|
||||
base_model_id = re.sub(r"-\d{8}$", "", model_id_lower)
|
||||
if base_model_id != model_id_lower:
|
||||
if model := _model_instances.get(base_model_id):
|
||||
return model
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
||||
@@ -132,16 +153,72 @@ async def refresh_model_maps_periodically() -> None:
|
||||
)
|
||||
|
||||
|
||||
_API_PATH_PREFIXES = (
|
||||
"v1/",
|
||||
"responses",
|
||||
"chat/",
|
||||
"completions",
|
||||
"models",
|
||||
"embeddings",
|
||||
"audio/",
|
||||
"images/",
|
||||
"moderations",
|
||||
"providers",
|
||||
"tee/",
|
||||
)
|
||||
|
||||
|
||||
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
||||
async def proxy(
|
||||
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Response | StreamingResponse:
|
||||
# GET requests must hit a known API prefix; otherwise return a 404 (HTML
|
||||
# for browsers, JSON for API clients). POST requests are always forwarded
|
||||
# so that OpenAI-style endpoints work with or without the `v1/` prefix
|
||||
# (e.g. `/chat/completions` as well as `/v1/chat/completions`).
|
||||
if request.method == "GET" and not path.startswith(_API_PATH_PREFIXES):
|
||||
return build_not_found_response(request, path)
|
||||
|
||||
headers = dict(request.headers)
|
||||
|
||||
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)
|
||||
|
||||
# /tee/* GET requests (e.g. attestation) don't map to models — just
|
||||
# forward to all enabled upstreams without model/cost/auth lookups.
|
||||
if request.method == "GET" and path.startswith("tee/"):
|
||||
all_upstreams = _upstreams
|
||||
last_error_response = None
|
||||
for i, upstream in enumerate(all_upstreams):
|
||||
try:
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
response = await upstream.forward_get_request(request, path, headers)
|
||||
if response.status_code in [502, 429] and i < len(all_upstreams) - 1:
|
||||
logger.warning(
|
||||
"Upstream %s returned %s for tee GET %s, trying next",
|
||||
upstream.provider_type,
|
||||
response.status_code,
|
||||
path,
|
||||
)
|
||||
continue
|
||||
return response
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
"Upstream %s failed for tee GET %s: %s",
|
||||
upstream.provider_type,
|
||||
path,
|
||||
e,
|
||||
)
|
||||
if i == len(all_upstreams) - 1:
|
||||
last_error_response = create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
continue
|
||||
return last_error_response or create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
if is_responses_api:
|
||||
model_id = extract_model_from_responses_request(request_body_dict)
|
||||
else:
|
||||
@@ -192,7 +269,15 @@ async def proxy(
|
||||
)
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
|
||||
"Upstream %s failed (x-cashu) for model=%s: %s",
|
||||
upstream.provider_type,
|
||||
model_id,
|
||||
e,
|
||||
extra={
|
||||
"provider": upstream.provider_type,
|
||||
"model": model_id,
|
||||
"status_code": e.status_code,
|
||||
},
|
||||
)
|
||||
if i == len(upstreams) - 1:
|
||||
last_error = e
|
||||
@@ -269,50 +354,86 @@ async def proxy(
|
||||
if request_body_dict:
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
|
||||
# Tracks request params already removed in response to upstream rejections,
|
||||
# shared across providers so a stripped param stays stripped on failover and
|
||||
# the reactive retry can never loop unboundedly.
|
||||
already_stripped: set[str] = set()
|
||||
|
||||
for i, upstream in enumerate(upstreams):
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
|
||||
try:
|
||||
try:
|
||||
if is_responses_api:
|
||||
response = await upstream.forward_responses_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
while True:
|
||||
try:
|
||||
if is_responses_api:
|
||||
response = await upstream.forward_responses_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
else:
|
||||
response = await upstream.forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
except UpstreamError:
|
||||
# Let the outer UpstreamError handler manage retry/revert
|
||||
raise
|
||||
except Exception as e:
|
||||
# Unexpected error (not an upstream failure) — revert and propagate
|
||||
logger.error(
|
||||
"Unexpected error in upstream request, reverting payment",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
response = await upstream.forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
raise
|
||||
|
||||
# Reactive recovery: some models reject one specific request
|
||||
# param (e.g. newer Anthropic models deprecating `temperature`).
|
||||
# When the upstream 400s naming such a param, strip it from the
|
||||
# body and retry the SAME upstream. ``already_stripped`` bounds
|
||||
# this to one retry per distinct param so it always terminates.
|
||||
if response.status_code == 400:
|
||||
correction = correct_request(
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
extract_error_message(response),
|
||||
already_stripped,
|
||||
)
|
||||
except UpstreamError:
|
||||
# Let the outer UpstreamError handler manage retry/revert
|
||||
raise
|
||||
except Exception as e:
|
||||
# Unexpected error (not an upstream failure) — revert and propagate
|
||||
logger.error(
|
||||
"Unexpected error in upstream request, reverting payment",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
},
|
||||
)
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
raise
|
||||
if correction is not None:
|
||||
request_body, bad_param = correction.body, correction.label
|
||||
already_stripped.add(bad_param)
|
||||
logger.warning(
|
||||
"Upstream %s rejected param '%s' for model=%s; "
|
||||
"stripping and retrying same upstream",
|
||||
upstream.provider_type,
|
||||
bad_param,
|
||||
model_id,
|
||||
extra={
|
||||
"provider": upstream.provider_type,
|
||||
"model": model_id,
|
||||
"stripped_param": bad_param,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
continue
|
||||
break
|
||||
|
||||
if response.status_code != 200:
|
||||
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
|
||||
@@ -337,10 +458,14 @@ async def proxy(
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
|
||||
"Upstream %s returned %s for model=%s, trying next provider",
|
||||
upstream.provider_type,
|
||||
response.status_code,
|
||||
model_id,
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"upstream": upstream.provider_type,
|
||||
"provider": upstream.provider_type,
|
||||
"model": model_id,
|
||||
},
|
||||
)
|
||||
continue
|
||||
@@ -348,26 +473,53 @@ async def proxy(
|
||||
# 4xx error (user error), or other non-retryable error, or last provider failed
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
logger.warning(
|
||||
"Upstream request failed, revert payment",
|
||||
"Upstream request failed, revert payment "
|
||||
"(provider=%s model=%s status=%s path=%s)",
|
||||
upstream.provider_type,
|
||||
model_id,
|
||||
response.status_code,
|
||||
path,
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"provider": upstream.provider_type,
|
||||
"model": model_id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
"upstream_headers": response.headers
|
||||
if hasattr(response, "headers")
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
return response
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(
|
||||
"Client disconnected mid-request, reverting reservation",
|
||||
extra={
|
||||
"path": path,
|
||||
"model": model_id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"max_cost_for_model": max_cost_for_model,
|
||||
},
|
||||
)
|
||||
await asyncio.shield(
|
||||
revert_pay_for_request(key, session, max_cost_for_model)
|
||||
)
|
||||
raise
|
||||
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
f"Upstream {upstream.provider_type} failed: {e}",
|
||||
extra={"retry": i < len(upstreams) - 1},
|
||||
"Upstream %s failed for model=%s: %s",
|
||||
upstream.provider_type,
|
||||
model_id,
|
||||
e,
|
||||
extra={
|
||||
"provider": upstream.provider_type,
|
||||
"model": model_id,
|
||||
"status_code": e.status_code,
|
||||
"retry": i < len(upstreams) - 1,
|
||||
},
|
||||
)
|
||||
|
||||
# If this was the last provider
|
||||
|
||||
@@ -13,6 +13,8 @@ class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "anthropic"
|
||||
default_base_url = "https://api.anthropic.com/v1"
|
||||
platform_url = "https://console.anthropic.com/settings/keys"
|
||||
supports_anthropic_messages = True
|
||||
litellm_provider_prefix = "anthropic/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
|
||||
@@ -13,6 +13,7 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "azure"
|
||||
default_base_url = None
|
||||
platform_url = "https://portal.azure.com/"
|
||||
litellm_provider_prefix = "azure/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,14 +26,14 @@ class GeminiClient(BaseAPIClient):
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
from openai import NOT_GIVEN
|
||||
from openai import omit
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
temperature=temperature if temperature is not None else omit,
|
||||
max_tokens=max_tokens if max_tokens is not None else omit,
|
||||
top_p=kwargs.get("top_p", omit),
|
||||
)
|
||||
return response.model_dump()
|
||||
|
||||
@@ -45,7 +45,7 @@ class GeminiClient(BaseAPIClient):
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
from openai import NOT_GIVEN
|
||||
from openai import omit
|
||||
|
||||
usage_callback = kwargs.get("usage_callback")
|
||||
completion_callback = kwargs.get("completion_callback")
|
||||
@@ -55,9 +55,9 @@ class GeminiClient(BaseAPIClient):
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
temperature=temperature if temperature is not None else omit,
|
||||
max_tokens=max_tokens if max_tokens is not None else omit,
|
||||
top_p=kwargs.get("top_p", omit),
|
||||
)
|
||||
|
||||
final_usage = None
|
||||
|
||||
108
routstr/upstream/count_tokens.py
Normal file
108
routstr/upstream/count_tokens.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Local handling of Anthropic ``/v1/messages/count_tokens`` for upstreams
|
||||
that do not natively expose the endpoint.
|
||||
|
||||
Most non-Anthropic upstreams (OpenAI-compat, Gemini OpenAI-compat,
|
||||
OpenRouter chat-completions, generic providers) return 400/404 when asked
|
||||
to ``POST /messages/count_tokens``. Claude Code and other Anthropic SDK
|
||||
clients call this endpoint before each turn to size context windows and
|
||||
trigger compaction, so a failure breaks the whole chat.
|
||||
|
||||
We answer locally. ``litellm.token_counter`` understands the Anthropic
|
||||
message shape and the per-model tokenizers, so we prefer it. If it raises
|
||||
(unknown model, encoding lookup failure, ...), we fall back to the
|
||||
project's own ``estimate_tokens`` heuristic, which is always defined and
|
||||
never raises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..core import get_logger
|
||||
from ..payment.helpers import estimate_tokens
|
||||
from ..payment.models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _parse_request_body(request_body: bytes | None) -> dict[str, Any]:
|
||||
if not request_body:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(request_body)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
messages = []
|
||||
|
||||
system = body.get("system")
|
||||
if isinstance(system, str) and system:
|
||||
messages = [{"role": "system", "content": system}, *messages]
|
||||
elif isinstance(system, list):
|
||||
text = "".join(
|
||||
block.get("text", "")
|
||||
for block in system
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
if text:
|
||||
messages = [{"role": "system", "content": text}, *messages]
|
||||
|
||||
tools = body.get("tools") if isinstance(body.get("tools"), list) else None
|
||||
|
||||
return int(
|
||||
litellm.token_counter(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def count_tokens_locally(
|
||||
request_body: bytes | None,
|
||||
model_obj: Model | None,
|
||||
) -> Response:
|
||||
"""Return an Anthropic-compatible count_tokens response without
|
||||
touching the upstream. Always returns 200; never raises."""
|
||||
body = _parse_request_body(request_body)
|
||||
|
||||
model_name = ""
|
||||
if model_obj is not None:
|
||||
model_name = model_obj.forwarded_model_id or model_obj.id or ""
|
||||
if not model_name:
|
||||
body_model = body.get("model")
|
||||
if isinstance(body_model, str):
|
||||
model_name = body_model
|
||||
|
||||
input_tokens: int
|
||||
try:
|
||||
input_tokens = _count_with_litellm(model_name, body)
|
||||
except Exception as exc:
|
||||
messages = body.get("messages")
|
||||
fallback_messages = messages if isinstance(messages, list) else []
|
||||
input_tokens = estimate_tokens(fallback_messages)
|
||||
logger.debug(
|
||||
"litellm token_counter failed; using local estimator",
|
||||
extra={
|
||||
"model": model_name,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"estimated_tokens": input_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
payload = {"input_tokens": max(0, int(input_tokens))}
|
||||
return Response(
|
||||
content=json.dumps(payload).encode(),
|
||||
status_code=200,
|
||||
media_type="application/json",
|
||||
)
|
||||
@@ -12,6 +12,7 @@ class FireworksUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "fireworks"
|
||||
default_base_url = "https://api.fireworks.ai/inference/v1"
|
||||
platform_url = "https://app.fireworks.ai/settings/users/api-keys"
|
||||
litellm_provider_prefix = "fireworks_ai/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from . import gemini_messages
|
||||
from .base import BaseUpstreamProvider
|
||||
from .clients.gemini import GeminiClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
@@ -20,9 +16,19 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Gemini provider — proxies through Gemini's OpenAI-compat surface.
|
||||
|
||||
The chat-completions, embeddings, and models paths all flow through
|
||||
:meth:`BaseUpstreamProvider.forward_request`; we only override
|
||||
``get_request_base_url`` to redirect to ``{base}/openai/...`` and
|
||||
``_dispatch_anthropic_messages`` to inject thought-signatures on the
|
||||
/v1/messages path (see :mod:`gemini_messages` for that rationale).
|
||||
"""
|
||||
|
||||
provider_type = "gemini"
|
||||
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||
platform_url = "https://aistudio.google.com/app/apikey"
|
||||
litellm_provider_prefix = "gemini/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -39,7 +45,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
@property
|
||||
def client(self) -> GeminiClient:
|
||||
"""Get or create the Gemini API client."""
|
||||
"""Get or create the Gemini API client (used for the models listing)."""
|
||||
if self._client is None:
|
||||
self._client = GeminiClient(api_key=self.api_key)
|
||||
return self._client
|
||||
@@ -65,248 +71,66 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("gemini/")
|
||||
"""Reduce a routstr model id to the bare upstream Gemini name.
|
||||
|
||||
async def forward_request(
|
||||
Gemini's OpenAI-compat surface expects the literal model id
|
||||
(e.g. ``gemini-3.1-flash-lite-preview``) — no ``gemini/`` provider
|
||||
prefix and no ``google/`` vendor sub-prefix. Take the last path
|
||||
segment so we tolerate any of:
|
||||
|
||||
``gemini-2.0-flash``
|
||||
``gemini/gemini-2.0-flash``
|
||||
``gemini/google/gemini-3.1-flash-lite-preview``
|
||||
"""
|
||||
return model_id.rsplit("/", 1)[-1]
|
||||
|
||||
@property
|
||||
def compat_base_url(self) -> str:
|
||||
"""Gemini's OpenAI-compat surface, regardless of what's stored.
|
||||
|
||||
Stored ``base_url`` may be ``.../v1beta`` (the native Gemini API
|
||||
root) or ``.../v1beta/openai`` (already pointed at the compat
|
||||
surface). Normalize to the latter.
|
||||
"""
|
||||
return self.base_url.rstrip("/").removesuffix("/openai") + "/openai"
|
||||
|
||||
def get_request_base_url(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Route every proxied request to the OpenAI-compat surface.
|
||||
|
||||
Required because the stored ``base_url`` typically points at the
|
||||
native Gemini API (``/v1beta``), but :meth:`forward_request`
|
||||
forwards OpenAI-shaped paths (``/chat/completions``,
|
||||
``/embeddings``, ``/models``) which only exist under the
|
||||
``/openai`` subtree.
|
||||
"""
|
||||
return self.compat_base_url
|
||||
|
||||
async def _dispatch_anthropic_messages(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
# Remove provider prefix from model ID for Gemini API
|
||||
if "/" in model_obj.id:
|
||||
model_obj.id = model_obj.id.split("/", 1)[1]
|
||||
model_obj: "Model",
|
||||
*,
|
||||
log_extra: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, Any, str | None]:
|
||||
"""Dispatch /v1/messages via the gemini-specific httpx path.
|
||||
|
||||
if not path.startswith("chat/completions"):
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if not request_body:
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
try:
|
||||
openai_data = json.loads(request_body)
|
||||
messages = openai_data.get("messages", [])
|
||||
temperature = openai_data.get("temperature")
|
||||
max_tokens = openai_data.get("max_tokens")
|
||||
top_p = openai_data.get("top_p")
|
||||
is_streaming = openai_data.get("stream", False)
|
||||
|
||||
logger.info(
|
||||
"Processing Gemini request with client abstraction",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"is_streaming": is_streaming,
|
||||
"message_count": len(messages),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
final_usage_data: dict | None = None
|
||||
|
||||
def usage_callback(usage_data: dict[str, Any]) -> None:
|
||||
"""Callback to capture usage data during streaming"""
|
||||
nonlocal final_usage_data
|
||||
final_usage_data = usage_data
|
||||
|
||||
async def completion_callback(
|
||||
model: str, usage_data: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Callback to handle payment when streaming completes"""
|
||||
nonlocal final_usage_data
|
||||
if usage_data:
|
||||
final_usage_data = usage_data
|
||||
|
||||
payment_data = {
|
||||
"model": model,
|
||||
"usage": final_usage_data,
|
||||
}
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||
if fresh_key:
|
||||
try:
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
payment_data,
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini streaming payment finalized",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"usage_data": final_usage_data,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
response_generator = self.client.generate_content_stream(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
usage_callback=usage_callback,
|
||||
completion_callback=completion_callback,
|
||||
)
|
||||
|
||||
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
||||
payment_finalized = False
|
||||
|
||||
async def finalize_payment() -> None:
|
||||
nonlocal payment_finalized
|
||||
if payment_finalized:
|
||||
return
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(
|
||||
key.__class__, key.hashed_key
|
||||
)
|
||||
if fresh_key:
|
||||
try:
|
||||
await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
{
|
||||
"model": model_obj.id,
|
||||
"usage": final_usage_data,
|
||||
},
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
payment_finalized = True
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment in fallback",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
async for chunk in response_generator:
|
||||
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
||||
yield sse_data.encode()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini streaming response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not payment_finalized:
|
||||
await finalize_payment()
|
||||
|
||||
return StreamingResponse(
|
||||
stream_with_cost(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
else:
|
||||
openai_format_response = await self.client.generate_content(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
)
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
await session.refresh(key)
|
||||
remaining_balance_msats = key.balance
|
||||
openai_format_response["cost"] = cost_data
|
||||
openai_format_response["cost"]["sats_cost"] = (
|
||||
cost_data.get("total_msats", 0) // 1000
|
||||
)
|
||||
openai_format_response["cost"]["remaining_balance_msats"] = (
|
||||
remaining_balance_msats
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"model": model_obj.id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(openai_format_response),
|
||||
media_type="application/json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini forward_request",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
See :mod:`routstr.upstream.gemini_messages` for the full rationale
|
||||
(thought-signature injection, why litellm + the openai SDK can't
|
||||
carry the required ``extra_content`` field).
|
||||
"""
|
||||
return await gemini_messages.dispatch_gemini_messages(
|
||||
request_body=request_body,
|
||||
model_obj=model_obj,
|
||||
base_url=self.compat_base_url,
|
||||
api_key=self.api_key,
|
||||
transform_model_name=self.transform_model_name,
|
||||
log_extra=log_extra,
|
||||
)
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from Gemini API."""
|
||||
"""Fetch models from Gemini API via the OpenAI-compat client."""
|
||||
try:
|
||||
models_data = await self.client.list_models()
|
||||
|
||||
|
||||
480
routstr/upstream/gemini_messages.py
Normal file
480
routstr/upstream/gemini_messages.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""Custom /v1/messages dispatcher for Gemini's OpenAI-compat endpoint.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
|
||||
Gemini 2.5 / 3 thinking models reject inbound ``functionCall`` parts that
|
||||
lack a ``thought_signature`` field once any prior turn in the conversation
|
||||
contains a function call. Anthropic-Messages clients (Claude Code etc.)
|
||||
have no concept of thought signatures, so multi-turn tool conversations
|
||||
fail with::
|
||||
|
||||
Function call is missing a thought_signature in functionCall parts.
|
||||
|
||||
Google's published escape hatch (https://ai.google.dev/gemini-api/docs/
|
||||
thought-signatures, FAQ #1) is the dummy signature
|
||||
``"skip_thought_signature_validator"`` placed at
|
||||
``tool_calls[i].extra_content.google.thought_signature`` for every tool
|
||||
call in the request. The hatch is documented specifically for
|
||||
"transferring a trace from a different model that does not include thought
|
||||
signatures" — exactly our case.
|
||||
|
||||
Why we can't reach the wire via litellm
|
||||
---------------------------------------
|
||||
|
||||
``litellm.anthropic.messages.acreate`` flows through the openai SDK, whose
|
||||
pydantic ``ChatCompletionMessageToolCall`` model silently drops unknown
|
||||
fields like ``extra_content``. Litellm has no openai-compat translator
|
||||
that emits ``extra_content.google.thought_signature``. So we bypass both
|
||||
litellm and the openai SDK at the transport layer.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
|
||||
1. Translate Anthropic body → OpenAI body via litellm's
|
||||
``AnthropicAdapter`` (the same translator
|
||||
``litellm.anthropic.messages.acreate`` uses internally).
|
||||
2. Inject ``extra_content.google.thought_signature`` on every
|
||||
``tool_calls[]`` entry.
|
||||
3. Set ``reasoning_effort="none"`` to disable Gemini's thinking pass.
|
||||
4. POST directly to ``{base_url}/chat/completions`` with ``stream=true``
|
||||
via ``httpx`` (preserves arbitrary fields verbatim).
|
||||
5. Translate OpenAI streaming chunks → Anthropic SSE events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.exceptions import UpstreamError
|
||||
from ..payment.models import Model
|
||||
from .messages_dispatch import (
|
||||
ANTHROPIC_ONLY_FIELDS,
|
||||
aggregate_anthropic_events_to_message,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
||||
|
||||
# Mapping: OpenAI finish_reason → Anthropic stop_reason
|
||||
_FINISH_TO_STOP = {
|
||||
"stop": "end_turn",
|
||||
"length": "max_tokens",
|
||||
"tool_calls": "tool_use",
|
||||
"function_call": "tool_use",
|
||||
"content_filter": "refusal",
|
||||
}
|
||||
|
||||
|
||||
def inject_thought_signatures(messages: list[dict]) -> None:
|
||||
"""Add ``extra_content.google.thought_signature`` to every tool_call.
|
||||
|
||||
Mutates ``messages`` in place. Idempotent: existing signatures are not
|
||||
overwritten.
|
||||
"""
|
||||
for msg in messages:
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if not isinstance(tool_calls, list):
|
||||
continue
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
extra = tc.get("extra_content")
|
||||
if not isinstance(extra, dict):
|
||||
extra = tc["extra_content"] = {}
|
||||
google_cfg = extra.get("google")
|
||||
if not isinstance(google_cfg, dict):
|
||||
google_cfg = extra["google"] = {}
|
||||
google_cfg.setdefault("thought_signature", DUMMY_THOUGHT_SIGNATURE)
|
||||
|
||||
|
||||
def _translate_anthropic_to_openai(body: dict, model: str) -> dict:
|
||||
"""Use litellm's translator to convert an Anthropic /messages body to
|
||||
OpenAI /chat/completions kwargs.
|
||||
|
||||
Imported lazily because the litellm internal path is heavy and not
|
||||
needed for any other code path in routstr.
|
||||
"""
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( # noqa: E501
|
||||
AnthropicAdapter,
|
||||
)
|
||||
|
||||
kwargs = {"model": model, **body}
|
||||
translated = AnthropicAdapter().translate_completion_input_params(kwargs)
|
||||
if translated is None:
|
||||
raise UpstreamError(
|
||||
"Failed to translate Anthropic body to OpenAI format",
|
||||
status_code=500,
|
||||
)
|
||||
return dict(translated)
|
||||
|
||||
|
||||
def _sse_event(event_type: str, payload: dict) -> bytes:
|
||||
return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n".encode()
|
||||
|
||||
|
||||
async def _openai_chunks_to_anthropic_events(
|
||||
line_iter: AsyncIterator[str], requested_model: str | None
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Translate an OpenAI chat-completions SSE byte stream into the
|
||||
Anthropic-Messages SSE event sequence.
|
||||
|
||||
Maintains per-chunk state across:
|
||||
|
||||
* one optional text content block (lazy-opened on first text delta)
|
||||
* any number of tool_use blocks indexed by openai's ``delta.tool_calls[].index``
|
||||
* final ``stop_reason`` / ``usage`` carried out via ``message_delta`` /
|
||||
``message_stop``
|
||||
"""
|
||||
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
|
||||
started = False
|
||||
text_block_idx: int | None = None
|
||||
tool_block_indices: dict[int, int] = {}
|
||||
next_block_idx = 0
|
||||
final_finish_reason: str | None = None
|
||||
final_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
|
||||
def open_text_block() -> bytes:
|
||||
nonlocal text_block_idx, next_block_idx
|
||||
text_block_idx = next_block_idx
|
||||
next_block_idx += 1
|
||||
return _sse_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": text_block_idx,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
)
|
||||
|
||||
def open_tool_block(delta_idx: int, tc: dict) -> bytes:
|
||||
nonlocal next_block_idx
|
||||
block_idx = next_block_idx
|
||||
next_block_idx += 1
|
||||
tool_block_indices[delta_idx] = block_idx
|
||||
fn = tc.get("function") or {}
|
||||
return _sse_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": block_idx,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:24]}",
|
||||
"name": fn.get("name") or "",
|
||||
"input": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def close_block(idx: int) -> bytes:
|
||||
return _sse_event(
|
||||
"content_block_stop",
|
||||
{"type": "content_block_stop", "index": idx},
|
||||
)
|
||||
|
||||
async for raw_line in line_iter:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload = line[5:].lstrip()
|
||||
if not payload or payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
|
||||
if not started:
|
||||
started = True
|
||||
yield _sse_event(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": chunk.get("id") or msg_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": requested_model or chunk.get("model") or "",
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
usage = chunk.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
in_tok = usage.get("prompt_tokens") or usage.get("input_tokens") or 0
|
||||
out_tok = usage.get("completion_tokens") or usage.get("output_tokens") or 0
|
||||
if in_tok:
|
||||
final_usage["input_tokens"] = int(in_tok)
|
||||
if out_tok:
|
||||
final_usage["output_tokens"] = int(out_tok)
|
||||
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
choice = choices[0] if isinstance(choices[0], dict) else {}
|
||||
delta = choice.get("delta") or {}
|
||||
if not isinstance(delta, dict):
|
||||
delta = {}
|
||||
|
||||
# Text delta
|
||||
text = delta.get("content")
|
||||
if isinstance(text, str) and text:
|
||||
if text_block_idx is None:
|
||||
yield open_text_block()
|
||||
yield _sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": text_block_idx,
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
},
|
||||
)
|
||||
|
||||
# Tool call deltas
|
||||
tool_calls_delta = delta.get("tool_calls")
|
||||
if isinstance(tool_calls_delta, list):
|
||||
for tc in tool_calls_delta:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
d_idx = int(tc.get("index") or 0)
|
||||
if d_idx not in tool_block_indices:
|
||||
yield open_tool_block(d_idx, tc)
|
||||
block_idx = tool_block_indices[d_idx]
|
||||
fn = tc.get("function") or {}
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str) and args:
|
||||
yield _sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": block_idx,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
finish = choice.get("finish_reason")
|
||||
if finish:
|
||||
final_finish_reason = finish
|
||||
|
||||
# Close any open content blocks
|
||||
if text_block_idx is not None:
|
||||
yield close_block(text_block_idx)
|
||||
for block_idx in tool_block_indices.values():
|
||||
yield close_block(block_idx)
|
||||
|
||||
# message_delta with stop_reason and usage
|
||||
stop_reason = _FINISH_TO_STOP.get(final_finish_reason or "", "end_turn")
|
||||
yield _sse_event(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
||||
"usage": final_usage,
|
||||
},
|
||||
)
|
||||
yield _sse_event("message_stop", {"type": "message_stop"})
|
||||
|
||||
|
||||
async def _post_and_stream(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
payload: dict,
|
||||
log_extra: dict[str, Any] | None,
|
||||
) -> tuple[httpx.AsyncClient, httpx.Response]:
|
||||
"""POST to upstream chat-completions and return (client, response) for
|
||||
streaming. Caller is responsible for closing both."""
|
||||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(120.0, read=120.0))
|
||||
try:
|
||||
request = client.build_request(
|
||||
"POST",
|
||||
url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
)
|
||||
response = await client.send(request, stream=True)
|
||||
except Exception as exc:
|
||||
await client.aclose()
|
||||
logger.error(
|
||||
"Gemini messages dispatch HTTP error",
|
||||
extra={"error": str(exc), "url": url, **(log_extra or {})},
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Failed to reach Gemini upstream: {exc}", status_code=502
|
||||
) from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
body_bytes = await response.aread()
|
||||
finally:
|
||||
await response.aclose()
|
||||
await client.aclose()
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
logger.error(
|
||||
"Gemini messages dispatch upstream error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": body_text[:1000],
|
||||
"url": url,
|
||||
**(log_extra or {}),
|
||||
},
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Upstream error via gemini compat: {body_text}",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return client, response
|
||||
|
||||
|
||||
async def dispatch_gemini_messages(
|
||||
*,
|
||||
request_body: bytes | None,
|
||||
model_obj: Model,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
transform_model_name: Callable[[str], str],
|
||||
log_extra: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, Any, str | None]:
|
||||
"""Dispatch a /v1/messages request to Gemini's OpenAI-compat endpoint
|
||||
with thought-signature injection.
|
||||
|
||||
Returns ``(client_stream, result, requested_model)`` where ``result``
|
||||
is either an ``AsyncIterator[bytes]`` of Anthropic-format SSE events
|
||||
(for streaming clients) or an Anthropic Message dict (after the caller
|
||||
aggregates).
|
||||
"""
|
||||
if not request_body:
|
||||
raise UpstreamError(
|
||||
"Missing request body for /v1/messages", status_code=400
|
||||
)
|
||||
|
||||
try:
|
||||
body: dict = json.loads(request_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise UpstreamError(
|
||||
f"Invalid JSON in /v1/messages body: {exc}", status_code=400
|
||||
) from exc
|
||||
|
||||
body.pop("model", None)
|
||||
client_stream = bool(body.pop("stream", False))
|
||||
|
||||
# Anthropic-Messages-only fields that don't translate to OpenAI
|
||||
# Chat Completions. litellm's translator passes through unknown
|
||||
# top-level fields verbatim and Gemini's compat surface 400s on
|
||||
# unknown names like ``context_management`` / ``output_config``.
|
||||
dropped: dict[str, Any] = {}
|
||||
for field in ANTHROPIC_ONLY_FIELDS:
|
||||
if field in body:
|
||||
dropped[field] = body.pop(field)
|
||||
if dropped:
|
||||
logger.debug(
|
||||
"Dropped anthropic-only fields before gemini compat dispatch",
|
||||
extra={"dropped_keys": sorted(dropped.keys())},
|
||||
)
|
||||
|
||||
requested_model = (
|
||||
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
|
||||
)
|
||||
upstream_model = transform_model_name(model_obj.id)
|
||||
|
||||
openai_kwargs = _translate_anthropic_to_openai(body, upstream_model)
|
||||
|
||||
messages = openai_kwargs.get("messages") or []
|
||||
if isinstance(messages, list):
|
||||
inject_thought_signatures(messages)
|
||||
|
||||
# Disable Gemini's thinking pass; the dummy signature already lifts
|
||||
# validation, but skipping thinking entirely avoids degraded model
|
||||
# output and keeps tool-calling deterministic.
|
||||
openai_kwargs.setdefault("reasoning_effort", "none")
|
||||
openai_kwargs["stream"] = True
|
||||
openai_kwargs["model"] = upstream_model
|
||||
# OpenAI-compat backends (including Gemini's) only emit a final
|
||||
# ``usage`` chunk when the request opts in via this flag. Without it
|
||||
# the cost-calculation pipeline can't read real token counts and
|
||||
# falls back to MaxCostData billing.
|
||||
existing_stream_options = openai_kwargs.get("stream_options")
|
||||
merged_stream_options = (
|
||||
dict(existing_stream_options)
|
||||
if isinstance(existing_stream_options, dict)
|
||||
else {}
|
||||
)
|
||||
merged_stream_options.setdefault("include_usage", True)
|
||||
openai_kwargs["stream_options"] = merged_stream_options
|
||||
|
||||
logger.info(
|
||||
"Dispatching /v1/messages via gemini compat (httpx)",
|
||||
extra={
|
||||
"model": upstream_model,
|
||||
"client_stream": client_stream,
|
||||
"messages_with_tool_calls": sum(
|
||||
1 for m in messages if isinstance(m, dict) and m.get("tool_calls")
|
||||
),
|
||||
**(log_extra or {}),
|
||||
},
|
||||
)
|
||||
|
||||
http_client, response = await _post_and_stream(
|
||||
base_url, api_key, openai_kwargs, log_extra
|
||||
)
|
||||
|
||||
async def line_iter() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
finally:
|
||||
await response.aclose()
|
||||
await http_client.aclose()
|
||||
|
||||
anthropic_event_iter = _openai_chunks_to_anthropic_events(
|
||||
line_iter(), requested_model
|
||||
)
|
||||
|
||||
if not client_stream:
|
||||
# Aggregate the Anthropic SSE byte stream into a single Message dict
|
||||
# so the rest of the pipeline (cost calc, metadata injection,
|
||||
# response building) can treat it identically to a non-streaming
|
||||
# litellm response.
|
||||
try:
|
||||
aggregated = await aggregate_anthropic_events_to_message(
|
||||
anthropic_event_iter
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to aggregate Gemini compat events into message",
|
||||
extra={"error": str(exc), **(log_extra or {})},
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Failed to aggregate upstream stream: {exc}",
|
||||
status_code=502,
|
||||
) from exc
|
||||
return client_stream, aggregated, requested_model
|
||||
|
||||
return client_stream, anthropic_event_iter, requested_model
|
||||
@@ -12,6 +12,7 @@ class GroqUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "groq"
|
||||
default_base_url = "https://api.groq.com/openai/v1"
|
||||
platform_url = "https://console.groq.com/keys"
|
||||
litellm_provider_prefix = "groq/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.settings import Settings
|
||||
@@ -122,12 +122,16 @@ async def get_all_models_with_overrides(
|
||||
|
||||
|
||||
async def refresh_upstreams_models_periodically(
|
||||
upstreams: list[BaseUpstreamProvider],
|
||||
upstreams_provider: (
|
||||
Callable[[], list[BaseUpstreamProvider]] | list[BaseUpstreamProvider]
|
||||
),
|
||||
) -> None:
|
||||
"""Background task to periodically refresh models cache for all providers.
|
||||
|
||||
Args:
|
||||
upstreams: List of upstream provider instances
|
||||
upstreams_provider: Either a callable returning the live upstream list
|
||||
(preferred — picks up providers added/changed via reinitialize_upstreams),
|
||||
or a static list (legacy, will go stale after reinitialize_upstreams).
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
@@ -139,9 +143,14 @@ async def refresh_upstreams_models_periodically(
|
||||
logger.info("Provider models refresh disabled (interval <= 0)")
|
||||
return
|
||||
|
||||
def _resolve_upstreams() -> list[BaseUpstreamProvider]:
|
||||
if callable(upstreams_provider):
|
||||
return upstreams_provider()
|
||||
return upstreams_provider
|
||||
|
||||
while True:
|
||||
try:
|
||||
for upstream in upstreams:
|
||||
for upstream in _resolve_upstreams():
|
||||
try:
|
||||
await upstream.refresh_models_cache()
|
||||
except Exception as e:
|
||||
@@ -188,13 +197,14 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
existing_providers = result.all()
|
||||
|
||||
if not existing_providers:
|
||||
logger.info(
|
||||
"No upstream providers found in database, seeding from settings"
|
||||
)
|
||||
await _seed_providers_from_settings(session, settings)
|
||||
await session.commit()
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
if existing_providers:
|
||||
logger.info(
|
||||
f"Seeded {len(existing_providers)} upstream providers from settings"
|
||||
)
|
||||
|
||||
async def _init_single_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
|
||||
162
routstr/upstream/litellm_routing.py
Normal file
162
routstr/upstream/litellm_routing.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Map an upstream `base_url` to the correct litellm provider prefix.
|
||||
|
||||
Used by `BaseUpstreamProvider.get_litellm_provider_prefix` so that custom /
|
||||
generic provider rows (which inherit the base class default) get routed to
|
||||
the right litellm backend instead of falling back to `openai/`.
|
||||
|
||||
The table is compiled from litellm 1.74's
|
||||
`litellm/litellm_core_utils/get_llm_provider_logic.py` (the
|
||||
`openai_compatible_endpoints` table) plus the providers documented at
|
||||
https://docs.litellm.ai/docs/providers. Substring match is used so that
|
||||
URLs with paths, ports, regional subdomains, etc. all resolve correctly.
|
||||
|
||||
Order matters: more specific needles must appear before more generic ones
|
||||
(e.g. ``openai.azure.com`` before ``api.openai.com``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import litellm
|
||||
|
||||
DEFAULT_PREFIX = "openai/"
|
||||
|
||||
LITELLM_HOST_PREFIX_MAP: tuple[tuple[str, str], ...] = (
|
||||
# Azure must win over api.openai.com because the host ends with
|
||||
# `openai.azure.com` and we don't want it picked up as plain OpenAI.
|
||||
("openai.azure.com", "azure/"),
|
||||
# Google
|
||||
("generativelanguage.googleapis.com", "gemini/"),
|
||||
("aiplatform.googleapis.com", "vertex_ai/"),
|
||||
# First-class providers with native litellm prefixes
|
||||
("api.openai.com", "openai/"),
|
||||
("api.anthropic.com", "anthropic/"),
|
||||
("api.groq.com", "groq/"),
|
||||
("api.fireworks.ai", "fireworks_ai/"),
|
||||
("api.x.ai", "xai/"),
|
||||
("api.perplexity.ai", "perplexity/"),
|
||||
("openrouter.ai", "openrouter/"),
|
||||
("api.deepseek.com", "deepseek/"),
|
||||
("api.together.xyz", "together_ai/"),
|
||||
("codestral.mistral.ai", "codestral/"),
|
||||
("api.mistral.ai", "mistral/"),
|
||||
("api.cohere.com", "cohere_chat/"),
|
||||
("api.cohere.ai", "cohere_chat/"),
|
||||
("api.deepinfra.com", "deepinfra/"),
|
||||
("api.endpoints.anyscale.com", "anyscale/"),
|
||||
("api.cerebras.ai", "cerebras/"),
|
||||
("inference.baseten.co", "baseten/"),
|
||||
("api.sambanova.ai", "sambanova/"),
|
||||
("api.ai21.com", "ai21_chat/"),
|
||||
("api.friendli.ai", "friendliai/"),
|
||||
("api.galadriel.com", "galadriel/"),
|
||||
("api.llama.com", "meta_llama/"),
|
||||
("api.featherless.ai", "featherless_ai/"),
|
||||
("inference.api.nscale.com", "nscale/"),
|
||||
("dashscope-intl.aliyuncs.com", "dashscope/"),
|
||||
("api.moonshot.ai", "moonshot/"),
|
||||
("api.moonshot.cn", "moonshot/"),
|
||||
("api.minimax.io", "minimax/"),
|
||||
("api.minimaxi.com", "minimax/"),
|
||||
("platform.publicai.co", "publicai/"),
|
||||
("api.synthetic.new", "synthetic/"),
|
||||
("api.stima.tech", "apertis/"),
|
||||
("nano-gpt.com", "nano-gpt/"),
|
||||
("api.poe.com", "poe/"),
|
||||
("llm.chutes.ai", "chutes/"),
|
||||
("api.v0.dev", "v0/"),
|
||||
("api.lambda.ai", "lambda_ai/"),
|
||||
("api.hyperbolic.xyz", "hyperbolic/"),
|
||||
("ai-gateway.vercel.sh", "vercel_ai_gateway/"),
|
||||
("api.inference.wandb.ai", "wandb/"),
|
||||
("integrate.api.nvidia.com", "nvidia_nim/"),
|
||||
("api.studio.nebius.com", "nebius/"),
|
||||
("api.novita.ai", "novita/"),
|
||||
("ark.cn-beijing.volces.com", "volcengine/"),
|
||||
("api.voyageai.com", "voyage/"),
|
||||
("api.jina.ai", "jina_ai/"),
|
||||
("api.aimlapi.com", "aiml/"),
|
||||
("api.snowflakecomputing.com", "snowflake/"),
|
||||
("databricks.com", "databricks/"),
|
||||
("huggingface.co", "huggingface/"),
|
||||
)
|
||||
|
||||
# Substrings that indicate an Ollama deployment regardless of port/scheme.
|
||||
OLLAMA_HOST_HINTS: tuple[str, ...] = (
|
||||
"localhost:11434",
|
||||
"127.0.0.1:11434",
|
||||
"ollama",
|
||||
)
|
||||
|
||||
|
||||
def detect_litellm_prefix(
|
||||
base_url: str | None, default: str = DEFAULT_PREFIX
|
||||
) -> str:
|
||||
"""Return the litellm provider prefix (`"<provider>/"`) for `base_url`.
|
||||
|
||||
Falls back to `default` when the host doesn't match any known provider.
|
||||
The default is `openai/` because every unmatched OpenAI-compatible
|
||||
server is, by definition, an OpenAI-compatible server.
|
||||
"""
|
||||
if not base_url:
|
||||
return default
|
||||
|
||||
parsed = urlsplit(base_url)
|
||||
host = parsed.netloc.lower() or base_url.lower()
|
||||
|
||||
for needle, prefix in LITELLM_HOST_PREFIX_MAP:
|
||||
if needle in host:
|
||||
return prefix
|
||||
|
||||
if any(hint in host for hint in OLLAMA_HOST_HINTS):
|
||||
return "ollama_chat/"
|
||||
|
||||
return default
|
||||
|
||||
|
||||
_configured = False
|
||||
|
||||
|
||||
def configure_litellm() -> None:
|
||||
"""Apply litellm global settings used by the messages-dispatch path.
|
||||
|
||||
Idempotent: safe to call from both app startup and module-level
|
||||
initializers without side effects on the second invocation.
|
||||
|
||||
Settings applied:
|
||||
|
||||
* ``LITELLM_DEBUG=1`` enables litellm's verbose debug logger.
|
||||
* Forces the Anthropic-messages adapter to call OpenAI Chat Completions
|
||||
(POST ``/chat/completions``) instead of the Responses API (POST
|
||||
``/responses``) for ``openai/``-prefixed providers. OpenAI-compatible
|
||||
upstreams like Google's generativelanguage compat endpoint expose
|
||||
``/chat/completions`` but not ``/responses``, which would 404. Set
|
||||
``LITELLM_USE_RESPONSES_API_FOR_ANTHROPIC_MESSAGES=1`` to opt out.
|
||||
* Silently drops Anthropic-Messages-only parameters (``thinking``,
|
||||
``cache_control``, ``context_management``, ...) when translating to
|
||||
providers that don't accept them, instead of raising
|
||||
``UnsupportedParamsError``. Set ``LITELLM_STRICT_PARAMS=1`` to opt
|
||||
out.
|
||||
"""
|
||||
global _configured
|
||||
if _configured:
|
||||
return
|
||||
|
||||
if os.getenv("LITELLM_DEBUG") == "1":
|
||||
try:
|
||||
litellm._turn_on_debug() # type: ignore[no-untyped-call]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.getenv("LITELLM_USE_RESPONSES_API_FOR_ANTHROPIC_MESSAGES") != "1":
|
||||
try:
|
||||
litellm.use_chat_completions_url_for_anthropic_messages = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.getenv("LITELLM_STRICT_PARAMS") != "1":
|
||||
litellm.drop_params = True
|
||||
|
||||
_configured = True
|
||||
561
routstr/upstream/messages_dispatch.py
Normal file
561
routstr/upstream/messages_dispatch.py
Normal file
@@ -0,0 +1,561 @@
|
||||
"""Pure helpers for translating ``/v1/messages`` to upstream chat completions
|
||||
via litellm.
|
||||
|
||||
This module owns the litellm/Anthropic-Messages translation layer:
|
||||
|
||||
* SSE parsing (``parse_sse_blocks``, ``events_from_chunk``)
|
||||
* Payload coercion (``coerce_litellm_payload``)
|
||||
* Stream aggregation (``aggregate_anthropic_events_to_message``) — drains
|
||||
a streamed Anthropic event sequence into a single Message dict
|
||||
* Per-event annotation for streaming (``annotate_event``,
|
||||
``stream_annotated_events``) — handles the model-rewrite + token-tally
|
||||
bookkeeping shared by the bearer-key and x-cashu streaming paths
|
||||
* The dispatch entry point (``dispatch_anthropic_messages``)
|
||||
* Refund math (``compute_refund``)
|
||||
|
||||
Nothing in here touches ``BaseUpstreamProvider``; the thin instance methods
|
||||
on the provider class forward to these functions and only retain logic that
|
||||
genuinely needs ``self`` (cost adjustment, metadata injection, refund
|
||||
sending).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from typing import Any, Callable, NamedTuple, cast
|
||||
|
||||
import litellm
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.exceptions import UpstreamError
|
||||
from ..payment.models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Anthropic-Messages-only fields that don't translate to OpenAI
|
||||
# Chat Completions. ``litellm.drop_params`` only filters *known*
|
||||
# unsupported params; these newer/extension fields get passed through
|
||||
# verbatim and the upstream rejects them with a 400. Pop them here so the
|
||||
# request reaches the upstream cleanly.
|
||||
ANTHROPIC_ONLY_FIELDS: tuple[str, ...] = (
|
||||
"thinking",
|
||||
"cache_control",
|
||||
"context_management",
|
||||
"output_config",
|
||||
"mcp_servers",
|
||||
"service_tier",
|
||||
"anthropic_version",
|
||||
"anthropic_beta",
|
||||
)
|
||||
|
||||
|
||||
def coerce_litellm_payload(payload: object) -> dict:
|
||||
"""Convert a litellm event into a plain dict.
|
||||
|
||||
Non-streaming responses come back as Anthropic-shaped pydantic models
|
||||
or dicts. Streaming may yield raw bytes/str (SSE-encoded); those go
|
||||
through ``events_from_chunk`` instead, not here.
|
||||
"""
|
||||
if isinstance(payload, dict):
|
||||
return dict(payload)
|
||||
if hasattr(payload, "model_dump"):
|
||||
return cast(dict, payload.model_dump())
|
||||
raise TypeError(f"Cannot coerce {type(payload).__name__} to dict")
|
||||
|
||||
|
||||
def parse_sse_blocks(buffer: bytes) -> tuple[list[dict], bytes]:
|
||||
"""Parse complete SSE event blocks out of a byte buffer.
|
||||
|
||||
Returns (events, remaining_buffer). Events are JSON objects parsed from
|
||||
one or more ``data:`` lines per block. Comments, blank lines, and
|
||||
``[DONE]`` sentinels are ignored. A trailing partial block is preserved
|
||||
in remaining_buffer.
|
||||
"""
|
||||
events: list[dict] = []
|
||||
while True:
|
||||
sep = buffer.find(b"\n\n")
|
||||
if sep < 0:
|
||||
sep_rn = buffer.find(b"\r\n\r\n")
|
||||
if sep_rn < 0:
|
||||
break
|
||||
block = buffer[:sep_rn]
|
||||
buffer = buffer[sep_rn + 4 :]
|
||||
else:
|
||||
block = buffer[:sep]
|
||||
buffer = buffer[sep + 2 :]
|
||||
|
||||
data_lines: list[str] = []
|
||||
for raw_line in block.replace(b"\r\n", b"\n").split(b"\n"):
|
||||
line = raw_line.decode("utf-8", errors="replace")
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
if not data_lines:
|
||||
continue
|
||||
payload = "\n".join(data_lines).strip()
|
||||
if not payload or payload == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
events.append(obj)
|
||||
return events, buffer
|
||||
|
||||
|
||||
def events_from_chunk(
|
||||
chunk: object, sse_buffer: bytes
|
||||
) -> tuple[list[dict], bytes]:
|
||||
"""Normalize a stream chunk into one or more event dicts.
|
||||
|
||||
``litellm.anthropic.messages.acreate(stream=True)`` yields raw SSE
|
||||
bytes in practice; some adapters yield strings or typed events. Handle
|
||||
all three.
|
||||
"""
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
sse_buffer += bytes(chunk)
|
||||
events, sse_buffer = parse_sse_blocks(sse_buffer)
|
||||
return events, sse_buffer
|
||||
if isinstance(chunk, str):
|
||||
sse_buffer += chunk.encode("utf-8")
|
||||
events, sse_buffer = parse_sse_blocks(sse_buffer)
|
||||
return events, sse_buffer
|
||||
return [coerce_litellm_payload(chunk)], sse_buffer
|
||||
|
||||
|
||||
async def aggregate_anthropic_events_to_message(
|
||||
iterator: AsyncIterator[Any],
|
||||
) -> dict:
|
||||
"""Drain an Anthropic-Messages event iterator into a single Message dict.
|
||||
|
||||
Produces the shape ``litellm.anthropic.messages.acreate(stream=False)``
|
||||
would have returned. Used to transparently stream from upstream while
|
||||
still returning a non-streaming response to the client. Lets us
|
||||
sidestep upstream quirks (e.g. Fireworks rejects ``max_tokens > 4096``
|
||||
unless ``stream=true``) without leaking that into client-visible
|
||||
behavior.
|
||||
"""
|
||||
sse_buffer = b""
|
||||
message: dict = {}
|
||||
blocks: list[dict] = []
|
||||
partial_json: dict[int, str] = {}
|
||||
final_stop_reason: str | None = None
|
||||
final_stop_sequence: str | None = None
|
||||
final_usage: dict[str, Any] = {}
|
||||
final_model: str | None = None
|
||||
|
||||
async for chunk in iterator:
|
||||
events, sse_buffer = events_from_chunk(chunk, sse_buffer)
|
||||
for event in events:
|
||||
etype = event.get("type")
|
||||
if etype == "message_start":
|
||||
raw = event.get("message") or {}
|
||||
if isinstance(raw, dict):
|
||||
message = dict(raw)
|
||||
existing = message.get("content")
|
||||
blocks = list(existing) if isinstance(existing, list) else []
|
||||
usage = message.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
final_usage = dict(usage)
|
||||
if isinstance(message.get("model"), str):
|
||||
final_model = message["model"]
|
||||
elif etype == "content_block_start":
|
||||
idx = int(event.get("index") or 0)
|
||||
cb = event.get("content_block") or {}
|
||||
cb_dict = dict(cb) if isinstance(cb, dict) else {}
|
||||
while len(blocks) <= idx:
|
||||
blocks.append({})
|
||||
blocks[idx] = cb_dict
|
||||
elif etype == "content_block_delta":
|
||||
idx = int(event.get("index") or 0)
|
||||
if idx >= len(blocks):
|
||||
continue
|
||||
delta = event.get("delta") or {}
|
||||
if not isinstance(delta, dict):
|
||||
continue
|
||||
dtype = delta.get("type")
|
||||
block = blocks[idx]
|
||||
if dtype == "text_delta":
|
||||
block["text"] = (block.get("text") or "") + (
|
||||
delta.get("text") or ""
|
||||
)
|
||||
elif dtype == "input_json_delta":
|
||||
partial_json[idx] = partial_json.get(idx, "") + (
|
||||
delta.get("partial_json") or ""
|
||||
)
|
||||
elif dtype == "thinking_delta":
|
||||
block["thinking"] = (block.get("thinking") or "") + (
|
||||
delta.get("thinking") or ""
|
||||
)
|
||||
elif dtype == "signature_delta":
|
||||
block["signature"] = (block.get("signature") or "") + (
|
||||
delta.get("signature") or ""
|
||||
)
|
||||
elif etype == "content_block_stop":
|
||||
idx = int(event.get("index") or 0)
|
||||
raw_json = partial_json.pop(idx, None)
|
||||
if raw_json is not None and idx < len(blocks):
|
||||
try:
|
||||
blocks[idx]["input"] = (
|
||||
json.loads(raw_json) if raw_json else {}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
blocks[idx]["input"] = raw_json
|
||||
elif etype == "message_delta":
|
||||
delta = event.get("delta") or {}
|
||||
if isinstance(delta, dict):
|
||||
if "stop_reason" in delta:
|
||||
final_stop_reason = delta.get("stop_reason")
|
||||
if "stop_sequence" in delta:
|
||||
final_stop_sequence = delta.get("stop_sequence")
|
||||
usage = event.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
final_usage.update(usage)
|
||||
# message_stop: nothing to merge
|
||||
|
||||
if not message:
|
||||
# Upstream returned no message_start; expose what we can so the
|
||||
# client at least sees the assembled content.
|
||||
message = {
|
||||
"id": "",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
}
|
||||
|
||||
message["content"] = blocks
|
||||
if final_model and not message.get("model"):
|
||||
message["model"] = final_model
|
||||
if final_stop_reason is not None:
|
||||
message["stop_reason"] = final_stop_reason
|
||||
if final_stop_sequence is not None:
|
||||
message["stop_sequence"] = final_stop_sequence
|
||||
if final_usage:
|
||||
existing_usage = message.get("usage")
|
||||
merged = dict(existing_usage) if isinstance(existing_usage, dict) else {}
|
||||
merged.update(final_usage)
|
||||
message["usage"] = merged
|
||||
return message
|
||||
|
||||
|
||||
class AnnotatedEvent(NamedTuple):
|
||||
"""One Anthropic SSE event after model-rewrite + token-tally bookkeeping.
|
||||
|
||||
``sse_bytes`` is the wire-ready ``event:`` / ``data:`` block; the two
|
||||
streaming paths in ``BaseUpstreamProvider`` consume ``sse_bytes`` plus
|
||||
the tallies and only differ in whether they stream live or buffer
|
||||
first.
|
||||
|
||||
``cache_read_input_tokens`` and ``cache_creation_input_tokens`` are
|
||||
surfaced separately so the cost path can price them against the cache
|
||||
rate rather than fold them silently into the regular input bucket.
|
||||
|
||||
``total_cost`` / ``input_cost`` / ``output_cost`` carry any
|
||||
USD cost figures the upstream attached to this event (from
|
||||
``usage.cost``, ``usage.total_cost``, or ``usage.cost_details``) so the
|
||||
streaming paths can re-embed them in the rebuilt ``usage`` dict and let
|
||||
``calculate_cost`` convert directly USD→sats instead of falling back to
|
||||
token-based math.
|
||||
"""
|
||||
|
||||
event: dict
|
||||
sse_bytes: bytes
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_read_input_tokens: int
|
||||
cache_creation_input_tokens: int
|
||||
total_cost: float
|
||||
input_cost: float
|
||||
output_cost: float
|
||||
model: str | None
|
||||
|
||||
|
||||
def annotate_event(event: dict, requested_model: str | None) -> AnnotatedEvent:
|
||||
"""Rewrite ``model`` fields and extract per-event token / model info.
|
||||
|
||||
Mutates ``event`` in place when ``requested_model`` is set so the
|
||||
upstream's true model name doesn't leak to the client.
|
||||
"""
|
||||
if requested_model:
|
||||
msg = event.get("message")
|
||||
if isinstance(msg, dict) and "model" in msg:
|
||||
msg["model"] = requested_model
|
||||
if "model" in event:
|
||||
event["model"] = requested_model
|
||||
|
||||
in_tokens = 0
|
||||
out_tokens = 0
|
||||
cache_read_tokens = 0
|
||||
cache_create_tokens = 0
|
||||
total_cost = 0.0
|
||||
input_cost = 0.0
|
||||
output_cost = 0.0
|
||||
model: str | None = None
|
||||
|
||||
def _coerce_float(value: object) -> float:
|
||||
if value is None or isinstance(value, bool):
|
||||
return 0.0
|
||||
if not isinstance(value, (int, float, str)):
|
||||
return 0.0
|
||||
try:
|
||||
return max(0.0, float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _accumulate(usage: dict) -> None:
|
||||
nonlocal in_tokens, out_tokens, cache_read_tokens, cache_create_tokens
|
||||
nonlocal total_cost, input_cost, output_cost
|
||||
in_tokens += int(usage.get("input_tokens") or 0)
|
||||
out_tokens += int(usage.get("output_tokens") or 0)
|
||||
cache_read_tokens += int(usage.get("cache_read_input_tokens") or 0)
|
||||
cache_create_tokens += int(usage.get("cache_creation_input_tokens") or 0)
|
||||
total_cost += _coerce_float(usage.get("total_cost"))
|
||||
input_cost += _coerce_float(usage.get("input_cost"))
|
||||
output_cost += _coerce_float(usage.get("output_cost"))
|
||||
|
||||
msg_for_meta = event.get("message")
|
||||
if isinstance(msg_for_meta, dict):
|
||||
if msg_for_meta.get("model"):
|
||||
model = str(msg_for_meta["model"])
|
||||
usage = msg_for_meta.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
_accumulate(usage)
|
||||
|
||||
if isinstance(event.get("usage"), dict):
|
||||
_accumulate(event["usage"])
|
||||
|
||||
# Some upstreams (notably OpenRouter-style proxies) attach cost fields
|
||||
# directly at the event root rather than inside ``usage``.
|
||||
for field in ("total_cost", "cost"):
|
||||
total_cost = max(total_cost, _coerce_float(event.get(field)))
|
||||
input_cost = max(input_cost, _coerce_float(event.get("input_cost")))
|
||||
output_cost = max(output_cost, _coerce_float(event.get("output_cost")))
|
||||
root_cost_details = event.get("cost_details")
|
||||
if isinstance(root_cost_details, dict):
|
||||
total_cost = max(
|
||||
total_cost,
|
||||
_coerce_float(root_cost_details.get("total_cost")),
|
||||
)
|
||||
input_cost = max(
|
||||
input_cost,
|
||||
_coerce_float(root_cost_details.get("input_cost")),
|
||||
)
|
||||
output_cost = max(
|
||||
output_cost,
|
||||
_coerce_float(root_cost_details.get("output_cost")),
|
||||
)
|
||||
|
||||
event_type = str(event.get("type") or "")
|
||||
payload = json.dumps(event)
|
||||
if event_type:
|
||||
sse_bytes = f"event: {event_type}\ndata: {payload}\n\n".encode()
|
||||
else:
|
||||
sse_bytes = f"data: {payload}\n\n".encode()
|
||||
|
||||
return AnnotatedEvent(
|
||||
event,
|
||||
sse_bytes,
|
||||
in_tokens,
|
||||
out_tokens,
|
||||
cache_read_tokens,
|
||||
cache_create_tokens,
|
||||
total_cost,
|
||||
input_cost,
|
||||
output_cost,
|
||||
model,
|
||||
)
|
||||
|
||||
|
||||
async def stream_annotated_events(
|
||||
iterator: AsyncIterator[Any],
|
||||
requested_model: str | None,
|
||||
) -> AsyncGenerator[AnnotatedEvent, None]:
|
||||
"""Yield annotated, SSE-serialized events from a litellm stream.
|
||||
|
||||
Both streaming paths in ``BaseUpstreamProvider`` consume this; the only
|
||||
divergence between them — yield-as-you-go vs buffer-then-replay — stays
|
||||
in the caller.
|
||||
"""
|
||||
sse_buffer = b""
|
||||
async for chunk in iterator:
|
||||
events, sse_buffer = events_from_chunk(chunk, sse_buffer)
|
||||
for event in events:
|
||||
yield annotate_event(event, requested_model)
|
||||
|
||||
|
||||
def embed_usd_costs(
|
||||
usage: dict,
|
||||
total_cost: float,
|
||||
input_cost: float,
|
||||
output_cost: float,
|
||||
) -> None:
|
||||
"""Mutate ``usage`` so ``calculate_cost`` will pick up the USD totals.
|
||||
|
||||
Mirrors the upstream shape: when any USD figure is present, attach
|
||||
``cost`` (used by the simple-fallback branch in ``calculate_cost``) and
|
||||
a ``cost_details`` block (used by the preferred branch — also gives the
|
||||
input/output USD split when we have one).
|
||||
"""
|
||||
if total_cost <= 0 and input_cost <= 0 and output_cost <= 0:
|
||||
return
|
||||
|
||||
cost_details: dict[str, float] = {}
|
||||
effective_total = total_cost
|
||||
if effective_total <= 0 and (input_cost > 0 or output_cost > 0):
|
||||
effective_total = input_cost + output_cost
|
||||
|
||||
if effective_total > 0:
|
||||
cost_details["total_cost"] = effective_total
|
||||
usage["cost"] = effective_total
|
||||
if input_cost > 0:
|
||||
cost_details["input_cost"] = input_cost
|
||||
if output_cost > 0:
|
||||
cost_details["output_cost"] = output_cost
|
||||
if cost_details:
|
||||
usage["cost_details"] = cost_details
|
||||
|
||||
|
||||
def compute_refund(amount: int, unit: str, cost_msats: int) -> int:
|
||||
if unit == "msat":
|
||||
return amount - cost_msats
|
||||
if unit == "sat":
|
||||
return amount - (cost_msats + 999) // 1000
|
||||
raise ValueError(f"Invalid unit: {unit}")
|
||||
|
||||
|
||||
async def dispatch_anthropic_messages(
|
||||
*,
|
||||
request_body: bytes | None,
|
||||
model_obj: Model,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider_prefix: str,
|
||||
transform_model_name: Callable[[str], str],
|
||||
log_extra: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, Any, str | None]:
|
||||
"""Call ``litellm.anthropic.messages.acreate`` and return
|
||||
``(client_stream, result, requested_model)``.
|
||||
|
||||
Shared by the bearer-key and x-cashu paths. Raises :class:`UpstreamError`
|
||||
on bad input or upstream failure.
|
||||
"""
|
||||
if not request_body:
|
||||
raise UpstreamError(
|
||||
"Missing request body for /v1/messages", status_code=400
|
||||
)
|
||||
|
||||
try:
|
||||
body: dict = json.loads(request_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise UpstreamError(
|
||||
f"Invalid JSON in /v1/messages body: {exc}", status_code=400
|
||||
) from exc
|
||||
|
||||
body.pop("model", None)
|
||||
# `stream` here is what the **client** asked for. Upstream is always
|
||||
# streamed (see `upstream_stream` below); when the client asked for a
|
||||
# non-streaming response we drain and aggregate the events into a
|
||||
# single Anthropic Message dict before returning. This sidesteps
|
||||
# provider-specific non-streaming caps (e.g. Fireworks rejects
|
||||
# `max_tokens > 4096` unless `stream=true`).
|
||||
client_stream = bool(body.pop("stream", False))
|
||||
upstream_stream = True
|
||||
|
||||
dropped: dict[str, Any] = {}
|
||||
for field in ANTHROPIC_ONLY_FIELDS:
|
||||
if field in body:
|
||||
dropped[field] = body.pop(field)
|
||||
if dropped:
|
||||
logger.debug(
|
||||
"Dropped anthropic-only fields before litellm dispatch",
|
||||
extra={"dropped_keys": sorted(dropped.keys())},
|
||||
)
|
||||
|
||||
# Convention: `model.id` is the canonical upstream model name;
|
||||
# `forwarded_model_id` is the public alias the internal API exposes
|
||||
# and echoes back to the client.
|
||||
requested_model = (
|
||||
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
|
||||
)
|
||||
upstream_model = transform_model_name(model_obj.id)
|
||||
litellm_model = f"{provider_prefix}{upstream_model}"
|
||||
|
||||
kwargs: dict = {
|
||||
"model": litellm_model,
|
||||
"api_base": base_url,
|
||||
"api_key": api_key,
|
||||
"stream": upstream_stream,
|
||||
**body,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Dispatching /v1/messages via litellm",
|
||||
extra={
|
||||
"model": litellm_model,
|
||||
"resolved_provider": provider_prefix.rstrip("/"),
|
||||
"client_stream": client_stream,
|
||||
"upstream_stream": upstream_stream,
|
||||
**(log_extra or {}),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await litellm.anthropic.messages.acreate(**kwargs)
|
||||
except Exception as exc:
|
||||
exc_message = getattr(exc, "message", None) or str(exc) or repr(exc)
|
||||
exc_status = getattr(exc, "status_code", None)
|
||||
exc_response = getattr(exc, "response", None)
|
||||
response_text = None
|
||||
if exc_response is not None:
|
||||
try:
|
||||
response_text = getattr(exc_response, "text", str(exc_response))
|
||||
except Exception:
|
||||
response_text = "<unreadable>"
|
||||
logger.error(
|
||||
"litellm dispatch failed",
|
||||
extra={
|
||||
"error": exc_message,
|
||||
"error_type": type(exc).__name__,
|
||||
"status_code": exc_status,
|
||||
"llm_provider": getattr(exc, "llm_provider", None),
|
||||
"body": getattr(exc, "body", None),
|
||||
"response_text": response_text,
|
||||
"model": litellm_model,
|
||||
"api_base": base_url,
|
||||
},
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Upstream error via litellm: {exc_message}",
|
||||
status_code=exc_status if isinstance(exc_status, int) else 502,
|
||||
) from exc
|
||||
|
||||
if not client_stream and hasattr(result, "__aiter__"):
|
||||
# Client asked for a non-streaming response but we always stream
|
||||
# from upstream — drain the events into a single Anthropic Message
|
||||
# dict so the rest of the pipeline can treat it as if upstream had
|
||||
# returned non-streaming. Some litellm adapters return a
|
||||
# non-streaming dict even when ``stream=True``; in that case,
|
||||
# leave the result as-is.
|
||||
try:
|
||||
aggregated: Any = await aggregate_anthropic_events_to_message(
|
||||
cast(AsyncIterator[Any], result)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to aggregate streamed events into message",
|
||||
extra={
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"model": litellm_model,
|
||||
},
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Failed to aggregate upstream stream: {exc}",
|
||||
status_code=502,
|
||||
) from exc
|
||||
return client_stream, aggregated, requested_model
|
||||
|
||||
return client_stream, result, requested_model
|
||||
@@ -21,6 +21,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "ollama"
|
||||
default_base_url = "http://localhost:11434"
|
||||
platform_url = None
|
||||
litellm_provider_prefix = "ollama_chat/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -184,7 +185,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
self._models_by_id = {m.forwarded_model_id or m.id: m for m in self._models_cache}
|
||||
logger.info(
|
||||
f"Refreshed models cache for {self.base_url}",
|
||||
extra={"model_count": len(models)},
|
||||
|
||||
@@ -15,6 +15,35 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "openrouter"
|
||||
default_base_url = "https://openrouter.ai/api/v1"
|
||||
platform_url = "https://openrouter.ai/settings/keys"
|
||||
supports_anthropic_messages = True
|
||||
litellm_provider_prefix = "openrouter/"
|
||||
|
||||
def _apply_provider_field(self, response_json: object) -> None:
|
||||
"""Stamp the ``provider`` field for OpenRouter responses.
|
||||
|
||||
OpenRouter is a router, not the real serving provider, so a bare
|
||||
``"openrouter"`` value carries no useful information. Rules:
|
||||
|
||||
- Real upstream sub-provider (e.g. ``"GMICloud"``) -> ``"openrouter:GMICloud"``.
|
||||
- Missing sub-provider, or one that merely echoes ``"openrouter"`` ->
|
||||
``"unknown"``.
|
||||
- Idempotent: re-stamping never produces ``"openrouter:openrouter:..."``;
|
||||
the ``openrouter:`` prefix appears at most once.
|
||||
"""
|
||||
if not isinstance(response_json, dict):
|
||||
return
|
||||
provider_type = (self.provider_type or "").strip()
|
||||
existing = response_json.get("provider")
|
||||
sub = existing.strip() if isinstance(existing, str) else ""
|
||||
# Strip any already-applied "openrouter:" prefixes (idempotency).
|
||||
prefix = f"{provider_type}:"
|
||||
while sub.lower().startswith(prefix.lower()):
|
||||
sub = sub[len(prefix) :].strip()
|
||||
# No real sub-provider, or it just echoes our own router name.
|
||||
if not sub or sub.lower() == provider_type.lower():
|
||||
response_json["provider"] = "unknown"
|
||||
return
|
||||
response_json["provider"] = f"{provider_type}:{sub}"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.06):
|
||||
"""Initialize OpenRouter provider with API key.
|
||||
|
||||
@@ -13,6 +13,7 @@ class PerplexityUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "perplexity"
|
||||
default_base_url = "https://api.perplexity.ai/"
|
||||
platform_url = "https://www.perplexity.ai/account/api/keys"
|
||||
litellm_provider_prefix = "perplexity/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
@@ -16,18 +16,20 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PPQAIModelPricing(BaseModel):
|
||||
ui: dict[str, float]
|
||||
api: dict[str, float]
|
||||
ui: Optional[dict[str, float]] = None
|
||||
api: Optional[dict[str, float]] = None
|
||||
input_per_1M_tokens: Optional[float] = Field(None, alias="input_per_1M_tokens")
|
||||
output_per_1M_tokens: Optional[float] = Field(None, alias="output_per_1M_tokens")
|
||||
|
||||
|
||||
class PPQAIModel(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
provider: Optional[str] = None
|
||||
name: str
|
||||
created_at: int
|
||||
context_length: int
|
||||
pricing: PPQAIModelPricing
|
||||
popular: bool
|
||||
popular: bool = False
|
||||
|
||||
|
||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
@@ -134,31 +136,54 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
if or_model:
|
||||
if input_price := ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
):
|
||||
input_price = None
|
||||
if ppqai_model.pricing.api:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
)
|
||||
elif ppqai_model.pricing.input_per_1M_tokens:
|
||||
input_price = ppqai_model.pricing.input_per_1M_tokens
|
||||
|
||||
if input_price is not None:
|
||||
or_model.pricing.prompt = input_price / 1_000_000
|
||||
if output_price := ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
):
|
||||
|
||||
output_price = None
|
||||
if ppqai_model.pricing.api:
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
)
|
||||
elif ppqai_model.pricing.output_per_1M_tokens:
|
||||
output_price = ppqai_model.pricing.output_per_1M_tokens
|
||||
|
||||
if output_price is not None:
|
||||
or_model.pricing.completion = output_price / 1_000_000
|
||||
|
||||
if cl := ppqai_model.context_length:
|
||||
or_model.context_length = cl
|
||||
models.append(or_model)
|
||||
else:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
input_price = 0.0
|
||||
if ppqai_model.pricing.api:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
elif ppqai_model.pricing.input_per_1M_tokens:
|
||||
input_price = ppqai_model.pricing.input_per_1M_tokens
|
||||
|
||||
output_price = 0.0
|
||||
if ppqai_model.pricing.api:
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
elif ppqai_model.pricing.output_per_1M_tokens:
|
||||
output_price = ppqai_model.pricing.output_per_1M_tokens
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=ppqai_model.id,
|
||||
name=ppqai_model.name,
|
||||
created=ppqai_model.created_at // 1000,
|
||||
description=f"{ppqai_model.provider} model",
|
||||
description=f"{ppqai_model.provider or 'PPQ.AI'} model",
|
||||
context_length=ppqai_model.context_length,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
|
||||
142
routstr/upstream/request_correction.py
Normal file
142
routstr/upstream/request_correction.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Reactive request-correction layer.
|
||||
|
||||
When an upstream rejects a request with a recoverable 4xx error, this layer
|
||||
tries to *fix* the request body and let the caller retry the same upstream
|
||||
instead of failing outright. It is provider-agnostic: correctors key off the
|
||||
upstream's own error wording, so the same recovery works across every provider.
|
||||
|
||||
The layer is a small pipeline of :data:`Corrector` callables. Each corrector
|
||||
inspects the parsed request body and the upstream error message and either
|
||||
returns a corrected body (plus a short label identifying the fix) or declines
|
||||
by returning ``None``. Adding a new reactive fix means writing one corrector
|
||||
and adding it to :data:`DEFAULT_CORRECTORS` — no changes to the proxy loop.
|
||||
|
||||
All corrections are immutable: a corrector never mutates the body it is given,
|
||||
it returns a new ``dict``. The proxy threads an ``applied`` set of fix labels
|
||||
through retries so each distinct fix is applied at most once, guaranteeing the
|
||||
retry loop always terminates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Matches upstream error text that names a single rejected request parameter,
|
||||
# e.g. "`temperature` is deprecated for this model." or
|
||||
# "parameter 'top_p' is not supported". Keys off the upstream's own wording so
|
||||
# a 400 about an unsupported sampling/option field can be recovered by stripping
|
||||
# that field and retrying the same upstream.
|
||||
_UNSUPPORTED_PARAM_RE = re.compile(
|
||||
r"[`'\"]?(?P<param>[a-zA-Z_][a-zA-Z0-9_]*)[`'\"]?\s+is\s+"
|
||||
r"(?:deprecated|not\s+supported|unsupported|no\s+longer\s+supported)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# A corrector inspects the parsed request body and the upstream error message
|
||||
# and returns ``(new_body_dict, label)`` for a fix it can apply, or ``None`` to
|
||||
# decline. ``label`` identifies the fix so it is applied at most once per request.
|
||||
Corrector = Callable[[dict, str], "tuple[dict, str] | None"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Correction:
|
||||
"""A successful request correction ready to retry.
|
||||
|
||||
``body`` is the corrected JSON body (encoded), ``label`` identifies the fix
|
||||
that was applied (e.g. the stripped param name) so the caller can guard
|
||||
against applying the same fix twice.
|
||||
"""
|
||||
|
||||
body: bytes
|
||||
label: str
|
||||
|
||||
|
||||
def extract_error_message(response: Response) -> str:
|
||||
"""Best-effort extraction of an error message string from a proxy Response."""
|
||||
body_bytes = getattr(response, "body", None)
|
||||
if not body_bytes:
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(body_bytes)
|
||||
except Exception:
|
||||
return body_bytes.decode("utf-8", errors="ignore")[:500]
|
||||
if isinstance(data, dict):
|
||||
err = data.get("error")
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message") or err.get("detail")
|
||||
if isinstance(msg, str):
|
||||
return msg
|
||||
elif isinstance(err, str):
|
||||
return err
|
||||
if isinstance(data.get("message"), str):
|
||||
return data["message"]
|
||||
return ""
|
||||
|
||||
|
||||
def strip_unsupported_param(
|
||||
body: dict, error_message: str
|
||||
) -> tuple[dict, str] | None:
|
||||
"""Drop a top-level param the upstream named as unsupported/deprecated.
|
||||
|
||||
Returns ``(new_body, param)`` (a new dict, original untouched) when the
|
||||
error names a top-level param present in the body, otherwise ``None``.
|
||||
"""
|
||||
match = _UNSUPPORTED_PARAM_RE.search(error_message)
|
||||
if not match:
|
||||
return None
|
||||
param = match.group("param")
|
||||
if param not in body:
|
||||
return None
|
||||
new_body = {k: v for k, v in body.items() if k != param}
|
||||
return new_body, param
|
||||
|
||||
|
||||
# Ordered pipeline of correctors tried on each recoverable rejection.
|
||||
DEFAULT_CORRECTORS: tuple[Corrector, ...] = (strip_unsupported_param,)
|
||||
|
||||
|
||||
def correct_request(
|
||||
request_body: bytes,
|
||||
error_message: str,
|
||||
applied: set[str],
|
||||
correctors: Sequence[Corrector] = DEFAULT_CORRECTORS,
|
||||
) -> Correction | None:
|
||||
"""Try to correct a rejected request body so it can be retried.
|
||||
|
||||
Runs each corrector in order against the parsed body and ``error_message``.
|
||||
The first corrector that proposes a fix whose ``label`` is not already in
|
||||
``applied`` wins; its result is returned as a :class:`Correction`. Returns
|
||||
``None`` when nothing parses, nothing matches, or every proposed fix was
|
||||
already applied — the caller then treats the response as a normal failure.
|
||||
|
||||
``applied`` is read-only here; the caller records the returned ``label`` to
|
||||
bound retries and guarantee forward progress.
|
||||
"""
|
||||
if not request_body or not error_message:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(request_body)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for corrector in correctors:
|
||||
result = corrector(data, error_message)
|
||||
if result is None:
|
||||
continue
|
||||
new_body, label = result
|
||||
if label in applied:
|
||||
continue
|
||||
return Correction(body=json.dumps(new_body).encode(), label=label)
|
||||
return None
|
||||
@@ -18,6 +18,10 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "routstr"
|
||||
default_base_url = None
|
||||
platform_url = None
|
||||
# Upstream Routstr nodes serve `/v1/messages` natively, so forward the
|
||||
# request as-is instead of round-tripping through litellm's
|
||||
# Anthropic→OpenAI translator.
|
||||
supports_anthropic_messages = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -43,6 +47,13 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
self.settings = provider_settings or {}
|
||||
|
||||
def normalize_request_path(
|
||||
self, path: str, model_obj: "Model | None" = None
|
||||
) -> str:
|
||||
"""Preserve the ``v1/`` prefix when forwarding to an upstream Routstr.
|
||||
"""
|
||||
return path.lstrip("/")
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
|
||||
@@ -13,6 +13,7 @@ class XAIUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "x-ai"
|
||||
default_base_url = "https://api.x.ai/v1"
|
||||
platform_url = "https://console.x.ai/"
|
||||
litellm_provider_prefix = "xai/"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import asyncio
|
||||
import time
|
||||
import typing
|
||||
from typing import TypedDict
|
||||
|
||||
from cashu.core.base import Proof, Token
|
||||
from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from pydantic_core import PydanticUndefined
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import db, get_logger
|
||||
from .core.db import store_cashu_transaction
|
||||
from .core.settings import settings
|
||||
from .payment.lnurl import raw_send_to_lnurl
|
||||
|
||||
# cashu still declares Optional[X] without explicit defaults on MintInfo.
|
||||
# Under pydantic v2 those are required, but real mints omit many of them.
|
||||
# Default Optional fields to None at import time so balance fetches don't 422.
|
||||
for _name, _field in _CashuMintInfo.model_fields.items():
|
||||
_annot = _field.annotation
|
||||
_is_optional = typing.get_origin(_annot) is typing.Union and type(
|
||||
None
|
||||
) in typing.get_args(_annot)
|
||||
if _is_optional and _field.default is PydanticUndefined:
|
||||
_field.default = None
|
||||
_CashuMintInfo.model_rebuild(force=True)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -32,6 +48,8 @@ async def recieve_token(
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
|
||||
await wallet.load_mint(keyset_id=token_obj.keysets[0])
|
||||
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
|
||||
@@ -40,17 +58,56 @@ async def recieve_token(
|
||||
|
||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||
"""Internal send function - returns amount and serialized token"""
|
||||
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url or settings.primary_mint, unit
|
||||
effective_mint_url = mint_url or settings.primary_mint
|
||||
wallet: Wallet = await get_wallet(effective_mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
|
||||
proofs_for_mint = sum(p.amount for p in proofs)
|
||||
|
||||
# Fallback: proofs from untrusted source mints are swapped to primary_mint
|
||||
# during receive, so the user's preferred refund_mint_url may have no proofs
|
||||
# even though the global wallet has the balance.
|
||||
if proofs_for_mint < amount and effective_mint_url != settings.primary_mint:
|
||||
logger.info(
|
||||
f"send: insufficient proofs at {effective_mint_url} "
|
||||
f"(have {proofs_for_mint}, need {amount}), falling back to primary_mint={settings.primary_mint}"
|
||||
)
|
||||
effective_mint_url = settings.primary_mint
|
||||
wallet = await get_wallet(effective_mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
|
||||
proofs_for_mint = sum(p.amount for p in proofs)
|
||||
|
||||
all_mint_urls = list({k.mint_url for k in wallet.keysets.values()})
|
||||
proof_summary = {
|
||||
f"{k.mint_url}/{k.unit.name}": sum(p.amount for p in wallet.proofs if p.id == k.id)
|
||||
for k in wallet.keysets.values()
|
||||
}
|
||||
# Show ALL proofs in DB by keyset_id, regardless of whether the loaded wallet
|
||||
# knows about that keyset. This reveals proofs orphaned under stale keysets.
|
||||
raw_proofs_by_keyset: dict[str, int] = {}
|
||||
for p in wallet.proofs:
|
||||
raw_proofs_by_keyset[p.id] = raw_proofs_by_keyset.get(p.id, 0) + p.amount
|
||||
logger.info(
|
||||
f"send: proof inventory | mint={effective_mint_url} unit={unit} amount={amount} "
|
||||
f"primary_mint={settings.primary_mint} proofs_for_mint={proofs_for_mint} "
|
||||
f"all_mints={all_mint_urls} by_keyset={proof_summary} "
|
||||
f"raw_proofs_by_keyset_id={raw_proofs_by_keyset} "
|
||||
f"total_wallet_proofs={sum(p.amount for p in wallet.proofs)}"
|
||||
)
|
||||
|
||||
# Reserve proofs only after serialization succeeds — if serialize_proofs or
|
||||
# swap_to_send fails mid-way, proofs stay unreserved so dashboard balance
|
||||
# doesn't go negative.
|
||||
send_proofs, _ = await wallet.select_to_send(
|
||||
proofs, amount, set_reserved=True, include_fees=False
|
||||
)
|
||||
token = await wallet.serialize_proofs(
|
||||
send_proofs, include_dleq=False, legacy=False, memo=None
|
||||
proofs, amount, set_reserved=False, include_fees=False
|
||||
)
|
||||
try:
|
||||
token = await wallet.serialize_proofs(
|
||||
send_proofs, include_dleq=False, legacy=False, memo=None
|
||||
)
|
||||
except Exception:
|
||||
await wallet.set_reserved_for_send(send_proofs, reserved=False)
|
||||
raise
|
||||
await wallet.set_reserved_for_send(send_proofs, reserved=True)
|
||||
return amount, token
|
||||
|
||||
|
||||
@@ -65,10 +122,11 @@ async def _calculate_swap_amount(
|
||||
token_mint_url: str,
|
||||
token_wallet: Wallet,
|
||||
primary_wallet: Wallet,
|
||||
proofs: list,
|
||||
) -> int:
|
||||
"""
|
||||
Calculate the amount to mint on the primary mint after accounting for
|
||||
potential swap fees (melt fees) on the foreign mint.
|
||||
melt fees and NUT-02 input fees on the foreign mint.
|
||||
"""
|
||||
if settings.primary_mint_unit == "sat":
|
||||
receive_amount = amount_msat // 1000
|
||||
@@ -95,10 +153,11 @@ async def _calculate_swap_amount(
|
||||
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
|
||||
|
||||
fee_reserve = dummy_melt_quote.fee_reserve
|
||||
input_fees = token_wallet.get_fees_for_proofs(proofs)
|
||||
if token_unit == "sat":
|
||||
fee_msat = fee_reserve * 1000
|
||||
fee_msat = (fee_reserve + input_fees) * 1000
|
||||
else:
|
||||
fee_msat = fee_reserve
|
||||
fee_msat = fee_reserve + input_fees
|
||||
|
||||
amount_msat_after_fee = amount_msat - fee_msat
|
||||
|
||||
@@ -108,13 +167,14 @@ async def _calculate_swap_amount(
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
|
||||
if minted_amount <= 0:
|
||||
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
|
||||
raise ValueError(f"Fees ({fee_reserve + input_fees} {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,
|
||||
"input_fees": input_fees,
|
||||
"minted_amount": minted_amount,
|
||||
"minted_unit": settings.primary_mint_unit,
|
||||
},
|
||||
@@ -175,6 +235,7 @@ async def swap_to_primary_mint(
|
||||
token_obj.mint,
|
||||
token_wallet,
|
||||
primary_wallet,
|
||||
token_obj.proofs,
|
||||
)
|
||||
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
@@ -184,13 +245,15 @@ async def swap_to_primary_mint(
|
||||
)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
total_needed = melt_quote.amount + melt_quote.fee_reserve
|
||||
input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs)
|
||||
total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
|
||||
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,
|
||||
"input_fees": input_fees,
|
||||
"total_needed": total_needed,
|
||||
"token_amount": token_amount,
|
||||
},
|
||||
@@ -203,6 +266,7 @@ async def swap_to_primary_mint(
|
||||
"token_amount": token_amount,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"input_fees": input_fees,
|
||||
"total_needed": total_needed,
|
||||
"shortfall": total_needed - token_amount,
|
||||
},
|
||||
@@ -210,7 +274,7 @@ async def swap_to_primary_mint(
|
||||
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})"
|
||||
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve} + input_fees: {input_fees})"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -241,19 +305,66 @@ async def swap_to_primary_mint(
|
||||
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
|
||||
)
|
||||
|
||||
await primary_wallet.load_proofs(reload=True)
|
||||
pre_mint_balance = primary_wallet.available_balance.amount
|
||||
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
|
||||
if "11003" in str(e) or "outputs already signed" in str(e).lower():
|
||||
# Previous mint call signed outputs at the mint but failed before
|
||||
# bump_secret_derivation ran locally. Recover orphaned proofs and
|
||||
# advance the counter so the next request derives fresh secrets.
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: outputs already signed — recovering orphaned proofs",
|
||||
extra={"mint_quote_id": mint_quote.quote, "minted_amount": minted_amount},
|
||||
)
|
||||
try:
|
||||
for keyset_id in primary_wallet.keysets:
|
||||
await primary_wallet.restore_tokens_for_keyset(keyset_id, to=1, batch=25)
|
||||
await primary_wallet.load_proofs(reload=True)
|
||||
post_recovery_balance = primary_wallet.available_balance.amount
|
||||
balance_gained = post_recovery_balance - pre_mint_balance
|
||||
logger.info(
|
||||
"swap_to_primary_mint: recovery scan completed",
|
||||
extra={
|
||||
"pre_mint_balance": pre_mint_balance,
|
||||
"post_recovery_balance": post_recovery_balance,
|
||||
"balance_gained": balance_gained,
|
||||
"expected": minted_amount,
|
||||
},
|
||||
)
|
||||
if balance_gained < minted_amount:
|
||||
# Recovery scan ran but did NOT restore the orphaned proofs
|
||||
# (mint reports them as spent — they're stuck). Refuse to
|
||||
# credit the API key balance for proofs we don't actually hold.
|
||||
raise ValueError(
|
||||
f"Swap recovery failed: mint signed outputs but proofs are "
|
||||
f"unrecoverable (mint reports them spent). "
|
||||
f"Expected {minted_amount}, recovered {balance_gained}. "
|
||||
f"Local wallet DB ('.wallet/') state is corrupted — "
|
||||
f"the counter for keyset is stuck at a bad index range."
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as recovery_err:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: recovery failed",
|
||||
extra={"error": str(recovery_err)},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Mint on primary failed and recovery unsuccessful: {e}"
|
||||
) from e
|
||||
else:
|
||||
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",
|
||||
@@ -279,6 +390,8 @@ async def credit_balance(
|
||||
|
||||
try:
|
||||
amount, unit, mint_url = await recieve_token(cashu_token)
|
||||
original_amount = amount
|
||||
original_unit = unit
|
||||
logger.info(
|
||||
"credit_balance: Token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
@@ -290,6 +403,20 @@ async def credit_balance(
|
||||
"credit_balance: Converted to msat", extra={"amount_msat": amount}
|
||||
)
|
||||
|
||||
# Guard against zero/negative redemptions (empty or dust tokens, or
|
||||
# swap-to-primary-mint amounts that net to <= 0 after fees). Raising here
|
||||
# — before the UPDATE/commit below — leaves any freshly-created, still
|
||||
# uncommitted ApiKey row to be rolled back when the request session
|
||||
# closes, instead of persisting an orphan key with balance 0.
|
||||
if amount <= 0:
|
||||
logger.error(
|
||||
"credit_balance: Redeemed amount is zero or negative; refusing to credit",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Redeemed token amount must be positive, got {amount} msats"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
@@ -310,7 +437,20 @@ async def credit_balance(
|
||||
extra={"new_balance": key.balance},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=cashu_token,
|
||||
amount=original_amount,
|
||||
unit=original_unit,
|
||||
mint_url=mint_url,
|
||||
typ="in",
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.debug(
|
||||
"Cashu token successfully redeemed and stored",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
@@ -412,7 +552,7 @@ async def fetch_all_balances(
|
||||
"unit": unit,
|
||||
"wallet_balance": proofs_balance,
|
||||
"user_balance": user_balance,
|
||||
"owner_balance": proofs_balance - user_balance,
|
||||
"owner_balance": proofs_balance - user_balance if proofs_balance != 0 else 0,
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
@@ -460,7 +600,9 @@ async def fetch_all_balances(
|
||||
total_wallet_balance_sats += proofs_balance_sats
|
||||
total_user_balance_sats += user_balance_sats
|
||||
|
||||
owner_balance = total_wallet_balance_sats - total_user_balance_sats
|
||||
owner_balance = 0
|
||||
if total_wallet_balance_sats != 0:
|
||||
owner_balance = total_wallet_balance_sats - total_user_balance_sats
|
||||
|
||||
return (
|
||||
balance_details,
|
||||
@@ -471,11 +613,11 @@ async def fetch_all_balances(
|
||||
|
||||
|
||||
async def periodic_payout() -> None:
|
||||
if not settings.receive_ln_address:
|
||||
logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout")
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(60 * 15)
|
||||
await asyncio.sleep(settings.payout_interval_seconds)
|
||||
print(settings.payout_interval_seconds)
|
||||
if not settings.receive_ln_address:
|
||||
continue
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
for mint_url in settings.cashu_mints:
|
||||
@@ -493,7 +635,12 @@ async def periodic_payout() -> None:
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
available_balance = proofs_balance - user_balance
|
||||
min_amount = 210 if unit == "sat" else 210000
|
||||
# Threshold is configured in sats; convert for msat wallets.
|
||||
min_amount = (
|
||||
settings.min_payout_sat
|
||||
if unit == "sat"
|
||||
else settings.min_payout_sat * 1000
|
||||
)
|
||||
if available_balance > min_amount:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet,
|
||||
@@ -573,6 +720,46 @@ async def periodic_refund_sweep() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def periodic_routstr_fee_payout() -> None:
|
||||
from .auth import (
|
||||
ROUTSTR_FEE_DEFAULT_PAYOUT,
|
||||
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS,
|
||||
ROUTSTR_LN_ADDRESS,
|
||||
)
|
||||
|
||||
if not ROUTSTR_LN_ADDRESS:
|
||||
logger.info("ROUTSTR_LN_ADDRESS not set, skipping fee payout")
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS)
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
fee = await db.get_routstr_fee(session)
|
||||
accumulated_sats = fee.accumulated_msats // 1000
|
||||
if accumulated_sats >= ROUTSTR_FEE_DEFAULT_PAYOUT:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, settings.primary_mint, "sat", not_reserved=True
|
||||
)
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet, proofs, ROUTSTR_LN_ADDRESS, "sat", amount=accumulated_sats
|
||||
)
|
||||
paid_msats = accumulated_sats * 1000
|
||||
await db.reset_routstr_fee(session, paid_msats)
|
||||
logger.info(
|
||||
"Routstr fee payout sent",
|
||||
extra={
|
||||
"accumulated_sats": accumulated_sats,
|
||||
"amount_received": amount_received,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in Routstr fee payout: {type(e).__name__}",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
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]
|
||||
|
||||
@@ -174,17 +174,20 @@ class TestmintWallet:
|
||||
"""Fallback method to create a basic test token"""
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import secrets
|
||||
|
||||
unique_id = int(time.time() * 1000000) + random.randint(1000, 9999)
|
||||
# Use a cryptographically random id/secret so every minted token is
|
||||
# guaranteed unique. Time/PRNG-based ids can collide on hosts with
|
||||
# coarse clock resolution, producing byte-identical tokens that hash to
|
||||
# the same api_key and silently dedupe (flaky concurrency tests).
|
||||
unique_id = secrets.token_hex(16)
|
||||
token_data = {
|
||||
"token": [
|
||||
{
|
||||
"mint": self.mint_url,
|
||||
"proofs": [
|
||||
{
|
||||
"id": f"009a1f293253e41e{unique_id % 100000000:08d}",
|
||||
"id": f"009a1f293253e41e{unique_id[:8]}",
|
||||
"amount": amount,
|
||||
"secret": f"test-secret-{amount}-{unique_id}",
|
||||
"C": "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
@@ -7,7 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import adjust_payment_for_tokens, pay_for_request
|
||||
from routstr.balance import ChildKeyRequest, create_child_key
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
from routstr.core.settings import settings
|
||||
|
||||
|
||||
@@ -119,6 +120,54 @@ async def test_child_key_insufficient_balance(
|
||||
assert exc.value.status_code == 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_child_key_creation_is_atomic(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""Two concurrent create_child_key() calls with balance for exactly one must
|
||||
result in exactly one success and one 402, with the parent balance deducted
|
||||
only once."""
|
||||
child_key_cost = 1000
|
||||
settings.child_key_cost = child_key_cost
|
||||
|
||||
parent_hash = f"parent_concurrent_{secrets.token_hex(8)}"
|
||||
async with create_session() as session:
|
||||
parent = ApiKey(hashed_key=parent_hash, balance=child_key_cost)
|
||||
session.add(parent)
|
||||
await session.commit()
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
async def attempt() -> None:
|
||||
async with create_session() as session:
|
||||
fresh_parent = await session.get(ApiKey, parent_hash)
|
||||
assert fresh_parent is not None
|
||||
try:
|
||||
await create_child_key(ChildKeyRequest(count=1), fresh_parent, session)
|
||||
results.append("success")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
results.append("blocked")
|
||||
|
||||
await asyncio.gather(attempt(), attempt())
|
||||
|
||||
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, parent_hash)
|
||||
assert final is not None
|
||||
|
||||
assert final.balance == 0, (
|
||||
f"Balance should be fully deducted once: expected 0, got {final.balance}"
|
||||
)
|
||||
assert final.total_spent == child_key_cost, (
|
||||
f"total_spent should equal one deduction: expected {child_key_cost}, "
|
||||
f"got {final.total_spent}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
|
||||
parent_key = ApiKey(
|
||||
|
||||
@@ -69,9 +69,14 @@ async def test_wallet_info_child_key_no_child_keys(
|
||||
info_response = await integration_client.get("/v1/wallet/info")
|
||||
assert info_response.status_code == 200
|
||||
info_data = info_response.json()
|
||||
parent_key = authenticated_client._test_api_key # type: ignore[attr-defined]
|
||||
parent_key_hash = parent_key.removeprefix("sk-")
|
||||
|
||||
assert info_data["is_child"] is True
|
||||
assert "child_keys" not in info_data
|
||||
assert "parent_key" not in info_data
|
||||
assert info_data["parent_key_preview"] == parent_key_hash[:8] + "..."
|
||||
assert info_data["parent_key_preview"] not in {parent_key, parent_key_hash}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
302
tests/integration/test_cli_tokens.py
Normal file
302
tests/integration/test_cli_tokens.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Integration tests for CLI token management (/admin/api/cli-tokens).
|
||||
|
||||
Covers:
|
||||
- GET /admin/api/cli-tokens — list (preview only, no full token)
|
||||
- POST /admin/api/cli-tokens — create (returns full token once)
|
||||
- DELETE /admin/api/cli-tokens/{id} — revoke
|
||||
- Using a CLI token as Bearer auth against admin endpoints
|
||||
- Expiry enforcement (expired tokens are rejected by require_admin_api)
|
||||
- last_used_at bump on successful use
|
||||
- Auth failures: missing token, wrong token, revoked token
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import AsyncSession, CliToken
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_session_token() -> AsyncGenerator[str, None]:
|
||||
"""Inject a short-lived admin session token into admin_sessions."""
|
||||
token = secrets.token_urlsafe(24)
|
||||
admin_sessions[token] = int(time.time()) + 3600
|
||||
yield token
|
||||
admin_sessions.pop(token, None)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_client(
|
||||
integration_client: AsyncClient, admin_session_token: str
|
||||
) -> AsyncClient:
|
||||
"""An integration_client pre-authenticated with an admin session token."""
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_session_token}"
|
||||
return integration_client
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Creation
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cli_token_returns_full_token_once(
|
||||
admin_client: AsyncClient,
|
||||
) -> None:
|
||||
"""POST /admin/api/cli-tokens returns the raw token only on creation."""
|
||||
resp = await admin_client.post(
|
||||
"/admin/api/cli-tokens",
|
||||
json={"name": "my-laptop"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert body["name"] == "my-laptop"
|
||||
assert isinstance(body["id"], str) and body["id"]
|
||||
assert isinstance(body["token"], str) and len(body["token"]) >= 32
|
||||
assert body["expires_at"] is None
|
||||
assert isinstance(body["created_at"], int)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cli_token_with_expiry(admin_client: AsyncClient) -> None:
|
||||
"""expires_in_days sets expires_at ~= now + days * 86400."""
|
||||
before = int(time.time())
|
||||
resp = await admin_client.post(
|
||||
"/admin/api/cli-tokens",
|
||||
json={"name": "ci-runner", "expires_in_days": 7},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert body["expires_at"] is not None
|
||||
delta = body["expires_at"] - before
|
||||
# Allow 10s jitter around 7 * 86400
|
||||
assert 7 * 86400 - 10 <= delta <= 7 * 86400 + 10
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cli_token_rejects_empty_name(
|
||||
admin_client: AsyncClient,
|
||||
) -> None:
|
||||
resp = await admin_client.post(
|
||||
"/admin/api/cli-tokens", json={"name": " "}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_cli_token_requires_admin(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
"""No admin token / no bearer → 403."""
|
||||
resp = await integration_client.post(
|
||||
"/admin/api/cli-tokens", json={"name": "no-auth"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Listing
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_cli_tokens_returns_preview_not_full_token(
|
||||
admin_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Listing never leaks the raw token."""
|
||||
create = await admin_client.post(
|
||||
"/admin/api/cli-tokens", json={"name": "secret-keeper"}
|
||||
)
|
||||
assert create.status_code == 200
|
||||
full_token = create.json()["token"]
|
||||
|
||||
resp = await admin_client.get("/admin/api/cli-tokens")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert any(t["name"] == "secret-keeper" for t in items)
|
||||
|
||||
for t in items:
|
||||
# No 'token' field, only 'token_preview'
|
||||
assert "token" not in t
|
||||
assert "token_preview" in t
|
||||
assert full_token not in t["token_preview"]
|
||||
assert "..." in t["token_preview"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_cli_tokens_requires_admin(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
resp = await integration_client.get("/admin/api/cli-tokens")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Using a CLI token as admin auth
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_token_authorizes_admin_endpoints(
|
||||
admin_client: AsyncClient,
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""A freshly-created CLI token can be used as Bearer on admin endpoints."""
|
||||
create = await admin_client.post(
|
||||
"/admin/api/cli-tokens", json={"name": "cli-auth"}
|
||||
)
|
||||
assert create.status_code == 200
|
||||
cli_token = create.json()["token"]
|
||||
token_id = create.json()["id"]
|
||||
|
||||
# Use a NEW client to isolate the header from admin_session_token
|
||||
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
|
||||
resp = await integration_client.get("/admin/api/cli-tokens")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# last_used_at should be populated after use
|
||||
row = await integration_session.get(CliToken, token_id)
|
||||
assert row is not None
|
||||
assert row.last_used_at is not None
|
||||
assert row.last_used_at >= row.created_at
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_cli_token_is_rejected(
|
||||
admin_client: AsyncClient,
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""A CLI token with expires_at in the past → 403."""
|
||||
create = await admin_client.post(
|
||||
"/admin/api/cli-tokens",
|
||||
json={"name": "will-expire", "expires_in_days": 1},
|
||||
)
|
||||
assert create.status_code == 200
|
||||
cli_token = create.json()["token"]
|
||||
token_id = create.json()["id"]
|
||||
|
||||
# Force-expire it in the DB
|
||||
row = await integration_session.get(CliToken, token_id)
|
||||
assert row is not None
|
||||
row.expires_at = int(time.time()) - 1
|
||||
integration_session.add(row)
|
||||
await integration_session.commit()
|
||||
|
||||
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
|
||||
resp = await integration_client.get("/admin/api/cli-tokens")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_bearer_token_is_rejected(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
integration_client.headers["Authorization"] = "Bearer not-a-real-token"
|
||||
resp = await integration_client.get("/admin/api/cli-tokens")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Revocation
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_cli_token_removes_auth(
|
||||
admin_client: AsyncClient,
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""After DELETE, the token no longer authorizes."""
|
||||
create = await admin_client.post(
|
||||
"/admin/api/cli-tokens", json={"name": "to-revoke"}
|
||||
)
|
||||
token_id = create.json()["id"]
|
||||
cli_token = create.json()["token"]
|
||||
|
||||
revoke = await admin_client.delete(f"/admin/api/cli-tokens/{token_id}")
|
||||
assert revoke.status_code == 200
|
||||
assert revoke.json() == {"ok": True, "deleted_id": token_id}
|
||||
|
||||
# Row is gone
|
||||
row = await integration_session.get(CliToken, token_id)
|
||||
assert row is None
|
||||
|
||||
# Can no longer be used for auth
|
||||
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
|
||||
resp = await integration_client.get("/admin/api/cli-tokens")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_unknown_cli_token_returns_404(
|
||||
admin_client: AsyncClient,
|
||||
) -> None:
|
||||
resp = await admin_client.delete("/admin/api/cli-tokens/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_cli_token_requires_admin(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
resp = await integration_client.delete("/admin/api/cli-tokens/anything")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Lifecycle / uniqueness
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_tokens_are_independent(
|
||||
admin_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Creating N tokens yields N unique tokens that all live in DB."""
|
||||
names = ["dev-a", "dev-b", "dev-c"]
|
||||
raw_tokens: list[str] = []
|
||||
ids: list[str] = []
|
||||
for name in names:
|
||||
r = await admin_client.post(
|
||||
"/admin/api/cli-tokens", json={"name": name}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
raw_tokens.append(r.json()["token"])
|
||||
ids.append(r.json()["id"])
|
||||
|
||||
# All unique
|
||||
assert len(set(raw_tokens)) == len(raw_tokens)
|
||||
assert len(set(ids)) == len(ids)
|
||||
|
||||
# All in DB
|
||||
result = await integration_session.exec(
|
||||
select(CliToken).where(CliToken.name.in_(names)) # type: ignore[attr-defined]
|
||||
)
|
||||
rows = result.all()
|
||||
assert {r.name for r in rows} == set(names)
|
||||
@@ -124,6 +124,40 @@ async def test_pay_for_request_raises_402_when_all_balance_reserved(
|
||||
assert key.reserved_balance == 50_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_info_matches_chat_available_balance(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
Regression for /v1/balance/info showing gross funds while chat admission
|
||||
rejects with a negative available balance.
|
||||
"""
|
||||
from routstr.auth import pay_for_request
|
||||
|
||||
key = _key(balance=4_404_339, reserved=4_410_636)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
response = await integration_client.get(
|
||||
"/v1/balance/info",
|
||||
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["balance"] == -6_297
|
||||
assert body["reserved"] == 4_410_636
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(key, 1, integration_session)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
detail = exc_info.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert "-6297 available" in detail["error"]["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — balance just one msat below model cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import pay_for_request
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -120,6 +122,270 @@ async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None
|
||||
assert key2.total_spent == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_limit_enforced_atomically_under_concurrency(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
parent_hash = "parent_limit_atomic"
|
||||
child_hash = "child_limit_atomic"
|
||||
cost = 300
|
||||
|
||||
async with create_session() as session:
|
||||
parent = ApiKey(hashed_key=parent_hash, balance=10000)
|
||||
child = ApiKey(
|
||||
hashed_key=child_hash,
|
||||
balance=0,
|
||||
parent_key_hash=parent_hash,
|
||||
balance_limit=cost,
|
||||
total_spent=0,
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
async def attempt() -> None:
|
||||
async with create_session() as session:
|
||||
fresh_child = await session.get(ApiKey, child_hash)
|
||||
assert fresh_child is not None
|
||||
try:
|
||||
await pay_for_request(fresh_child, cost, session)
|
||||
results.append("success")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
results.append("blocked")
|
||||
|
||||
await asyncio.gather(attempt(), attempt())
|
||||
|
||||
assert sorted(results) == ["blocked", "success"], (
|
||||
f"Expected exactly one success and one 402, got: {results}"
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
final_child = await session.get(ApiKey, child_hash)
|
||||
assert final_child is not None
|
||||
|
||||
assert final_child.reserved_balance == cost, (
|
||||
f"Child reserved_balance should equal one reservation, "
|
||||
f"got {final_child.reserved_balance}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_payments_with_parent_and_child_key(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
parent_hash = "parent_parallel_mixed"
|
||||
child_hash = "child_parallel_mixed"
|
||||
cost = 300
|
||||
|
||||
async with create_session() as session:
|
||||
parent = ApiKey(hashed_key=parent_hash, balance=10000)
|
||||
child = ApiKey(
|
||||
hashed_key=child_hash,
|
||||
balance=0,
|
||||
parent_key_hash=parent_hash,
|
||||
balance_limit=2 * cost,
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
async def attempt(key_hash: str) -> str:
|
||||
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)
|
||||
return "success"
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
return "blocked"
|
||||
|
||||
results = await asyncio.gather(attempt(parent_hash), attempt(child_hash))
|
||||
|
||||
assert results == ["success", "success"], (
|
||||
f"Both parent and child payments should succeed, got: {results}"
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
final_parent = await session.get(ApiKey, parent_hash)
|
||||
final_child = await session.get(ApiKey, child_hash)
|
||||
assert final_parent is not None
|
||||
assert final_child is not None
|
||||
|
||||
# Both requests bill the parent; only the child request reserves on the child.
|
||||
assert final_parent.reserved_balance == 2 * cost
|
||||
assert final_parent.total_requests == 2
|
||||
assert final_child.reserved_balance == cost
|
||||
assert final_child.total_requests == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_limit_with_existing_total_spent_under_concurrency(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
parent_hash = "parent_total_spent"
|
||||
child_hash = "child_total_spent"
|
||||
cost = 300
|
||||
|
||||
async with create_session() as session:
|
||||
parent = ApiKey(hashed_key=parent_hash, balance=10000)
|
||||
# 700 already spent against a 1000 limit: only one more 300 request fits.
|
||||
child = ApiKey(
|
||||
hashed_key=child_hash,
|
||||
balance=0,
|
||||
parent_key_hash=parent_hash,
|
||||
balance_limit=1000,
|
||||
total_spent=700,
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
async def attempt() -> None:
|
||||
async with create_session() as session:
|
||||
fresh_child = await session.get(ApiKey, child_hash)
|
||||
assert fresh_child is not None
|
||||
try:
|
||||
await pay_for_request(fresh_child, cost, session)
|
||||
results.append("success")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
results.append("blocked")
|
||||
|
||||
await asyncio.gather(attempt(), attempt())
|
||||
|
||||
assert sorted(results) == ["blocked", "success"], (
|
||||
f"Expected exactly one success and one 402, got: {results}"
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
final_child = await session.get(ApiKey, child_hash)
|
||||
assert final_child is not None
|
||||
|
||||
assert final_child.reserved_balance == cost
|
||||
assert final_child.total_spent == 700
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_limit_with_existing_reserved_balance(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
parent_hash = "parent_reserved_set"
|
||||
blocked_hash = "child_reserved_blocked"
|
||||
allowed_hash = "child_reserved_allowed"
|
||||
cost = 300
|
||||
|
||||
async with create_session() as session:
|
||||
parent = ApiKey(hashed_key=parent_hash, balance=10000)
|
||||
# 800 already reserved against a 1000 limit: another 300 must be rejected.
|
||||
blocked_child = ApiKey(
|
||||
hashed_key=blocked_hash,
|
||||
balance=0,
|
||||
parent_key_hash=parent_hash,
|
||||
balance_limit=1000,
|
||||
reserved_balance=800,
|
||||
)
|
||||
# 500 reserved against a 1000 limit: another 300 still fits.
|
||||
allowed_child = ApiKey(
|
||||
hashed_key=allowed_hash,
|
||||
balance=0,
|
||||
parent_key_hash=parent_hash,
|
||||
balance_limit=1000,
|
||||
reserved_balance=500,
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(blocked_child)
|
||||
session.add(allowed_child)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
fresh_blocked = await session.get(ApiKey, blocked_hash)
|
||||
assert fresh_blocked is not None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await pay_for_request(fresh_blocked, cost, session)
|
||||
assert exc_info.value.status_code == 402
|
||||
|
||||
async with create_session() as session:
|
||||
fresh_allowed = await session.get(ApiKey, allowed_hash)
|
||||
assert fresh_allowed is not None
|
||||
await pay_for_request(fresh_allowed, cost, session)
|
||||
|
||||
async with create_session() as session:
|
||||
final_blocked = await session.get(ApiKey, blocked_hash)
|
||||
final_allowed = await session.get(ApiKey, allowed_hash)
|
||||
final_parent = await session.get(ApiKey, parent_hash)
|
||||
assert final_blocked is not None
|
||||
assert final_allowed is not None
|
||||
assert final_parent is not None
|
||||
|
||||
assert final_blocked.reserved_balance == 800, "Rejected request must not reserve"
|
||||
assert final_blocked.total_requests == 0
|
||||
assert final_allowed.reserved_balance == 500 + cost
|
||||
assert final_allowed.total_requests == 1
|
||||
# Only the allowed request should have billed the parent.
|
||||
assert final_parent.reserved_balance == cost
|
||||
assert final_parent.total_requests == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_child_reservation_discarded_when_parent_balance_depleted(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
parent_hash = "parent_depleted"
|
||||
child_hash = "child_depleted"
|
||||
cost = 300
|
||||
|
||||
async with create_session() as session:
|
||||
# Parent can only afford one request; child has no balance_limit.
|
||||
parent = ApiKey(hashed_key=parent_hash, balance=cost)
|
||||
child = ApiKey(
|
||||
hashed_key=child_hash,
|
||||
balance=0,
|
||||
parent_key_hash=parent_hash,
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
results: list[str] = []
|
||||
|
||||
async def attempt() -> None:
|
||||
async with create_session() as session:
|
||||
fresh_child = await session.get(ApiKey, child_hash)
|
||||
assert fresh_child is not None
|
||||
try:
|
||||
await pay_for_request(fresh_child, cost, session)
|
||||
results.append("success")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 402
|
||||
results.append("blocked")
|
||||
|
||||
await asyncio.gather(attempt(), attempt())
|
||||
|
||||
assert sorted(results) == ["blocked", "success"], (
|
||||
f"Expected exactly one success and one 402, got: {results}"
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
final_parent = await session.get(ApiKey, parent_hash)
|
||||
final_child = await session.get(ApiKey, child_hash)
|
||||
assert final_parent is not None
|
||||
assert final_child is not None
|
||||
|
||||
assert final_parent.reserved_balance == cost
|
||||
# The failed request must not leave a committed reservation on the child.
|
||||
assert final_child.reserved_balance == cost, (
|
||||
f"Child reserved_balance should reflect only the successful request, "
|
||||
f"got {final_child.reserved_balance}"
|
||||
)
|
||||
assert final_child.total_requests == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
|
||||
# This requires mocking the router call or testing the logic in balance.py
|
||||
|
||||
159
tests/integration/test_lightning_invoice_constraints.py
Normal file
159
tests/integration/test_lightning_invoice_constraints.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Integration tests for Lightning invoice key constraint fields.
|
||||
|
||||
Covers two things:
|
||||
- The three constraint fields (balance_limit, balance_limit_reset, validity_date)
|
||||
are persisted on LightningInvoice and survive a DB round-trip.
|
||||
- create_api_key_from_invoice propagates those fields to the created ApiKey,
|
||||
so the constraints are actually enforced when the key is used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey, LightningInvoice
|
||||
from routstr.lightning import create_api_key_from_invoice
|
||||
|
||||
|
||||
def _make_invoice(**kwargs: object) -> LightningInvoice:
|
||||
base = dict(
|
||||
id="inv_test_001",
|
||||
bolt11="lnbc1000n1test",
|
||||
amount_sats=1000,
|
||||
description="test invoice",
|
||||
payment_hash="deadbeef" * 8,
|
||||
status="paid",
|
||||
purpose="create",
|
||||
expires_at=int(time.time()) + 3600,
|
||||
paid_at=int(time.time()),
|
||||
)
|
||||
base.update(kwargs)
|
||||
return LightningInvoice(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_wallet_mint() -> object:
|
||||
with patch("routstr.lightning.get_wallet") as mock_get_wallet:
|
||||
wallet = AsyncMock()
|
||||
wallet.mint = AsyncMock(return_value=[])
|
||||
mock_get_wallet.return_value = wallet
|
||||
yield mock_get_wallet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_persists_balance_limit(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
invoice = _make_invoice(balance_limit=5000)
|
||||
integration_session.add(invoice)
|
||||
await integration_session.commit()
|
||||
|
||||
stored = await integration_session.get(LightningInvoice, invoice.id)
|
||||
assert stored is not None
|
||||
assert stored.balance_limit == 5000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_persists_balance_limit_reset(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
invoice = _make_invoice(balance_limit=5000, balance_limit_reset="daily")
|
||||
integration_session.add(invoice)
|
||||
await integration_session.commit()
|
||||
|
||||
stored = await integration_session.get(LightningInvoice, invoice.id)
|
||||
assert stored is not None
|
||||
assert stored.balance_limit_reset == "daily"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_persists_validity_date(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
expiry = int(time.time()) + 86400
|
||||
invoice = _make_invoice(validity_date=expiry)
|
||||
integration_session.add(invoice)
|
||||
await integration_session.commit()
|
||||
|
||||
stored = await integration_session.get(LightningInvoice, invoice.id)
|
||||
assert stored is not None
|
||||
assert stored.validity_date == expiry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Propagation to ApiKey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_created_key_receives_balance_limit(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
invoice = _make_invoice(balance_limit=8000)
|
||||
integration_session.add(invoice)
|
||||
await integration_session.flush()
|
||||
|
||||
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||
await integration_session.commit()
|
||||
|
||||
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||
assert stored_key is not None
|
||||
assert stored_key.balance_limit == 8000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_created_key_receives_balance_limit_reset(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
invoice = _make_invoice(balance_limit=8000, balance_limit_reset="monthly")
|
||||
integration_session.add(invoice)
|
||||
await integration_session.flush()
|
||||
|
||||
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||
await integration_session.commit()
|
||||
|
||||
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||
assert stored_key is not None
|
||||
assert stored_key.balance_limit_reset == "monthly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_created_key_receives_validity_date(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
expiry = int(time.time()) + 86400
|
||||
invoice = _make_invoice(validity_date=expiry)
|
||||
integration_session.add(invoice)
|
||||
await integration_session.flush()
|
||||
|
||||
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||
await integration_session.commit()
|
||||
|
||||
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||
assert stored_key is not None
|
||||
assert stored_key.validity_date == expiry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_created_key_without_constraints_has_none_fields(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
invoice = _make_invoice()
|
||||
integration_session.add(invoice)
|
||||
await integration_session.flush()
|
||||
|
||||
api_key = await create_api_key_from_invoice(invoice, integration_session)
|
||||
await integration_session.commit()
|
||||
|
||||
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
|
||||
assert stored_key is not None
|
||||
assert stored_key.balance_limit is None
|
||||
assert stored_key.balance_limit_reset is None
|
||||
assert stored_key.validity_date is None
|
||||
198
tests/integration/test_lightning_invoice_rip08.py
Normal file
198
tests/integration/test_lightning_invoice_rip08.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""RIP-08 lightning invoice endpoint tests.
|
||||
|
||||
Verifies both the spec-compliant path (`POST /lightning/invoice` with
|
||||
`Authorization: Bearer sk-...`) and the legacy path
|
||||
(`POST /v1/balance/lightning/invoice` with `api_key` in body).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
RIP08_PATH = "/lightning/invoice"
|
||||
LEGACY_PATH = "/v1/balance/lightning/invoice"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def patch_invoice_generation() -> Any:
|
||||
"""Stub out `generate_lightning_invoice` so no mint round-trip is needed."""
|
||||
counter = {"n": 0}
|
||||
|
||||
async def fake_generate(amount_sats: int, description: str) -> tuple[str, str]:
|
||||
counter["n"] += 1
|
||||
return (
|
||||
f"lnbc{amount_sats}n1pfakeinvoice{counter['n']}",
|
||||
f"payment_hash_{counter['n']}",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"routstr.lightning.generate_lightning_invoice",
|
||||
side_effect=fake_generate,
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seeded_topup_key(integration_session: AsyncSession) -> str:
|
||||
"""Insert an ApiKey row and return the public `sk-...` form."""
|
||||
hashed = "0" * 64
|
||||
key = ApiKey(
|
||||
hashed_key=hashed,
|
||||
balance=0,
|
||||
refund_currency="sat",
|
||||
refund_mint_url="http://localhost:3338",
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
return f"sk-{hashed}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_create_invoice_purpose_create(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""`purpose=create` works on both paths and requires no auth."""
|
||||
resp = await integration_client.post(
|
||||
path,
|
||||
json={"amount_sats": 1000, "purpose": "create"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["amount_sats"] == 1000
|
||||
assert body["bolt11"].startswith("lnbc")
|
||||
assert body["invoice_id"]
|
||||
assert body["payment_hash"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_topup_with_authorization_header(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
seeded_topup_key: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""RIP-08: topup using `Authorization: Bearer sk-...` header (no api_key in body)."""
|
||||
resp = await integration_client.post(
|
||||
path,
|
||||
json={"amount_sats": 500, "purpose": "topup"},
|
||||
headers={"Authorization": f"Bearer {seeded_topup_key}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["amount_sats"] == 500
|
||||
assert body["bolt11"].startswith("lnbc")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_topup_with_legacy_api_key_in_body(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
seeded_topup_key: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""Legacy: topup with `api_key` in body still accepted on both paths."""
|
||||
resp = await integration_client.post(
|
||||
path,
|
||||
json={
|
||||
"amount_sats": 250,
|
||||
"purpose": "topup",
|
||||
"api_key": seeded_topup_key,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["amount_sats"] == 250
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_topup_missing_auth_returns_401(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""Topup without any credential is rejected on both paths."""
|
||||
resp = await integration_client.post(
|
||||
path,
|
||||
json={"amount_sats": 100, "purpose": "topup"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_topup_unknown_api_key_returns_404(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
path: str,
|
||||
) -> None:
|
||||
resp = await integration_client.post(
|
||||
path,
|
||||
json={"amount_sats": 100, "purpose": "topup"},
|
||||
headers={"Authorization": "Bearer sk-deadbeef"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_invoice_status_404_for_unknown_id(
|
||||
integration_client: AsyncClient,
|
||||
path: str,
|
||||
) -> None:
|
||||
base = path.rsplit("/invoice", 1)[0] + "/invoice"
|
||||
resp = await integration_client.get(f"{base}/does-not-exist/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_purpose_defaults_to_create(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
) -> None:
|
||||
"""Per RIP-08, `purpose` may be omitted and defaults to `create`."""
|
||||
resp = await integration_client.post(
|
||||
RIP08_PATH,
|
||||
json={"amount_sats": 100},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["amount_sats"] == 100
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_header_overrides_body_api_key(
|
||||
integration_client: AsyncClient,
|
||||
patch_invoice_generation: Any,
|
||||
seeded_topup_key: str,
|
||||
) -> None:
|
||||
"""Header api_key wins over body api_key: bogus body must not cause 404."""
|
||||
resp = await integration_client.post(
|
||||
RIP08_PATH,
|
||||
json={
|
||||
"amount_sats": 100,
|
||||
"purpose": "topup",
|
||||
"api_key": "sk-" + "f" * 64, # bogus body key
|
||||
},
|
||||
headers={"Authorization": f"Bearer {seeded_topup_key}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
224
tests/integration/test_model_test_endpoint_security.py
Normal file
224
tests/integration/test_model_test_endpoint_security.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import ModelRow, UpstreamProviderRow
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_test_endpoint_requires_admin_auth(
|
||||
integration_client: AsyncClient,
|
||||
) -> None:
|
||||
with patch("httpx.AsyncClient") as mock_async_client:
|
||||
response = await integration_client.post(
|
||||
"/api/models/test",
|
||||
json={
|
||||
"model_id": "model-a",
|
||||
"endpoint_type": "chat-completions",
|
||||
"request_data": {"messages": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_async_client.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_test_endpoint_rejects_unsupported_endpoint_type(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
admin_token = "test-admin-token-model-test"
|
||||
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="sk-upstream-test",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
assert provider.id is not None
|
||||
|
||||
model = 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=provider.id,
|
||||
enabled=True,
|
||||
)
|
||||
integration_session.add(model)
|
||||
await integration_session.commit()
|
||||
|
||||
try:
|
||||
with patch("httpx.AsyncClient") as mock_async_client:
|
||||
response = await integration_client.post(
|
||||
"/api/models/test",
|
||||
json={
|
||||
"model_id": "model-a",
|
||||
"endpoint_type": "../../abuse",
|
||||
"request_data": {"messages": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Unsupported endpoint_type"
|
||||
mock_async_client.assert_not_called()
|
||||
finally:
|
||||
admin_sessions.pop(admin_token, None)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_test_endpoint_rejects_oversized_request_data(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
admin_token = "test-admin-token-model-test-oversized"
|
||||
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="sk-upstream-test",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
assert provider.id is not None
|
||||
|
||||
model = 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=provider.id,
|
||||
enabled=True,
|
||||
)
|
||||
integration_session.add(model)
|
||||
await integration_session.commit()
|
||||
|
||||
oversized = "x" * (64 * 1024 + 1)
|
||||
|
||||
try:
|
||||
with patch("httpx.AsyncClient") as mock_async_client:
|
||||
response = await integration_client.post(
|
||||
"/api/models/test",
|
||||
json={
|
||||
"model_id": "model-a",
|
||||
"endpoint_type": "chat-completions",
|
||||
"request_data": {"blob": oversized},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 413
|
||||
assert response.json()["detail"] == "request_data too large"
|
||||
mock_async_client.assert_not_called()
|
||||
finally:
|
||||
admin_sessions.pop(admin_token, None)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_test_endpoint_admin_uses_allowed_upstream_path(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
admin_token = "test-admin-token-model-test-success"
|
||||
admin_sessions[admin_token] = int(time.time()) + 3600
|
||||
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="sk-upstream-test",
|
||||
enabled=True,
|
||||
provider_fee=1.01,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
assert provider.id is not None
|
||||
|
||||
model = 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=provider.id,
|
||||
enabled=True,
|
||||
forwarded_model_id="upstream-model-a",
|
||||
)
|
||||
integration_session.add(model)
|
||||
await integration_session.commit()
|
||||
|
||||
class MockResponse:
|
||||
status_code = 200
|
||||
text = '{"ok": true}'
|
||||
|
||||
def json(self) -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
class MockAsyncClient:
|
||||
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:
|
||||
assert url == "https://api.example.com/v1/chat/completions"
|
||||
assert json["model"] == "upstream-model-a"
|
||||
assert headers["Authorization"] == "Bearer sk-upstream-test"
|
||||
return MockResponse()
|
||||
|
||||
try:
|
||||
with patch("httpx.AsyncClient", return_value=MockAsyncClient()):
|
||||
response = await integration_client.post(
|
||||
"/api/models/test",
|
||||
json={
|
||||
"model_id": "model-a",
|
||||
"endpoint_type": "chat-completions",
|
||||
"request_data": {"messages": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"success": True,
|
||||
"data": {"ok": True},
|
||||
"status_code": 200,
|
||||
}
|
||||
finally:
|
||||
admin_sessions.pop(admin_token, None)
|
||||
210
tests/integration/test_temporary_balances_api.py
Normal file
210
tests/integration/test_temporary_balances_api.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlmodel import col, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
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}"}
|
||||
|
||||
|
||||
async def _add_key(
|
||||
session: AsyncSession,
|
||||
hashed_key: str,
|
||||
*,
|
||||
balance: int = 0,
|
||||
total_spent: int = 0,
|
||||
total_requests: int = 0,
|
||||
created_at: int | None = None,
|
||||
parent_key_hash: str | None = None,
|
||||
refund_address: str | None = None,
|
||||
) -> ApiKey:
|
||||
key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=balance,
|
||||
total_spent=total_spent,
|
||||
total_requests=total_requests,
|
||||
parent_key_hash=parent_key_hash,
|
||||
refund_address=refund_address,
|
||||
)
|
||||
key.created_at = created_at
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
# The model's default_factory is translated into a SQLAlchemy column
|
||||
# default that fires on INSERT whenever the value is None, so a true NULL
|
||||
# (a legacy row created before the column existed) can only be produced by
|
||||
# an explicit UPDATE after insert.
|
||||
if created_at is None:
|
||||
await session.exec(
|
||||
update(ApiKey) # type: ignore[call-overload]
|
||||
.where(col(ApiKey.hashed_key) == hashed_key)
|
||||
.values(created_at=None)
|
||||
)
|
||||
await session.commit()
|
||||
return key
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_balances_envelope_and_created_at(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
await _add_key(integration_session, "key_a", balance=1000, created_at=1000)
|
||||
|
||||
response = await integration_client.get(
|
||||
"/admin/api/temporary-balances", headers=_admin_headers()
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert set(body.keys()) == {"balances", "total", "totals"}
|
||||
assert body["total"] == 1
|
||||
assert body["balances"][0]["hashed_key"] == "key_a"
|
||||
assert body["balances"][0]["created_at"] == 1000
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_balances_sorted_latest_first_nulls_last(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
await _add_key(integration_session, "older", created_at=1000)
|
||||
await _add_key(integration_session, "newer", created_at=2000)
|
||||
await _add_key(integration_session, "legacy", created_at=None)
|
||||
|
||||
response = await integration_client.get(
|
||||
"/admin/api/temporary-balances", headers=_admin_headers()
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
order = [b["hashed_key"] for b in response.json()["balances"]]
|
||||
# Newest created first, NULL created_at (legacy) sorts last.
|
||||
assert order == ["newer", "older", "legacy"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_balances_pagination(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
for i in range(5):
|
||||
await _add_key(integration_session, f"key_{i}", created_at=1000 + i)
|
||||
|
||||
headers = _admin_headers()
|
||||
page1 = (
|
||||
await integration_client.get(
|
||||
"/admin/api/temporary-balances?limit=2&offset=0", headers=headers
|
||||
)
|
||||
).json()
|
||||
page2 = (
|
||||
await integration_client.get(
|
||||
"/admin/api/temporary-balances?limit=2&offset=2", headers=headers
|
||||
)
|
||||
).json()
|
||||
|
||||
assert page1["total"] == 5
|
||||
assert page2["total"] == 5
|
||||
assert [b["hashed_key"] for b in page1["balances"]] == ["key_4", "key_3"]
|
||||
assert [b["hashed_key"] for b in page2["balances"]] == ["key_2", "key_1"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_balances_totals_exclude_child_balance(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
await _add_key(
|
||||
integration_session,
|
||||
"parent",
|
||||
balance=5000,
|
||||
total_spent=100,
|
||||
total_requests=3,
|
||||
created_at=1000,
|
||||
)
|
||||
# Child draws from parent's balance, so its balance must NOT be summed,
|
||||
# but its spent/requests still count.
|
||||
await _add_key(
|
||||
integration_session,
|
||||
"child",
|
||||
balance=0,
|
||||
total_spent=200,
|
||||
total_requests=7,
|
||||
created_at=1001,
|
||||
parent_key_hash="parent",
|
||||
)
|
||||
|
||||
response = await integration_client.get(
|
||||
"/admin/api/temporary-balances", headers=_admin_headers()
|
||||
)
|
||||
|
||||
totals = response.json()["totals"]
|
||||
assert totals["total_balance"] == 5000
|
||||
assert totals["total_spent"] == 300
|
||||
assert totals["total_requests"] == 10
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_balances_search_filters_total_and_totals(
|
||||
integration_client: httpx.AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
await _add_key(
|
||||
integration_session,
|
||||
"alpha",
|
||||
balance=1000,
|
||||
created_at=1000,
|
||||
refund_address="alice@ln.tld",
|
||||
)
|
||||
await _add_key(
|
||||
integration_session,
|
||||
"beta",
|
||||
balance=2000,
|
||||
created_at=1001,
|
||||
refund_address="bob@ln.tld",
|
||||
)
|
||||
|
||||
headers = _admin_headers()
|
||||
|
||||
# Match by hashed_key.
|
||||
by_key = (
|
||||
await integration_client.get(
|
||||
"/admin/api/temporary-balances?search=alpha", headers=headers
|
||||
)
|
||||
).json()
|
||||
assert by_key["total"] == 1
|
||||
assert by_key["balances"][0]["hashed_key"] == "alpha"
|
||||
# totals reflect only the filtered set.
|
||||
assert by_key["totals"]["total_balance"] == 1000
|
||||
|
||||
# Match by refund_address.
|
||||
by_addr = (
|
||||
await integration_client.get(
|
||||
"/admin/api/temporary-balances?search=bob@ln.tld", headers=headers
|
||||
)
|
||||
).json()
|
||||
assert by_addr["total"] == 1
|
||||
assert by_addr["balances"][0]["hashed_key"] == "beta"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_balances_requires_admin(
|
||||
integration_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
response = await integration_client.get("/admin/api/temporary-balances")
|
||||
assert response.status_code in (401, 403)
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -356,6 +356,70 @@ async def test_concurrent_refund_requests(
|
||||
assert len(successful) + len(failed) == 5
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_rejects_concurrent_topup_on_same_key(
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
) -> None:
|
||||
"""Test refund returns 409 when a concurrent topup changes the balance first."""
|
||||
from routstr import balance as balance_module
|
||||
|
||||
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||
assert wallet_response.status_code == 200
|
||||
initial_balance = wallet_response.json()["balance"]
|
||||
|
||||
topup_amount_sat = 500
|
||||
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
|
||||
|
||||
key_looked_up = asyncio.Event()
|
||||
allow_refund_to_continue = asyncio.Event()
|
||||
original_lookup = balance_module._lookup_key_no_create
|
||||
delayed_once = False
|
||||
|
||||
async def delayed_lookup_key_no_create(*args: Any, **kwargs: Any) -> ApiKey | None:
|
||||
nonlocal delayed_once
|
||||
key = await original_lookup(*args, **kwargs)
|
||||
if not delayed_once and key is not None:
|
||||
delayed_once = True
|
||||
key_looked_up.set()
|
||||
await allow_refund_to_continue.wait()
|
||||
return key
|
||||
|
||||
async def issue_refund() -> Any:
|
||||
return await authenticated_client.post("/v1/wallet/refund")
|
||||
|
||||
async def issue_topup() -> Any:
|
||||
await key_looked_up.wait()
|
||||
try:
|
||||
return await authenticated_client.post(
|
||||
"/v1/wallet/topup", params={"cashu_token": topup_token}
|
||||
)
|
||||
finally:
|
||||
allow_refund_to_continue.set()
|
||||
|
||||
with patch(
|
||||
"routstr.balance._lookup_key_no_create", new=delayed_lookup_key_no_create
|
||||
):
|
||||
refund_response, topup_response = await asyncio.gather(
|
||||
issue_refund(), issue_topup()
|
||||
)
|
||||
|
||||
assert topup_response.status_code == 200
|
||||
assert topup_response.json()["msats"] == topup_amount_sat * 1000
|
||||
assert refund_response.status_code == 409
|
||||
assert (
|
||||
refund_response.json()["detail"]
|
||||
== "Balance changed concurrently. Please retry the refund."
|
||||
)
|
||||
|
||||
final_balance_response = await authenticated_client.get("/v1/wallet/")
|
||||
assert final_balance_response.status_code == 200
|
||||
assert final_balance_response.json()["balance"] == (
|
||||
initial_balance + topup_amount_sat * 1000
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_during_active_usage(
|
||||
@@ -394,6 +458,42 @@ async def test_refund_during_active_usage(
|
||||
assert response.json()["balance"] == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_history_returns_apikey_transactions(
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
integration_session: Any,
|
||||
) -> None:
|
||||
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = wallet_response.json()["api_key"]
|
||||
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
|
||||
|
||||
topup_token = await testmint_wallet.mint_tokens(250)
|
||||
topup_response = await authenticated_client.post(
|
||||
"/v1/wallet/topup", params={"cashu_token": topup_token}
|
||||
)
|
||||
assert topup_response.status_code == 200
|
||||
|
||||
refund_response = await authenticated_client.post("/v1/wallet/refund")
|
||||
assert refund_response.status_code == 200
|
||||
|
||||
history_response = await authenticated_client.get("/v1/wallet/history")
|
||||
assert history_response.status_code == 200
|
||||
transactions = history_response.json()["transactions"]
|
||||
assert len(transactions) >= 2
|
||||
assert all("api_key_hashed_key" not in tx for tx in transactions)
|
||||
assert {tx["type"] for tx in transactions} >= {"in", "out"}
|
||||
|
||||
db_result = await integration_session.execute(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.api_key_hashed_key == hashed_key
|
||||
)
|
||||
)
|
||||
db_transactions = db_result.scalars().all()
|
||||
assert len(db_transactions) >= 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_unavailability_handling(
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
@@ -71,6 +71,16 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
|
||||
assert db_key.balance == new_balance
|
||||
assert db_key.balance == initial_balance + (topup_amount * 1000)
|
||||
|
||||
tx_result = await integration_session.execute(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.token == token,
|
||||
CashuTransaction.type == "in",
|
||||
)
|
||||
)
|
||||
tx = tx_result.scalar_one()
|
||||
assert tx.api_key_hashed_key == hashed_key
|
||||
assert tx.source == "apikey"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@@ -426,9 +436,9 @@ async def test_topup_with_zero_amount_token( # type: ignore[no-untyped-def]
|
||||
"/v1/wallet/topup", params={"cashu_token": token}
|
||||
)
|
||||
|
||||
# Should succeed but add 0 msats
|
||||
assert response.status_code == 200
|
||||
assert response.json()["msats"] == 0
|
||||
# Zero/negative redemptions are refused to avoid crediting empty
|
||||
# or dust tokens (and to prevent orphan zero-balance keys).
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
59
tests/unit/test_auth_cashu.py
Normal file
59
tests/unit/test_auth_cashu.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import validate_bearer_key
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = _make_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
db_session = AsyncSession(engine, expire_on_commit=False)
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
await db_session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
token = "cashuAfirst_seen_but_redemption_fails"
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||
patch(
|
||||
"routstr.auth.credit_balance",
|
||||
new=AsyncMock(side_effect=ValueError("token already spent")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException):
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
@@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from routstr.balance import refund_wallet_endpoint
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
from routstr.wallet import credit_balance
|
||||
|
||||
|
||||
def _make_cashu_tx(
|
||||
@@ -29,6 +30,12 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock:
|
||||
return result
|
||||
|
||||
|
||||
def _update_result(rowcount: int) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.rowcount = rowcount
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_returns_token() -> None:
|
||||
x_cashu_token = "cashuAtest_token_value"
|
||||
@@ -161,11 +168,12 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
|
||||
refund_token = "cashuArefund_apikey_token"
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
session.exec = AsyncMock(return_value=_update_result(1))
|
||||
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,
|
||||
@@ -186,6 +194,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
|
||||
assert call_kwargs["source"] == "apikey"
|
||||
assert call_kwargs["token"] == refund_token
|
||||
assert call_kwargs["typ"] == "out"
|
||||
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -194,11 +203,12 @@ async def test_apikey_refund_logs_token() -> None:
|
||||
refund_token = "cashuAlogged_token"
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
session.exec = AsyncMock(return_value=_update_result(1))
|
||||
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()),
|
||||
@@ -222,11 +232,12 @@ async def test_apikey_refund_log_includes_path() -> None:
|
||||
refund_token = "cashuApath_token"
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
session.exec = AsyncMock(return_value=_update_result(1))
|
||||
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()),
|
||||
@@ -248,3 +259,145 @@ async def test_apikey_refund_log_includes_path() -> None:
|
||||
assert len(token_issued_calls) == 1
|
||||
extra = token_issued_calls[0].kwargs.get("extra", {})
|
||||
assert extra.get("path") == "/v1/wallet/refund"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
|
||||
"""When the debit CAS fails (rowcount=0), no token is minted and 409 is returned."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
# Debit returns rowcount=0 → balance changed concurrently
|
||||
session.exec = AsyncMock(return_value=_update_result(0))
|
||||
session.commit = AsyncMock()
|
||||
|
||||
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", mock_send_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()),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-testhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
# Crucially: send_token must NOT have been called
|
||||
mock_send_token.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credit_balance_stores_apikey_transaction_history() -> None:
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(return_value=_update_result(1))
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.wallet.recieve_token",
|
||||
AsyncMock(return_value=(100, "sat", "https://mint.example")),
|
||||
),
|
||||
patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store,
|
||||
):
|
||||
amount = await credit_balance("cashuAtopup_token", key, session)
|
||||
|
||||
assert amount == 100_000
|
||||
mock_store.assert_awaited_once()
|
||||
call_kwargs = mock_store.call_args.kwargs
|
||||
assert call_kwargs["typ"] == "in"
|
||||
assert call_kwargs["source"] == "apikey"
|
||||
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
|
||||
assert call_kwargs["amount"] == 100
|
||||
assert call_kwargs["unit"] == "sat"
|
||||
assert call_kwargs["token"] == "cashuAtopup_token"
|
||||
assert call_kwargs["mint_url"] == "https://mint.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
||||
"""When debit succeeds but minting fails, balance must be restored."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
|
||||
# First exec call = debit (succeeds), second = restore
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
|
||||
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"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-testhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
# Verify two exec calls: debit + restore
|
||||
assert session.exec.await_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_fresh_cashu_bearer_returns_401() -> None:
|
||||
"""Fresh Cashu token not in DB must get 401, never create a new ApiKey."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer cashuAfresh_never_deposited_token",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
session.get.assert_awaited_once()
|
||||
# No add/commit → no key was persisted
|
||||
session.add.assert_not_called()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_unknown_sk_bearer_returns_401() -> None:
|
||||
"""Unknown sk- key not in DB must get 401."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-unknownhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
session.get.assert_awaited_once()
|
||||
|
||||
345
tests/unit/test_cost_calculation_caching.py
Normal file
345
tests/unit/test_cost_calculation_caching.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Tests for cache token handling in cost calculation.
|
||||
|
||||
Covers OpenAI vs Anthropic caching formats, edge cases, and billing accuracy.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
from routstr.payment.cost_calculation import CostData, MaxCostData, calculate_cost
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session() -> AsyncMock:
|
||||
"""Mock AsyncSession for cost calculation tests."""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_fixed_pricing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mock settings and price to use fixed pricing."""
|
||||
monkeypatch.setattr(settings, "fixed_pricing", True)
|
||||
monkeypatch.setattr(settings, "fixed_per_1k_input_tokens", 0.001)
|
||||
monkeypatch.setattr(settings, "fixed_per_1k_output_tokens", 0.001)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sats_usd_price() -> None: # type: ignore[misc]
|
||||
"""Patch sats_usd_price to avoid initialization issues."""
|
||||
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
|
||||
yield
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 1: OpenAI Cache Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
|
||||
"""OpenAI includes cached_tokens in prompt_tokens, subtract them."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 2000, # ← Includes 1000 cached
|
||||
"completion_tokens": 100,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 1000 # ← Extracted separately
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # 2000 - 1000
|
||||
assert result.cache_read_input_tokens == 1000
|
||||
assert result.output_tokens == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 2: Anthropic Cache Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Anthropic cache tokens are separate (additive) from input_tokens."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 500, # ← Regular input only
|
||||
"output_tokens": 100,
|
||||
"cache_creation_input_tokens": 1500, # ← Additive, not included above
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500
|
||||
assert result.cache_creation_input_tokens == 1500
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.output_tokens == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 3: Invalid Cache (Edge Case)
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle buggy upstream reporting cached > prompt_tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 150 # ← Invalid! Greater than prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
# Should not go negative
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 0 # max(0, 100 - 150)
|
||||
assert result.cache_read_input_tokens == 150
|
||||
assert result.output_tokens == 50
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 4: Malformed Token Values
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle non-numeric cache token values."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"cache_read_input_tokens": "-50", # ← String, negative
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": "invalid" # ← Non-numeric string
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
# Both should coerce to 0
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.input_tokens == 100 # No subtraction if cache_read = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 5: Anthropic Cache Not Subtracted
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Anthropic cache fields should NOT be subtracted from input_tokens."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 500,
|
||||
"completion_tokens": 100,
|
||||
"cache_read_input_tokens": 200, # ← Additive, don't subtract
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
# Anthropic: input_tokens stays as-is
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500 # NOT 300
|
||||
assert result.cache_read_input_tokens == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 6: Only Cache Read, No Regular Input
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with only cache read tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 0 # max(0, 0 - 1000)
|
||||
assert result.cache_read_input_tokens == 1000
|
||||
assert result.output_tokens == 50
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 7: Only Cache Creation
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with only cache creation tokens (Anthropic)."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 500,
|
||||
"output_tokens": 100,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500
|
||||
assert result.cache_creation_input_tokens == 2000
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.output_tokens == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 8: Both Cache Read and Creation
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with both cache read and creation."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 100,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
"cache_read_input_tokens": 500,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 300
|
||||
assert result.cache_creation_input_tokens == 2000
|
||||
assert result.cache_read_input_tokens == 500
|
||||
assert result.output_tokens == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 9: Token Field Fallback
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Verify fallback order for token extraction."""
|
||||
# When prompt_tokens is not present, fall back to input_tokens
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"input_tokens": 250,
|
||||
"completion_tokens": 50,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 250
|
||||
assert result.output_tokens == 50
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 10: Float Token Values
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_float_token_values_coerced_to_int(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle float token values by converting to int."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 100.7, # Float
|
||||
"completion_tokens": 50.3, # Float
|
||||
"cache_read_input_tokens": 25.9, # Float
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 100 # Floored
|
||||
assert result.output_tokens == 50 # Floored
|
||||
assert result.cache_read_input_tokens == 25 # Floored
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 11: Boolean Cache Tokens
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle boolean cache token values by coercing to zero."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"cache_read_input_tokens": True, # Boolean
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0 # Boolean coerced to 0
|
||||
assert result.input_tokens == 100 # No subtraction
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 12: Zero Cache Tokens
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle explicit zero cache tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.input_tokens == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 13: Missing Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""When usage is missing, return MaxCostData with zero tokens."""
|
||||
response = {"model": "gpt-4", "choices": [{"message": {"content": "test"}}]}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, MaxCostData)
|
||||
assert result.input_tokens == 0
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.output_tokens == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 14: Null Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""When usage is null, return MaxCostData with zero tokens."""
|
||||
response = {"model": "gpt-4", "usage": None}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, MaxCostData)
|
||||
assert result.input_tokens == 0
|
||||
assert result.cache_read_input_tokens == 0
|
||||
177
tests/unit/test_count_tokens_local.py
Normal file
177
tests/unit/test_count_tokens_local.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Unit tests for the local count_tokens shim.
|
||||
|
||||
The shim runs whenever an upstream that does not support Anthropic's
|
||||
``/v1/messages`` endpoint is asked for a token count. It must always
|
||||
return a 200 JSON ``{"input_tokens": N}`` response and must never raise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
from routstr.upstream import count_tokens as count_tokens_module
|
||||
from routstr.upstream.count_tokens import count_tokens_locally
|
||||
|
||||
|
||||
def _make_model(model_id: str = "anthropic/claude-3-5-sonnet") -> Model:
|
||||
pricing = Pricing(prompt=0.000003, completion=0.000015)
|
||||
architecture = Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="cl100k_base",
|
||||
instruct_type=None,
|
||||
)
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="",
|
||||
context_length=200_000,
|
||||
architecture=architecture,
|
||||
pricing=pricing,
|
||||
)
|
||||
|
||||
|
||||
def _body(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload).encode()
|
||||
|
||||
|
||||
def _read_payload(response: Any) -> dict[str, Any]:
|
||||
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
|
||||
return json.loads(body.decode())
|
||||
|
||||
|
||||
def test_returns_input_tokens_for_simple_messages() -> None:
|
||||
model = _make_model()
|
||||
request_body = _body(
|
||||
{
|
||||
"model": model.id,
|
||||
"messages": [{"role": "user", "content": "hello world"}],
|
||||
}
|
||||
)
|
||||
|
||||
response = count_tokens_locally(request_body, model)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.media_type == "application/json"
|
||||
payload = _read_payload(response)
|
||||
assert "input_tokens" in payload
|
||||
assert isinstance(payload["input_tokens"], int)
|
||||
assert payload["input_tokens"] >= 0
|
||||
|
||||
|
||||
def test_falls_back_to_estimator_when_litellm_raises() -> None:
|
||||
model = _make_model()
|
||||
request_body = _body(
|
||||
{
|
||||
"model": model.id,
|
||||
"messages": [{"role": "user", "content": "this is a longer message"}],
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
count_tokens_module,
|
||||
"_count_with_litellm",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
response = count_tokens_locally(request_body, model)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = _read_payload(response)
|
||||
assert payload["input_tokens"] >= 1
|
||||
|
||||
|
||||
def test_handles_missing_model_object() -> None:
|
||||
request_body = _body(
|
||||
{
|
||||
"model": "anthropic/claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
|
||||
response = count_tokens_locally(request_body, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = _read_payload(response)
|
||||
assert payload["input_tokens"] >= 0
|
||||
|
||||
|
||||
def test_handles_empty_request_body() -> None:
|
||||
response = count_tokens_locally(b"", _make_model())
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = _read_payload(response)
|
||||
assert payload["input_tokens"] >= 0
|
||||
|
||||
|
||||
def test_handles_malformed_json() -> None:
|
||||
response = count_tokens_locally(b"not-json", _make_model())
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = _read_payload(response)
|
||||
assert payload["input_tokens"] >= 0
|
||||
|
||||
|
||||
def test_includes_system_prompt_in_count() -> None:
|
||||
model = _make_model()
|
||||
short = _body(
|
||||
{
|
||||
"model": model.id,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
with_system = _body(
|
||||
{
|
||||
"model": model.id,
|
||||
"system": "You are a helpful assistant with a long preamble " * 10,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
|
||||
short_count = _read_payload(count_tokens_locally(short, model))["input_tokens"]
|
||||
long_count = _read_payload(count_tokens_locally(with_system, model))["input_tokens"]
|
||||
|
||||
assert long_count > short_count
|
||||
|
||||
|
||||
def test_supports_anthropic_system_block_list() -> None:
|
||||
model = _make_model()
|
||||
request_body = _body(
|
||||
{
|
||||
"model": model.id,
|
||||
"system": [{"type": "text", "text": "be terse" * 50}],
|
||||
"messages": [{"role": "user", "content": "ok"}],
|
||||
}
|
||||
)
|
||||
|
||||
response = count_tokens_locally(request_body, model)
|
||||
|
||||
payload = _read_payload(response)
|
||||
assert payload["input_tokens"] > 0
|
||||
|
||||
|
||||
def test_uses_forwarded_model_id_when_present() -> None:
|
||||
model = _make_model("anthropic/claude-3-5-sonnet")
|
||||
model.forwarded_model_id = "claude-3-5-sonnet-20241022"
|
||||
request_body = _body(
|
||||
{
|
||||
"model": "ignored",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _capture(model_name: str, body: dict[str, Any]) -> int:
|
||||
captured["model"] = model_name
|
||||
return 7
|
||||
|
||||
with patch.object(count_tokens_module, "_count_with_litellm", side_effect=_capture):
|
||||
response = count_tokens_locally(request_body, model)
|
||||
|
||||
assert captured["model"] == "claude-3-5-sonnet-20241022"
|
||||
assert _read_payload(response)["input_tokens"] == 7
|
||||
93
tests/unit/test_litellm_routing.py
Normal file
93
tests/unit/test_litellm_routing.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Tests for `routstr.upstream.litellm_routing.detect_litellm_prefix`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.litellm_routing import detect_litellm_prefix
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url,expected",
|
||||
[
|
||||
# The bug case: custom row pointing at Fireworks must NOT route to openai/.
|
||||
("https://api.fireworks.ai/inference/v1", "fireworks_ai/"),
|
||||
# Other OpenAI-compatible providers commonly plugged into the custom slot.
|
||||
("https://api.groq.com/openai/v1", "groq/"),
|
||||
("https://api.x.ai/v1", "xai/"),
|
||||
("https://api.deepseek.com/v1", "deepseek/"),
|
||||
("https://api.together.xyz/v1", "together_ai/"),
|
||||
("https://api.perplexity.ai", "perplexity/"),
|
||||
("https://openrouter.ai/api/v1", "openrouter/"),
|
||||
("https://api.mistral.ai/v1", "mistral/"),
|
||||
("https://codestral.mistral.ai/v1", "codestral/"),
|
||||
("https://api.cohere.com/v1", "cohere_chat/"),
|
||||
("https://api.cohere.ai/v1", "cohere_chat/"),
|
||||
("https://api.deepinfra.com/v1/openai", "deepinfra/"),
|
||||
("https://api.cerebras.ai/v1", "cerebras/"),
|
||||
("https://api.sambanova.ai/v1", "sambanova/"),
|
||||
("https://api.moonshot.cn/v1", "moonshot/"),
|
||||
("https://api.moonshot.ai/v1", "moonshot/"),
|
||||
("https://api.studio.nebius.com/v1", "nebius/"),
|
||||
("https://api.novita.ai/v3/openai", "novita/"),
|
||||
("https://api.lambda.ai/v1", "lambda_ai/"),
|
||||
("https://api.aimlapi.com/v1", "aiml/"),
|
||||
("https://api.featherless.ai/v1", "featherless_ai/"),
|
||||
("https://integrate.api.nvidia.com/v1", "nvidia_nim/"),
|
||||
("https://inference.baseten.co/v1", "baseten/"),
|
||||
("https://ai-gateway.vercel.sh/v1", "vercel_ai_gateway/"),
|
||||
("https://api.inference.wandb.ai/v1", "wandb/"),
|
||||
("https://api.poe.com/v1", "poe/"),
|
||||
("https://llm.chutes.ai/v1/", "chutes/"),
|
||||
("https://api.v0.dev/v1", "v0/"),
|
||||
("https://api.hyperbolic.xyz/v1", "hyperbolic/"),
|
||||
("https://api.synthetic.new/openai/v1", "synthetic/"),
|
||||
("https://api.stima.tech/v1", "apertis/"),
|
||||
("https://nano-gpt.com/api/v1", "nano-gpt/"),
|
||||
("https://api.friendli.ai/serverless/v1", "friendliai/"),
|
||||
("https://api.galadriel.com/v1", "galadriel/"),
|
||||
("https://api.llama.com/compat/v1", "meta_llama/"),
|
||||
("https://api.minimax.io/v1", "minimax/"),
|
||||
("https://api.minimaxi.com/v1", "minimax/"),
|
||||
("https://platform.publicai.co/v1", "publicai/"),
|
||||
("https://inference.api.nscale.com/v1", "nscale/"),
|
||||
("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "dashscope/"),
|
||||
("https://api.endpoints.anyscale.com/v1", "anyscale/"),
|
||||
("https://api.ai21.com/studio/v1", "ai21_chat/"),
|
||||
("https://ark.cn-beijing.volces.com/api/v3", "volcengine/"),
|
||||
("https://api.voyageai.com/v1", "voyage/"),
|
||||
("https://api.jina.ai/v1", "jina_ai/"),
|
||||
("https://api.snowflakecomputing.com", "snowflake/"),
|
||||
("https://my-workspace.databricks.com/serving-endpoints", "databricks/"),
|
||||
("https://huggingface.co/api/inference", "huggingface/"),
|
||||
# First-class providers — URL detection still produces the right prefix
|
||||
# so subclasses without an explicit override stay correct.
|
||||
("https://api.openai.com/v1", "openai/"),
|
||||
("https://api.anthropic.com/v1", "anthropic/"),
|
||||
("https://generativelanguage.googleapis.com/v1beta/openai", "gemini/"),
|
||||
("https://us-central1-aiplatform.googleapis.com/v1", "vertex_ai/"),
|
||||
# Azure ordering: must beat api.openai.com.
|
||||
("https://my-resource.openai.azure.com/openai/deployments/foo", "azure/"),
|
||||
# Ollama hints.
|
||||
("http://localhost:11434/v1", "ollama_chat/"),
|
||||
("http://127.0.0.1:11434/v1", "ollama_chat/"),
|
||||
("http://my-ollama-host:11434/v1", "ollama_chat/"),
|
||||
# Casing and trailing slash normalisation.
|
||||
("HTTPS://API.FIREWORKS.AI/INFERENCE/V1/", "fireworks_ai/"),
|
||||
# Unknown host falls back to openai/ (still OpenAI-compatible by convention).
|
||||
("https://example.com/v1", "openai/"),
|
||||
("", "openai/"),
|
||||
],
|
||||
)
|
||||
def test_detect_litellm_prefix(base_url: str, expected: str) -> None:
|
||||
assert detect_litellm_prefix(base_url) == expected
|
||||
|
||||
|
||||
def test_detect_litellm_prefix_none_uses_default() -> None:
|
||||
assert detect_litellm_prefix(None) == "openai/"
|
||||
|
||||
|
||||
def test_detect_litellm_prefix_custom_default() -> None:
|
||||
assert detect_litellm_prefix("https://example.com", default="anthropic/") == (
|
||||
"anthropic/"
|
||||
)
|
||||
333
tests/unit/test_messages_dispatch_cost_accumulation.py
Normal file
333
tests/unit/test_messages_dispatch_cost_accumulation.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Tests for cost accumulation in streaming message dispatch.
|
||||
|
||||
Verifies that costs are correctly summed across multiple streaming events,
|
||||
not taking only the maximum.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||
|
||||
from routstr.upstream.messages_dispatch import annotate_event
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 1: Cost Accumulation via AnnotatedEvent
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_annotate_event_extracts_costs() -> None:
|
||||
"""Each event should report its own costs."""
|
||||
event = {
|
||||
"type": "content_block_start",
|
||||
"usage": {"total_cost": 0.010, "input_cost": 0.008, "output_cost": 0.002}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.total_cost == 0.010
|
||||
assert result.input_cost == 0.008
|
||||
assert result.output_cost == 0.002
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 2: Multiple Events with Incremental Costs
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_multiple_events_sum_costs() -> None:
|
||||
"""When processing multiple events, costs should accumulate (not max)."""
|
||||
# Event 1: initial costs
|
||||
event1 = {
|
||||
"type": "message_start",
|
||||
"usage": {"input_tokens": 100, "total_cost": 0.010}
|
||||
}
|
||||
result1 = annotate_event(event1, None)
|
||||
|
||||
# Event 2: additional costs
|
||||
event2 = {
|
||||
"type": "content_block_start",
|
||||
"usage": {"output_tokens": 50, "total_cost": 0.005}
|
||||
}
|
||||
result2 = annotate_event(event2, None)
|
||||
|
||||
# Event 3: more costs
|
||||
event3 = {
|
||||
"type": "message_delta",
|
||||
"usage": {"output_tokens": 25, "total_cost": 0.008}
|
||||
}
|
||||
result3 = annotate_event(event3, None)
|
||||
|
||||
# Each event should report its own cost (before accumulation)
|
||||
assert result1.total_cost == 0.010
|
||||
assert result2.total_cost == 0.005
|
||||
assert result3.total_cost == 0.008
|
||||
|
||||
# When summed for billing: 0.010 + 0.005 + 0.008 = 0.023
|
||||
# This verifies the cost accumulation fix (using += instead of max())
|
||||
total_from_events = result1.total_cost + result2.total_cost + result3.total_cost
|
||||
assert total_from_events == 0.023
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 3: Token Accumulation Consistency
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_tokens_and_costs_extracted_independently() -> None:
|
||||
"""Tokens and costs should be extracted independently per event."""
|
||||
event = {
|
||||
"type": "content_block_delta",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 30,
|
||||
"total_cost": 0.007,
|
||||
"input_cost": 0.005,
|
||||
"output_cost": 0.002
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
# All values should be extracted
|
||||
assert result.input_tokens == 100
|
||||
assert result.output_tokens == 50
|
||||
assert result.cache_read_input_tokens == 30
|
||||
assert result.total_cost == 0.007
|
||||
assert result.input_cost == 0.005
|
||||
assert result.output_cost == 0.002
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 4: Cost in Message vs Root
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_cost_extracted_from_message_usage() -> None:
|
||||
"""Costs in message.usage should be extracted correctly."""
|
||||
event = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"total_cost": 0.010,
|
||||
"input_cost": 0.008,
|
||||
"output_cost": 0.002
|
||||
}
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.input_tokens == 100
|
||||
assert result.total_cost == 0.010
|
||||
assert result.input_cost == 0.008
|
||||
assert result.output_cost == 0.002
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 5: Cost at Event Root Level
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_cost_extracted_from_event_root() -> None:
|
||||
"""Costs at event root should be extracted (OpenRouter style)."""
|
||||
event = {
|
||||
"type": "content_block_delta",
|
||||
"usage": {"output_tokens": 25},
|
||||
"total_cost": 0.005, # ← At root level
|
||||
"cost": 0.005
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.output_tokens == 25
|
||||
assert result.total_cost == 0.005
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 6: Cost Details in Event Root
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_cost_details_extracted_from_event_root() -> None:
|
||||
"""cost_details at event root should be extracted correctly."""
|
||||
event = {
|
||||
"type": "message_delta",
|
||||
"cost_details": {
|
||||
"total_cost": 0.015,
|
||||
"input_cost": 0.010,
|
||||
"output_cost": 0.005
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.total_cost == 0.015
|
||||
assert result.input_cost == 0.010
|
||||
assert result.output_cost == 0.005
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 7: No Duplicated Dict Lookups
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_annotate_event_no_duplicate_lookups() -> None:
|
||||
"""Verify that dict lookups are not duplicated (fix for copy-paste error)."""
|
||||
# This is tested implicitly through proper extraction
|
||||
event = {
|
||||
"type": "message_delta",
|
||||
"cost_details": {
|
||||
"total_cost": 0.020,
|
||||
"input_cost": 0.015,
|
||||
"output_cost": 0.005
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
# Should extract each field exactly once, correctly
|
||||
assert result.total_cost == 0.020
|
||||
assert result.input_cost == 0.015
|
||||
assert result.output_cost == 0.005
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 8: Model Name Extraction
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_model_extracted_from_event() -> None:
|
||||
"""Model name should be extracted from event."""
|
||||
event = {
|
||||
"type": "message_start",
|
||||
"message": {"model": "claude-3-5-sonnet"}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.model == "claude-3-5-sonnet"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 9: Model Name Override
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_model_name_override() -> None:
|
||||
"""Requested model should override actual model in event."""
|
||||
event = {
|
||||
"type": "message_start",
|
||||
"message": {"model": "actual-model"}
|
||||
}
|
||||
# Override with requested_model
|
||||
result = annotate_event(event, requested_model="alias-model")
|
||||
|
||||
# Event should be modified to use requested_model
|
||||
assert event["message"]["model"] == "alias-model" # type: ignore[index]
|
||||
assert result.model == "alias-model"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 10: SSE Encoding
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_sse_bytes_encoded() -> None:
|
||||
"""Event should be encoded as SSE bytes."""
|
||||
event = {
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.sse_bytes is not None
|
||||
assert isinstance(result.sse_bytes, bytes)
|
||||
assert b"event: content_block_start" in result.sse_bytes or b"data:" in result.sse_bytes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 11: Missing Cost Fields Default to Zero
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_missing_cost_fields_default_to_zero() -> None:
|
||||
"""When cost fields are missing, should default to 0.0."""
|
||||
event = {
|
||||
"type": "content_block_delta",
|
||||
"usage": {"output_tokens": 25}
|
||||
# No cost fields
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.total_cost == 0.0
|
||||
assert result.input_cost == 0.0
|
||||
assert result.output_cost == 0.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 12: Cache Tokens in Events
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_cache_tokens_extracted_from_event() -> None:
|
||||
"""Cache tokens should be extracted from event usage."""
|
||||
event = {
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 200,
|
||||
"cache_creation_input_tokens": 0
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.input_tokens == 100
|
||||
assert result.output_tokens == 50
|
||||
assert result.cache_read_input_tokens == 200
|
||||
assert result.cache_creation_input_tokens == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 13: Malformed Cost Values
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_malformed_cost_values_coerced() -> None:
|
||||
"""Malformed cost values should be coerced to 0.0."""
|
||||
event = {
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"total_cost": "invalid", # ← Non-numeric
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
# Invalid cost should default to 0.0
|
||||
assert result.total_cost == 0.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 14: Negative Cost Values
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_negative_cost_values_clamped() -> None:
|
||||
"""Negative cost values should be clamped to 0.0."""
|
||||
event = {
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"total_cost": -0.05 # ← Negative
|
||||
}
|
||||
}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
# Negative cost should be clamped to 0.0
|
||||
assert result.total_cost == 0.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 15: Streaming Event Type Preserved
|
||||
# ============================================================================
|
||||
@pytest.mark.unit
|
||||
def test_event_type_preserved_in_sse() -> None:
|
||||
"""Event type should be preserved in SSE encoding."""
|
||||
event_types = ["message_start", "content_block_start", "content_block_delta", "message_delta"]
|
||||
for event_type in event_types:
|
||||
event = {"type": event_type}
|
||||
result = annotate_event(event, None)
|
||||
|
||||
assert result.event["type"] == event_type # type: ignore[index]
|
||||
# SSE should include the event type line if present
|
||||
if event_type:
|
||||
assert f"event: {event_type}".encode() in result.sse_bytes
|
||||
1310
tests/unit/test_messages_litellm_dispatch.py
Normal file
1310
tests/unit/test_messages_litellm_dispatch.py
Normal file
File diff suppressed because it is too large
Load Diff
117
tests/unit/test_models_refresh_loop.py
Normal file
117
tests/unit/test_models_refresh_loop.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Regression tests for the periodic upstream models refresh loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||
|
||||
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||
|
||||
|
||||
class _FakeUpstream:
|
||||
"""Minimal stand-in for BaseUpstreamProvider used by the refresh loop.
|
||||
|
||||
Only ``base_url`` (for error logging) and ``refresh_models_cache`` (the call
|
||||
under test) are exercised; everything else stays unused.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.base_url = f"http://{name}"
|
||||
self.refresh_models_cache = AsyncMock()
|
||||
|
||||
|
||||
def _make_fake_upstream(name: str) -> BaseUpstreamProvider:
|
||||
# The loop only uses duck-typed attributes — cast keeps the test type-clean
|
||||
# without dragging in BaseUpstreamProvider's full constructor.
|
||||
return cast(BaseUpstreamProvider, _FakeUpstream(name))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_loop_picks_up_providers_added_after_startup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If a provider is added after the loop starts (e.g. via reinitialize_upstreams),
|
||||
the next loop iteration must refresh it. Previously the loop captured the upstream
|
||||
list at startup and missed any later additions."""
|
||||
from routstr.core.settings import settings as global_settings
|
||||
from routstr.upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
# Tight interval so the test finishes quickly.
|
||||
monkeypatch.setattr(
|
||||
global_settings, "models_refresh_interval_seconds", 1, raising=False
|
||||
)
|
||||
|
||||
initial_upstream = _make_fake_upstream("initial")
|
||||
live_list: list[BaseUpstreamProvider] = [initial_upstream]
|
||||
|
||||
# Stub out the post-iteration sats-pricing refresh so the loop body has no DB deps.
|
||||
async def _noop_pricing_refresh() -> None: # pragma: no cover - trivial stub
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"routstr.payment.models._update_sats_pricing_once",
|
||||
_noop_pricing_refresh,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(lambda: live_list)
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait for the first iteration to refresh the initial upstream.
|
||||
for _ in range(40):
|
||||
if initial_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert initial_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
|
||||
"loop did not refresh the initial upstream within the timeout"
|
||||
)
|
||||
|
||||
# Simulate reinitialize_upstreams: replace the live list contents with new
|
||||
# provider instances. The loop must observe the swap on its next tick.
|
||||
new_upstream = _make_fake_upstream("added-after-startup")
|
||||
live_list[:] = [new_upstream]
|
||||
|
||||
for _ in range(60):
|
||||
if new_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert new_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
|
||||
"loop did not refresh the upstream added after startup — "
|
||||
"regression: list snapshot captured at startup"
|
||||
)
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_loop_disabled_when_interval_non_positive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from routstr.core.settings import settings as global_settings
|
||||
from routstr.upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
monkeypatch.setattr(
|
||||
global_settings, "models_refresh_interval_seconds", 0, raising=False
|
||||
)
|
||||
|
||||
upstream = _make_fake_upstream("never-refreshed")
|
||||
|
||||
# Loop must return immediately without ever touching the upstream.
|
||||
await asyncio.wait_for(
|
||||
refresh_upstreams_models_periodically(lambda: [upstream]),
|
||||
timeout=1.0,
|
||||
)
|
||||
upstream.refresh_models_cache.assert_not_awaited() # type: ignore[attr-defined]
|
||||
129
tests/unit/test_provider_field_injection.py
Normal file
129
tests/unit/test_provider_field_injection.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from routstr.upstream.anthropic import AnthropicUpstreamProvider
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
from routstr.upstream.openrouter import OpenRouterUpstreamProvider
|
||||
|
||||
|
||||
def _make_provider(cls: type, provider_type: str) -> BaseUpstreamProvider:
|
||||
p = cls(api_key="test_key")
|
||||
assert p.provider_type == provider_type
|
||||
return p
|
||||
|
||||
|
||||
def test_apply_provider_field_direct_upstream() -> None:
|
||||
"""For a direct upstream (no upstream-reported provider), the field
|
||||
is just the provider_type string."""
|
||||
p = _make_provider(AnthropicUpstreamProvider, "anthropic")
|
||||
data: dict = {"id": "msg_1", "model": "claude-3-5-sonnet"}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "anthropic"
|
||||
|
||||
|
||||
def test_apply_provider_field_openrouter_passthrough() -> None:
|
||||
"""OpenRouter responses include an upstream ``provider`` string —
|
||||
routstr should prefix with its own provider_type."""
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {
|
||||
"id": "gen-abc",
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
"provider": "Anthropic",
|
||||
}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "openrouter:Anthropic"
|
||||
|
||||
|
||||
def test_apply_provider_field_openrouter_no_upstream_provider() -> None:
|
||||
"""If OpenRouter omits the provider field, the real serving provider is
|
||||
unknown — a bare ``openrouter`` value carries no information."""
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"id": "gen-abc"}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "unknown"
|
||||
|
||||
|
||||
def test_apply_provider_field_openrouter_echoes_router_name() -> None:
|
||||
"""If OpenRouter reports its own name as the provider, treat as unknown."""
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"provider": "openrouter"}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "unknown"
|
||||
|
||||
|
||||
def test_apply_provider_field_openrouter_idempotent_no_double_prefix() -> None:
|
||||
"""Re-stamping must never nest the prefix: openrouter only once."""
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"provider": "GMICloud"}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "openrouter:GMICloud"
|
||||
# Second pass (e.g. streaming) keeps a single prefix.
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "openrouter:GMICloud"
|
||||
|
||||
|
||||
def test_apply_provider_field_openrouter_collapses_existing_double_prefix() -> None:
|
||||
"""A pre-existing double prefix is collapsed to a single one."""
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"provider": "openrouter:openrouter:GMICloud"}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "openrouter:GMICloud"
|
||||
|
||||
|
||||
def test_apply_provider_field_strips_whitespace() -> None:
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"provider": " Fireworks "}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "openrouter:Fireworks"
|
||||
|
||||
|
||||
def test_apply_provider_field_blank_upstream_treated_as_missing() -> None:
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"provider": " "}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "unknown"
|
||||
|
||||
|
||||
def test_apply_provider_field_non_string_upstream_treated_as_missing() -> None:
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
data: dict = {"provider": 42}
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "unknown"
|
||||
|
||||
|
||||
def test_apply_provider_field_idempotent_for_direct_upstream() -> None:
|
||||
"""Calling twice on a direct upstream payload keeps the same value and
|
||||
never nests the prefix (no ``anthropic:anthropic``)."""
|
||||
p = _make_provider(AnthropicUpstreamProvider, "anthropic")
|
||||
data: dict = {}
|
||||
p._apply_provider_field(data)
|
||||
p._apply_provider_field(data)
|
||||
assert data["provider"] == "anthropic"
|
||||
|
||||
|
||||
def test_apply_provider_field_ignores_non_dict() -> None:
|
||||
"""Lists / primitives must be skipped silently."""
|
||||
p = _make_provider(AnthropicUpstreamProvider, "anthropic")
|
||||
# Should not raise.
|
||||
p._apply_provider_field([1, 2, 3]) # type: ignore[arg-type]
|
||||
p._apply_provider_field("hello") # type: ignore[arg-type]
|
||||
p._apply_provider_field(None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_inject_cost_metadata_sets_provider() -> None:
|
||||
"""``inject_cost_metadata`` is the unified injection point and must
|
||||
also stamp the provider field."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
|
||||
key = MagicMock(spec=ApiKey)
|
||||
key.balance = 1000
|
||||
|
||||
response_json: dict = {
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
"provider": "Anthropic",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
}
|
||||
cost_data = {"total_msats": 2500, "total_usd": 0.0025}
|
||||
p.inject_cost_metadata(response_json, cost_data, key)
|
||||
|
||||
assert response_json["provider"] == "openrouter:Anthropic"
|
||||
70
tests/unit/test_proxy_not_found.py
Normal file
70
tests/unit/test_proxy_not_found.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for the app-level 404 handler in routstr.core.main."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routstr.core import main as core_main
|
||||
|
||||
|
||||
def _make_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_api_route(
|
||||
"/{path:path}",
|
||||
core_main.not_found_catch_all,
|
||||
methods=["GET", "POST"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
core_main._NOT_FOUND_HTML is None,
|
||||
reason="UI bundle (ui_out/404.html) not present in this environment",
|
||||
)
|
||||
def test_unknown_path_returns_html_404_for_browser() -> None:
|
||||
client = TestClient(_make_app())
|
||||
response = client.get("/some/random/page", headers={"accept": "text/html"})
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert "404" in response.text
|
||||
|
||||
|
||||
def test_unknown_path_returns_json_404_for_api_client() -> None:
|
||||
client = TestClient(_make_app())
|
||||
response = client.get(
|
||||
"/some/random/page", headers={"accept": "application/json"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
payload = response.json()
|
||||
assert payload["error"]["type"] == "not_found"
|
||||
assert payload["error"]["code"] == 404
|
||||
assert "/some/random/page" in payload["error"]["message"]
|
||||
|
||||
|
||||
def test_root_path_returns_404() -> None:
|
||||
client = TestClient(_make_app())
|
||||
response = client.get("/", headers={"accept": "application/json"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_json_returned_when_ui_html_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from routstr.core import not_found as nf
|
||||
|
||||
monkeypatch.setattr(nf, "_NOT_FOUND_HTML", None)
|
||||
client = TestClient(_make_app())
|
||||
response = client.get("/some/random/page", headers={"accept": "text/html"})
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
|
||||
|
||||
def test_post_unknown_path_returns_json_even_for_browser() -> None:
|
||||
client = TestClient(_make_app())
|
||||
response = client.post("/some/random/page", headers={"accept": "text/html"})
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
payload = response.json()
|
||||
assert payload["error"]["type"] == "not_found"
|
||||
156
tests/unit/test_request_correction.py
Normal file
156
tests/unit/test_request_correction.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Unit tests for the reactive request-correction layer.
|
||||
|
||||
Covers the recovery path that lets a request survive a 400 where the upstream
|
||||
names a single unsupported request param (e.g. newer Anthropic models
|
||||
deprecating ``temperature``): the param is stripped from the JSON body and the
|
||||
same upstream is retried, provider-agnostically, keyed off the error text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.responses import Response
|
||||
|
||||
from routstr.upstream.request_correction import (
|
||||
Correction,
|
||||
correct_request,
|
||||
extract_error_message,
|
||||
strip_unsupported_param,
|
||||
)
|
||||
|
||||
|
||||
def _body(**kwargs: object) -> bytes:
|
||||
return json.dumps(kwargs).encode()
|
||||
|
||||
|
||||
class TestCorrectRequest:
|
||||
def test_strips_deprecated_temperature(self) -> None:
|
||||
body = _body(model="claude-opus-4-8", temperature=1, messages=[])
|
||||
result = correct_request(
|
||||
body, "`temperature` is deprecated for this model.", set()
|
||||
)
|
||||
assert isinstance(result, Correction)
|
||||
assert result.label == "temperature"
|
||||
decoded = json.loads(result.body)
|
||||
assert "temperature" not in decoded
|
||||
assert decoded["model"] == "claude-opus-4-8"
|
||||
|
||||
def test_strips_not_supported_param(self) -> None:
|
||||
body = _body(model="m", top_p=0.9, messages=[])
|
||||
result = correct_request(body, "Parameter 'top_p' is not supported", set())
|
||||
assert result is not None
|
||||
assert result.label == "top_p"
|
||||
assert "top_p" not in json.loads(result.body)
|
||||
|
||||
def test_returns_none_when_label_already_applied(self) -> None:
|
||||
body = _body(model="m", temperature=1)
|
||||
assert (
|
||||
correct_request(body, "`temperature` is deprecated", {"temperature"})
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_when_param_absent_from_body(self) -> None:
|
||||
body = _body(model="m", messages=[])
|
||||
assert correct_request(body, "`temperature` is deprecated", set()) is None
|
||||
|
||||
def test_returns_none_when_message_does_not_match(self) -> None:
|
||||
body = _body(model="m", temperature=1)
|
||||
assert correct_request(body, "Insufficient balance", set()) is None
|
||||
|
||||
def test_returns_none_on_empty_inputs(self) -> None:
|
||||
assert correct_request(b"", "`temperature` is deprecated", set()) is None
|
||||
assert correct_request(_body(temperature=1), "", set()) is None
|
||||
|
||||
def test_returns_none_on_non_object_body(self) -> None:
|
||||
assert correct_request(b"[1, 2, 3]", "`temperature` is deprecated", set()) is None
|
||||
|
||||
def test_deprecated_model_name_is_not_stripped_as_param(self) -> None:
|
||||
"""A 'model is deprecated' error must not strip an unrelated body field.
|
||||
|
||||
The regex matches the ``<token> is deprecated`` wording, but the
|
||||
``param not in body`` guard means a deprecated *model* name (not a
|
||||
request param) yields no correction rather than a false strip.
|
||||
"""
|
||||
body = _body(model="gpt-3", temperature=1, messages=[])
|
||||
assert correct_request(body, "`gpt-3` is deprecated, use gpt-4", set()) is None
|
||||
|
||||
def test_streaming_400_buffered_error_is_correctable(self) -> None:
|
||||
"""Streaming 400s funnel through a buffered JSON Response, so the same
|
||||
correction path applies as for non-streaming requests."""
|
||||
# Mirrors forward_upstream_error_response's buffered JSON envelope.
|
||||
resp = Response(
|
||||
content=json.dumps(
|
||||
{"error": {"message": "`temperature` is deprecated for this model"}}
|
||||
).encode(),
|
||||
status_code=400,
|
||||
)
|
||||
body = _body(model="claude-opus-4-8", temperature=1, messages=[])
|
||||
result = correct_request(body, extract_error_message(resp), set())
|
||||
assert isinstance(result, Correction)
|
||||
assert result.label == "temperature"
|
||||
assert "temperature" not in json.loads(result.body)
|
||||
|
||||
|
||||
class TestStripUnsupportedParam:
|
||||
def test_does_not_mutate_input(self) -> None:
|
||||
body = {"model": "m", "temperature": 1}
|
||||
result = strip_unsupported_param(body, "`temperature` is deprecated")
|
||||
assert result is not None
|
||||
new_body, param = result
|
||||
assert param == "temperature"
|
||||
assert "temperature" not in new_body
|
||||
# original untouched (immutability)
|
||||
assert body == {"model": "m", "temperature": 1}
|
||||
|
||||
def test_declines_when_no_match(self) -> None:
|
||||
assert strip_unsupported_param({"temperature": 1}, "nope") is None
|
||||
|
||||
|
||||
class TestExtractErrorMessage:
|
||||
def test_extracts_nested_error_message(self) -> None:
|
||||
resp = Response(
|
||||
content=json.dumps(
|
||||
{"error": {"message": "`temperature` is deprecated", "type": "x"}}
|
||||
).encode(),
|
||||
status_code=400,
|
||||
)
|
||||
assert extract_error_message(resp) == "`temperature` is deprecated"
|
||||
|
||||
def test_extracts_string_error(self) -> None:
|
||||
resp = Response(
|
||||
content=json.dumps({"error": "bad request"}).encode(), status_code=400
|
||||
)
|
||||
assert extract_error_message(resp) == "bad request"
|
||||
|
||||
def test_extracts_top_level_message(self) -> None:
|
||||
resp = Response(
|
||||
content=json.dumps({"message": "nope"}).encode(), status_code=400
|
||||
)
|
||||
assert extract_error_message(resp) == "nope"
|
||||
|
||||
def test_empty_body_returns_empty_string(self) -> None:
|
||||
assert extract_error_message(Response(status_code=400)) == ""
|
||||
|
||||
def test_non_json_body_returns_preview(self) -> None:
|
||||
resp = Response(content=b"plain text error", status_code=400)
|
||||
assert extract_error_message(resp) == "plain text error"
|
||||
|
||||
|
||||
class TestEndToEndChaining:
|
||||
def test_two_distinct_params_corrected_sequentially(self) -> None:
|
||||
"""Simulates the proxy loop: each 400 fixes one param, set guards reuse."""
|
||||
body = _body(model="m", temperature=1, top_p=0.5, messages=[])
|
||||
applied: set[str] = set()
|
||||
|
||||
first = correct_request(body, "`temperature` is deprecated", applied)
|
||||
assert first is not None
|
||||
body, applied = first.body, applied | {first.label}
|
||||
|
||||
second = correct_request(body, "`top_p` is not supported", applied)
|
||||
assert second is not None
|
||||
body, applied = second.body, applied | {second.label}
|
||||
|
||||
decoded = json.loads(body)
|
||||
assert "temperature" not in decoded and "top_p" not in decoded
|
||||
assert applied == {"temperature", "top_p"}
|
||||
@@ -1,11 +1,12 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from pydantic.v1 import ValidationError
|
||||
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
|
||||
from routstr.core.settings import Settings, SettingsService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -46,6 +47,54 @@ async def test_settings_db_precedence_over_env() -> None:
|
||||
assert again.enable_analytics_sharing is False
|
||||
|
||||
|
||||
def test_payout_settings_have_sensible_defaults() -> None:
|
||||
s = Settings()
|
||||
assert s.min_payout_sat == 210
|
||||
assert s.payout_interval_seconds == 900
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,bad_value",
|
||||
[
|
||||
("min_payout_sat", 0),
|
||||
("min_payout_sat", -1),
|
||||
("payout_interval_seconds", 0),
|
||||
("payout_interval_seconds", -10),
|
||||
],
|
||||
)
|
||||
def test_payout_settings_reject_invalid_values(field: str, bad_value: int) -> None:
|
||||
kwargs: dict[str, object] = {field: bad_value}
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_payout_settings_accept_custom_positive_values() -> None:
|
||||
s = Settings(min_payout_sat=500, payout_interval_seconds=60)
|
||||
assert s.min_payout_sat == 500
|
||||
assert s.payout_interval_seconds == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_settings_persist_via_settings_service() -> None:
|
||||
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(
|
||||
{"min_payout_sat": 1000, "payout_interval_seconds": 300}, session
|
||||
)
|
||||
assert updated.min_payout_sat == 1000
|
||||
assert updated.payout_interval_seconds == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payout_settings_update_rejects_invalid() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
await SettingsService.initialize(session)
|
||||
with pytest.raises(ValidationError):
|
||||
await SettingsService.update({"min_payout_sat": 0}, session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_initialize_discards_unknown_keys() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
378
tests/unit/test_stale_reservations.py
Normal file
378
tests/unit/test_stale_reservations.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Tests for stale reserved_balance handling (issue #551).
|
||||
|
||||
Covers:
|
||||
- pay_for_request stamping reserved_at on billing and child keys
|
||||
- release_stale_reservations sweeper semantics
|
||||
- reset_all_reserved_balances clearing reserved_at
|
||||
- refund endpoint self-healing stale/legacy reservations
|
||||
- proxy reverting the reservation when the client disconnects (CancelledError)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import pay_for_request
|
||||
from routstr.balance import refund_wallet_endpoint
|
||||
from routstr.core.db import (
|
||||
ApiKey,
|
||||
release_stale_reservations,
|
||||
reset_all_reserved_balances,
|
||||
)
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session() -> "AsyncGenerator[AsyncSession, None]":
|
||||
engine = _make_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
db_session = AsyncSession(engine, expire_on_commit=False)
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
await db_session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pay_for_request stamps reserved_at
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_sets_reserved_at(session: AsyncSession) -> None:
|
||||
key = ApiKey(hashed_key="paykey", balance=10_000)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
before = int(time.time())
|
||||
await pay_for_request(key, 1_000, session)
|
||||
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 1_000
|
||||
assert key.reserved_at is not None
|
||||
assert key.reserved_at >= before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_for_request_sets_reserved_at_on_child_key(session: AsyncSession) -> None:
|
||||
parent = ApiKey(hashed_key="parentkey", balance=10_000)
|
||||
child = ApiKey(hashed_key="childkey", balance=0, parent_key_hash="parentkey")
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
await pay_for_request(child, 1_000, session)
|
||||
|
||||
await session.refresh(parent)
|
||||
await session.refresh(child)
|
||||
assert parent.reserved_balance == 1_000
|
||||
assert parent.reserved_at is not None
|
||||
assert child.reserved_balance == 1_000
|
||||
assert child.reserved_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_clears_reserved_at_when_fully_released(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
key = ApiKey(hashed_key="revertkey", balance=10_000)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
await pay_for_request(key, 1_000, session)
|
||||
reverted = await revert_pay_for_request(key, session, 1_000)
|
||||
|
||||
assert reverted is True
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 0
|
||||
assert key.reserved_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_keeps_reserved_at_while_other_reservations_remain(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
key = ApiKey(hashed_key="partialrevert", balance=10_000)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
await pay_for_request(key, 1_000, session)
|
||||
await pay_for_request(key, 1_000, session)
|
||||
reverted = await revert_pay_for_request(key, session, 1_000)
|
||||
|
||||
assert reverted is True
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 1_000
|
||||
assert key.reserved_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release_stale_reservations sweeper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_stale_reservations_releases_old(session: AsyncSession) -> None:
|
||||
key = ApiKey(
|
||||
hashed_key="stalekey",
|
||||
balance=5_000,
|
||||
reserved_balance=1_000,
|
||||
reserved_at=int(time.time()) - 1_000,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
released = await release_stale_reservations(session, max_age_seconds=300)
|
||||
|
||||
assert released == 1
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 0
|
||||
assert key.reserved_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_stale_reservations_keeps_fresh(session: AsyncSession) -> None:
|
||||
key = ApiKey(
|
||||
hashed_key="freshkey",
|
||||
balance=5_000,
|
||||
reserved_balance=1_000,
|
||||
reserved_at=int(time.time()),
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
released = await release_stale_reservations(session, max_age_seconds=300)
|
||||
|
||||
assert released == 0
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 1_000
|
||||
assert key.reserved_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_stale_reservations_skips_null_reserved_at(session: AsyncSession) -> None:
|
||||
# Reservations without a timestamp may belong to instances running older
|
||||
# code (rolling deploy) — the background sweeper must not touch them.
|
||||
key = ApiKey(
|
||||
hashed_key="legacykey",
|
||||
balance=5_000,
|
||||
reserved_balance=1_000,
|
||||
reserved_at=None,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
released = await release_stale_reservations(session, max_age_seconds=300)
|
||||
|
||||
assert released == 0
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 1_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_all_reserved_balances_clears_reserved_at(session: AsyncSession) -> None:
|
||||
key = ApiKey(
|
||||
hashed_key="resetkey",
|
||||
balance=5_000,
|
||||
reserved_balance=1_000,
|
||||
reserved_at=int(time.time()),
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
await reset_all_reserved_balances(session)
|
||||
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 0
|
||||
assert key.reserved_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refund endpoint self-healing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _refund_patches(refund_token: str = "cashuArefund"): # type: ignore[no-untyped-def]
|
||||
return (
|
||||
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()),
|
||||
)
|
||||
|
||||
|
||||
async def _add_key(session: AsyncSession, **kwargs) -> ApiKey: # type: ignore[no-untyped-def]
|
||||
key = ApiKey(refund_currency="sat", **kwargs)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
return key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_self_heals_stale_reservation(session: AsyncSession) -> None:
|
||||
key = await _add_key(
|
||||
session,
|
||||
hashed_key="stalerefund",
|
||||
balance=5_000,
|
||||
reserved_balance=2_000,
|
||||
reserved_at=int(time.time()) - 10_000,
|
||||
)
|
||||
|
||||
p1, p2, p3, p4 = _refund_patches()
|
||||
with p1, p2, p3, p4:
|
||||
result = await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-stalerefund",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["token"] == "cashuArefund"
|
||||
# Full balance refunded (5000 msats -> 5 sats), reservation healed
|
||||
assert result["sats"] == "5"
|
||||
await session.refresh(key)
|
||||
assert key.balance == 0
|
||||
assert key.reserved_balance == 0
|
||||
assert key.reserved_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_self_heals_legacy_null_reserved_at(session: AsyncSession) -> None:
|
||||
# Keys stuck from before reserved_at existed must be refundable.
|
||||
key = await _add_key(
|
||||
session,
|
||||
hashed_key="legacyrefund",
|
||||
balance=5_000,
|
||||
reserved_balance=2_000,
|
||||
reserved_at=None,
|
||||
)
|
||||
|
||||
p1, p2, p3, p4 = _refund_patches()
|
||||
with p1, p2, p3, p4:
|
||||
result = await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-legacyrefund",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["token"] == "cashuArefund"
|
||||
await session.refresh(key)
|
||||
assert key.balance == 0
|
||||
assert key.reserved_balance == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_rejects_recent_reservation(session: AsyncSession) -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
await _add_key(
|
||||
session,
|
||||
hashed_key="activerefund",
|
||||
balance=5_000,
|
||||
reserved_balance=2_000,
|
||||
reserved_at=int(time.time()),
|
||||
)
|
||||
|
||||
p1, p2, p3, p4 = _refund_patches()
|
||||
with p1, p2, p3, p4:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-activerefund",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "ongoing requests" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_without_reservation_still_works(session: AsyncSession) -> None:
|
||||
key = await _add_key(
|
||||
session,
|
||||
hashed_key="plainrefund",
|
||||
balance=5_000,
|
||||
reserved_balance=0,
|
||||
)
|
||||
|
||||
p1, p2, p3, p4 = _refund_patches()
|
||||
with p1, p2, p3, p4:
|
||||
result = await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-plainrefund",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["token"] == "cashuArefund"
|
||||
await session.refresh(key)
|
||||
assert key.balance == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy reverts reservation on client disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_reverts_reservation_on_client_disconnect() -> None:
|
||||
from routstr import proxy as proxy_module
|
||||
|
||||
key = ApiKey(hashed_key="cancelkey", balance=10_000)
|
||||
|
||||
request = MagicMock()
|
||||
request.method = "POST"
|
||||
request.headers = {"authorization": "Bearer sk-cancelkey"}
|
||||
request.body = AsyncMock(return_value=b'{"model": "test-model"}')
|
||||
|
||||
upstream = MagicMock()
|
||||
upstream.provider_type = "test"
|
||||
upstream.prepare_headers = MagicMock(side_effect=lambda h: h)
|
||||
upstream.forward_request = AsyncMock(side_effect=asyncio.CancelledError())
|
||||
|
||||
session = MagicMock()
|
||||
revert_mock = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch.object(proxy_module, "get_model_instance", return_value=MagicMock()),
|
||||
patch.object(proxy_module, "get_provider_for_model", return_value=[upstream]),
|
||||
patch.object(
|
||||
proxy_module, "get_max_cost_for_model", AsyncMock(return_value=1_000)
|
||||
),
|
||||
patch.object(
|
||||
proxy_module,
|
||||
"calculate_discounted_max_cost",
|
||||
AsyncMock(return_value=1_000),
|
||||
),
|
||||
patch.object(proxy_module, "check_token_balance", MagicMock()),
|
||||
patch.object(
|
||||
proxy_module, "get_bearer_token_key", AsyncMock(return_value=key)
|
||||
),
|
||||
patch.object(proxy_module, "pay_for_request", AsyncMock(return_value=1_000)),
|
||||
patch.object(proxy_module, "revert_pay_for_request", revert_mock),
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await proxy_module.proxy(request, "v1/chat/completions", session=session)
|
||||
|
||||
revert_mock.assert_awaited_once_with(key, session, 1_000)
|
||||
@@ -51,7 +51,15 @@ async def test_stream_with_id_injection() -> None:
|
||||
base.adjust_payment_for_tokens = AsyncMock(
|
||||
return_value={"total_usd": 0.1, "total_msats": 100}
|
||||
)
|
||||
base.create_session = MagicMock()
|
||||
# create_session() is used as an async context manager whose entered
|
||||
# value exposes an awaitable .get(). Build a mock that behaves that
|
||||
# way so the post-stream cost-chunk emission can run.
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = AsyncMock(return_value=key)
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
base.create_session = MagicMock(return_value=mock_ctx)
|
||||
|
||||
streaming_response = await provider.handle_streaming_chat_completion(
|
||||
response=mock_response,
|
||||
|
||||
339
tests/unit/test_streaming_sse_providers.py
Normal file
339
tests/unit/test_streaming_sse_providers.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Battle-test the streaming SSE parser against real per-provider framing.
|
||||
|
||||
Each test drives the *actual* ``handle_streaming_chat_completion`` generator
|
||||
with a mock upstream response whose ``aiter_bytes`` emits byte sequences that
|
||||
mirror what each supported provider sends on the wire (captured from the
|
||||
providers' own streaming docs):
|
||||
|
||||
* OpenAI / Groq / Fireworks / xAI / Perplexity / Azure - plain
|
||||
``data: {json}\\n\\n`` + ``data: [DONE]``.
|
||||
* OpenRouter - same, but with ``: OPENROUTER PROCESSING`` keepalive comments
|
||||
interleaved (the framing that produced the original
|
||||
``Unexpected token ':'`` client crash).
|
||||
* Gemini (native ``alt=sse``) - ``data:`` payloads framed with CRLF.
|
||||
|
||||
The invariant every provider must satisfy: every line the proxy emits that
|
||||
starts with ``data: `` either equals ``[DONE]`` or is valid JSON, and no SSE
|
||||
comment ever reaches the client. That invariant is exactly what the buggy
|
||||
``re.split(b"data: ")`` parser violated for OpenRouter.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.upstream import base
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
|
||||
def _make_response(chunks: list[bytes]) -> MagicMock:
|
||||
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
|
||||
return mock_response
|
||||
|
||||
|
||||
async def _drive(chunks: list[bytes], requested_model: str | None = None) -> list[bytes]:
|
||||
"""Run the real streaming generator over ``chunks`` and collect output bytes."""
|
||||
provider = BaseUpstreamProvider(
|
||||
base_url="https://api.example.com", api_key="test_key"
|
||||
)
|
||||
|
||||
key = MagicMock(spec=ApiKey)
|
||||
key.hashed_key = "test_hash"
|
||||
key.balance = 1000
|
||||
|
||||
base.adjust_payment_for_tokens = AsyncMock(
|
||||
return_value={"total_usd": 0.1, "total_msats": 100}
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = AsyncMock(return_value=key)
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
base.create_session = MagicMock(return_value=mock_ctx)
|
||||
|
||||
streaming_response = await provider.handle_streaming_chat_completion(
|
||||
response=_make_response(chunks),
|
||||
key=key,
|
||||
max_cost_for_model=100,
|
||||
background_tasks=MagicMock(),
|
||||
requested_model=requested_model,
|
||||
)
|
||||
|
||||
out: list[bytes] = []
|
||||
async for chunk in streaming_response.body_iterator:
|
||||
if isinstance(chunk, str):
|
||||
out.append(chunk.encode())
|
||||
else:
|
||||
out.append(bytes(chunk))
|
||||
return out
|
||||
|
||||
|
||||
def _data_payloads(out: list[bytes]) -> list[bytes]:
|
||||
"""Return the raw payload of every ``data: `` line across all emitted bytes."""
|
||||
payloads: list[bytes] = []
|
||||
for chunk in out:
|
||||
for line in chunk.split(b"\n"):
|
||||
if line.startswith(b"data: "):
|
||||
payloads.append(line[len(b"data: ") :])
|
||||
return payloads
|
||||
|
||||
|
||||
def _assert_clean(out: list[bytes]) -> list[dict]:
|
||||
"""Core invariant: every data line is [DONE] or valid JSON; no comments leak."""
|
||||
blob = b"".join(out)
|
||||
# No SSE comment line must ever reach the client.
|
||||
for line in blob.split(b"\n"):
|
||||
assert not line.startswith(b":"), f"comment leaked to client: {line!r}"
|
||||
# The original bug signature: a data line whose value is itself a comment.
|
||||
assert not line.startswith(b"data: :"), f"mangled comment frame: {line!r}"
|
||||
|
||||
objs: list[dict] = []
|
||||
for payload in _data_payloads(out):
|
||||
stripped = payload.strip()
|
||||
if stripped == b"[DONE]":
|
||||
continue
|
||||
obj = json.loads(stripped) # raises if the proxy emitted non-JSON data
|
||||
objs.append(obj)
|
||||
return objs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_style_plain_stream() -> None:
|
||||
"""OpenAI / Groq / Fireworks / xAI / Perplexity: plain data + [DONE]."""
|
||||
chunks = [
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
assert any(o.get("choices") for o in objs)
|
||||
assert b"data: [DONE]\n\n" in b"".join(out)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_keepalive_comments() -> None:
|
||||
"""OpenRouter ``: OPENROUTER PROCESSING`` keepalives must never crash clients.
|
||||
|
||||
This is the exact regression: the old parser emitted
|
||||
``data: : OPENROUTER PROCESSING`` which made downstream
|
||||
``JSON.parse`` throw ``Unexpected token ':'``.
|
||||
"""
|
||||
chunks = [
|
||||
b": OPENROUTER PROCESSING\n\n",
|
||||
b": OPENROUTER PROCESSING\n\n",
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"Hi"}}]}\n\n',
|
||||
b": OPENROUTER PROCESSING\n\n",
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"!"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
# The keepalive must be gone entirely.
|
||||
assert b"OPENROUTER PROCESSING" not in b"".join(out)
|
||||
# Real content survived.
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
assert "Hi" in contents and "!" in contents
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_comment_glued_to_data_chunk() -> None:
|
||||
"""Keepalive packed into the same TCP chunk as data (the harder case)."""
|
||||
chunks = [
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"a"}}]}\n\n'
|
||||
b": OPENROUTER PROCESSING\n\n"
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"b"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
assert contents == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_split_across_chunk_boundary() -> None:
|
||||
"""A single event's JSON arriving in two TCP reads must reassemble."""
|
||||
chunks = [
|
||||
b'data: {"id":"x","choices":[{"delta":{"con',
|
||||
b'tent":"split"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
assert contents == ["split"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byte_by_byte_fragmentation() -> None:
|
||||
"""Pathological framing: one byte per chunk. Must still parse cleanly."""
|
||||
raw = (
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"drip"}}]}\n\n'
|
||||
b": OPENROUTER PROCESSING\n\n"
|
||||
b"data: [DONE]\n\n"
|
||||
)
|
||||
chunks = [raw[i : i + 1] for i in range(len(raw))]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
assert objs and objs[0]["choices"][0]["delta"]["content"] == "drip"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_crlf_framing() -> None:
|
||||
"""Gemini native (alt=sse) frames events with CRLF."""
|
||||
chunks = [
|
||||
b'data: {"id":"g","choices":[{"delta":{"content":"hej"}}]}\r\n\r\n',
|
||||
b'data: {"id":"g","choices":[{"delta":{"content":"!"}}]}\r\n\r\n',
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
assert contents == ["hej", "!"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_leading_role_chunk() -> None:
|
||||
"""Azure OpenAI opens with a content-filter / role-only chunk."""
|
||||
chunks = [
|
||||
b'data: {"id":"az","choices":[],"prompt_filter_results":[]}\n\n',
|
||||
b'data: {"id":"az","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||
b'data: {"id":"az","choices":[{"delta":{"content":"ok"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
_assert_clean(out)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_mid_stream_error_event() -> None:
|
||||
"""OpenRouter mid-stream errors arrive as a normal data JSON event."""
|
||||
err = {
|
||||
"id": "x",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": "openai/gpt-4o",
|
||||
"error": {"code": "server_error", "message": "Provider disconnected"},
|
||||
"choices": [{"index": 0, "delta": {"content": ""}, "finish_reason": "error"}],
|
||||
}
|
||||
chunks = [
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"partial"}}]}\n\n',
|
||||
b"data: " + json.dumps(err).encode() + b"\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
assert any("error" in o for o in objs), "error event must be forwarded intact"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_combined_content_and_usage_chunk() -> None:
|
||||
"""Gemini thinking models pack usage into the final *content* chunk.
|
||||
|
||||
Regression: the parser swallowed any chunk carrying a ``usage`` dict, so
|
||||
when content + usage arrived together the assistant text was dropped and
|
||||
the client saw "no assistant messages" despite a 200 + token accounting.
|
||||
"""
|
||||
chunks = [
|
||||
b'data: {"id":"g","choices":[{"delta":{"content":"the answer"},'
|
||||
b'"finish_reason":"stop"}],"usage":{"prompt_tokens":3,'
|
||||
b'"completion_tokens":2,"total_tokens":5}}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
# Content delivered exactly once (not dropped, not duplicated by the trailer).
|
||||
assert contents == ["the answer"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_separate_usage_chunk_not_forwarded_as_content() -> None:
|
||||
"""A pure usage chunk (choices: []) is still swallowed, content intact."""
|
||||
chunks = [
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"hello"}}]}\n\n',
|
||||
b'data: {"id":"x","choices":[],"usage":{"total_tokens":4}}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out)
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
assert contents == ["hello"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requested_model_override_applied() -> None:
|
||||
"""Model rewriting still works through the buffered parser."""
|
||||
chunks = [
|
||||
b'data: {"id":"x","model":"upstream-model","choices":[{"delta":{"content":"hi"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks, requested_model="routstr-model")
|
||||
objs = _assert_clean(out)
|
||||
# The upstream content chunk carried model "upstream-model"; the parser must
|
||||
# rewrite it to the requested model. (The trailing routstr-generated usage
|
||||
# chunk is excluded - it is not an upstream-forwarded chunk.)
|
||||
content_chunks = [o for o in objs if o.get("choices")]
|
||||
assert content_chunks, "expected at least one forwarded content chunk"
|
||||
assert all(o.get("model") == "routstr-model" for o in content_chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiline_non_json_data_each_line_prefixed() -> None:
|
||||
"""A multi-line non-JSON ``data`` block must keep a ``data:`` prefix per line.
|
||||
|
||||
Two ``data:`` lines in one event reassemble to ``line one\\nline two``, which
|
||||
is not JSON, so it takes the raw-forward path. The parser must re-prefix each
|
||||
line; a bare second line would reach the client without its ``data:`` field
|
||||
and break naive SSE parsers.
|
||||
"""
|
||||
chunks = [
|
||||
b"data: line one\ndata: line two\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
blob = b"".join(out)
|
||||
for line in blob.split(b"\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped == b"[DONE]":
|
||||
continue
|
||||
assert line.startswith(b"data: "), f"bare line leaked to client: {line!r}"
|
||||
assert b"data: line one" in blob and b"data: line two" in blob
|
||||
150
tests/unit/test_upstream_error_response.py
Normal file
150
tests/unit/test_upstream_error_response.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Tests for ``BaseUpstreamProvider.forward_upstream_error_response``.
|
||||
|
||||
Upstream services (e.g. an Express server that doesn't expose ``/messages``)
|
||||
sometimes return a non-JSON error body. The proxy must surface those errors
|
||||
in a consistent JSON envelope so clients don't have to parse HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.base import BaseUpstreamProvider, _is_json_content_type
|
||||
|
||||
|
||||
def _make_request(request_id: str = "req-123") -> Mock:
|
||||
request = Mock(spec=["method", "state"])
|
||||
request.method = "POST"
|
||||
request.state = Mock()
|
||||
request.state.request_id = request_id
|
||||
return request
|
||||
|
||||
|
||||
def _make_upstream_response(
|
||||
*,
|
||||
body: bytes,
|
||||
status_code: int = 404,
|
||||
content_type: str | None = "text/html",
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
headers: dict[str, str] = {}
|
||||
if content_type is not None:
|
||||
headers["content-type"] = content_type
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
return httpx.Response(status_code=status_code, headers=headers, content=body)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> BaseUpstreamProvider:
|
||||
return BaseUpstreamProvider(
|
||||
base_url="https://privateprovider.xyz", api_key="k", provider_fee=1.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content_type,expected",
|
||||
[
|
||||
("application/json", True),
|
||||
("application/json; charset=utf-8", True),
|
||||
("text/json", True),
|
||||
("application/problem+json", True),
|
||||
("application/vnd.api+json", True),
|
||||
("text/html", False),
|
||||
("text/html; charset=utf-8", False),
|
||||
("text/plain", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_is_json_content_type(content_type: str | None, expected: bool) -> None:
|
||||
assert _is_json_content_type(content_type) is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_html_error_is_normalized_to_json_envelope(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
html_body = (
|
||||
b"<!DOCTYPE html><html><head><title>Error</title></head>"
|
||||
b"<body><pre>Cannot POST /messages</pre></body></html>"
|
||||
)
|
||||
upstream = _make_upstream_response(body=html_body, status_code=404)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/messages", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.media_type == "application/json"
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["type"] == "upstream_error"
|
||||
assert payload["error"]["upstream_status"] == 404
|
||||
assert payload["error"]["upstream_content_type"] == "text/html"
|
||||
assert "Cannot POST /messages" in payload["error"]["upstream_body_preview"]
|
||||
assert payload["request_id"] == "req-123"
|
||||
# The upstream's text/html content-type must not survive — Response()
|
||||
# sets the JSON content-type for us via media_type.
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_text_error_is_normalized(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
upstream = _make_upstream_response(
|
||||
body=b"Service Unavailable", status_code=503, content_type="text/plain"
|
||||
)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/messages", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.media_type == "application/json"
|
||||
payload = json.loads(bytes(response.body))
|
||||
assert payload["error"]["message"] == "Service Unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_body_with_non_json_content_type_normalizes(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
upstream = _make_upstream_response(
|
||||
body=b"", status_code=502, content_type="text/html"
|
||||
)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/messages", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.media_type == "application/json"
|
||||
payload = json.loads(bytes(response.body))
|
||||
assert payload["error"]["type"] == "upstream_error"
|
||||
assert payload["error"]["upstream_body_preview"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_error_body_is_passed_through_unchanged(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
json_body = json.dumps(
|
||||
{"error": {"message": "Invalid model", "type": "invalid_request_error"}}
|
||||
).encode()
|
||||
upstream = _make_upstream_response(
|
||||
body=json_body, status_code=400, content_type="application/json"
|
||||
)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/messages", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert bytes(response.body) == json_body
|
||||
assert response.media_type == "application/json"
|
||||
313
tests/unit/test_upstream_gemini.py
Normal file
313
tests/unit/test_upstream_gemini.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Unit tests for the Gemini /v1/messages dispatch path.
|
||||
|
||||
The Gemini upstream needs special handling because its OpenAI-compat
|
||||
surface rejects inbound ``functionCall`` parts that lack a
|
||||
``thought_signature``. We bypass litellm + openai SDK at the wire layer
|
||||
(see ``routstr/upstream/gemini_messages.py``) so we can inject Google's
|
||||
documented dummy signature (``"skip_thought_signature_validator"``).
|
||||
|
||||
These tests cover the two pure helpers that drive the dispatcher:
|
||||
|
||||
* ``inject_thought_signatures`` — request-side injection
|
||||
* ``_openai_chunks_to_anthropic_events`` — response-side translator
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.gemini_messages import (
|
||||
DUMMY_THOUGHT_SIGNATURE,
|
||||
_openai_chunks_to_anthropic_events,
|
||||
inject_thought_signatures,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inject_thought_signatures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_thought_signatures_adds_dummy_to_each_tool_call() -> None:
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "do thing"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_1",
|
||||
"type": "function",
|
||||
"function": {"name": "Bash", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "toolu_2",
|
||||
"type": "function",
|
||||
"function": {"name": "Read", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
inject_thought_signatures(messages)
|
||||
|
||||
for tc in messages[1]["tool_calls"]:
|
||||
assert (
|
||||
tc["extra_content"]["google"]["thought_signature"]
|
||||
== DUMMY_THOUGHT_SIGNATURE
|
||||
)
|
||||
|
||||
|
||||
def test_inject_thought_signatures_preserves_existing_signature() -> None:
|
||||
"""Don't clobber a real signature that came back from a prior turn."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc1",
|
||||
"type": "function",
|
||||
"function": {"name": "fn", "arguments": "{}"},
|
||||
"extra_content": {
|
||||
"google": {"thought_signature": "real-signature"}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
inject_thought_signatures(messages)
|
||||
|
||||
assert (
|
||||
messages[0]["tool_calls"][0]["extra_content"]["google"][
|
||||
"thought_signature"
|
||||
]
|
||||
== "real-signature"
|
||||
)
|
||||
|
||||
|
||||
def test_inject_thought_signatures_skips_messages_without_tool_calls() -> None:
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
|
||||
inject_thought_signatures(messages)
|
||||
|
||||
for m in messages:
|
||||
assert "extra_content" not in m
|
||||
|
||||
|
||||
def test_inject_thought_signatures_handles_malformed_extra_content() -> None:
|
||||
"""If a caller already set ``extra_content`` to a non-dict (defensive),
|
||||
we replace it instead of crashing."""
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc",
|
||||
"type": "function",
|
||||
"function": {"name": "fn", "arguments": "{}"},
|
||||
"extra_content": "garbage",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
inject_thought_signatures(messages)
|
||||
|
||||
extra = messages[0]["tool_calls"][0]["extra_content"]
|
||||
assert isinstance(extra, dict)
|
||||
assert extra["google"]["thought_signature"] == DUMMY_THOUGHT_SIGNATURE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _openai_chunks_to_anthropic_events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _lines(*chunks: dict | str) -> AsyncGenerator[str, None]:
|
||||
"""Helper to wrap chunk dicts as SSE-style ``data:`` lines."""
|
||||
for c in chunks:
|
||||
if isinstance(c, dict):
|
||||
yield f"data: {json.dumps(c)}"
|
||||
else:
|
||||
yield c
|
||||
|
||||
|
||||
def _parse_anthropic_sse(blocks: list[bytes]) -> list[dict]:
|
||||
"""Flatten a list of Anthropic SSE byte chunks into event dicts."""
|
||||
events: list[dict] = []
|
||||
for blob in blocks:
|
||||
text = blob.decode()
|
||||
for entry in text.split("\n\n"):
|
||||
for line in entry.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].lstrip()))
|
||||
return events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translator_emits_text_only_response() -> None:
|
||||
"""Plain text response: message_start → content_block_* (text) →
|
||||
message_delta(end_turn) → message_stop."""
|
||||
chunks: list[dict] = [
|
||||
{
|
||||
"id": "chatcmpl-1",
|
||||
"model": "gemini-2.5-flash",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant"}}],
|
||||
},
|
||||
{"choices": [{"delta": {"content": "Hello"}}]},
|
||||
{"choices": [{"delta": {"content": ", world"}}]},
|
||||
{
|
||||
"choices": [{"delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 7},
|
||||
},
|
||||
]
|
||||
|
||||
out = []
|
||||
async for event_bytes in _openai_chunks_to_anthropic_events(
|
||||
_lines(*chunks), requested_model="gemini-2.5-flash"
|
||||
):
|
||||
out.append(event_bytes)
|
||||
|
||||
events = _parse_anthropic_sse(out)
|
||||
types = [e["type"] for e in events]
|
||||
assert types == [
|
||||
"message_start",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
]
|
||||
# Text deltas concatenate to "Hello, world".
|
||||
text_deltas = [
|
||||
e["delta"]["text"]
|
||||
for e in events
|
||||
if e["type"] == "content_block_delta"
|
||||
]
|
||||
assert "".join(text_deltas) == "Hello, world"
|
||||
# Stop reason was mapped from openai's "stop".
|
||||
msg_delta = next(e for e in events if e["type"] == "message_delta")
|
||||
assert msg_delta["delta"]["stop_reason"] == "end_turn"
|
||||
assert msg_delta["usage"]["input_tokens"] == 5
|
||||
assert msg_delta["usage"]["output_tokens"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translator_emits_tool_use_block() -> None:
|
||||
"""tool_calls split across deltas → tool_use content block with
|
||||
accumulated input_json_delta and stop_reason='tool_use'."""
|
||||
chunks: list[dict] = [
|
||||
{
|
||||
"id": "chatcmpl-2",
|
||||
"model": "gemini-2.5-flash",
|
||||
"choices": [{"delta": {"role": "assistant"}}],
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call-abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Bash",
|
||||
"arguments": '{"cmd":',
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"function": {"arguments": ' "ls"}'},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{"choices": [{"delta": {}, "finish_reason": "tool_calls"}]},
|
||||
]
|
||||
|
||||
out = []
|
||||
async for event_bytes in _openai_chunks_to_anthropic_events(
|
||||
_lines(*chunks), requested_model="gemini-2.5-flash"
|
||||
):
|
||||
out.append(event_bytes)
|
||||
|
||||
events = _parse_anthropic_sse(out)
|
||||
types = [e["type"] for e in events]
|
||||
assert types == [
|
||||
"message_start",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
]
|
||||
# Tool use block was opened with the right name.
|
||||
cb_start = next(e for e in events if e["type"] == "content_block_start")
|
||||
assert cb_start["content_block"]["type"] == "tool_use"
|
||||
assert cb_start["content_block"]["name"] == "Bash"
|
||||
assert cb_start["content_block"]["id"] == "call-abc"
|
||||
# Argument deltas were forwarded as input_json_delta partials.
|
||||
deltas = [e for e in events if e["type"] == "content_block_delta"]
|
||||
assert all(d["delta"]["type"] == "input_json_delta" for d in deltas)
|
||||
assert "".join(d["delta"]["partial_json"] for d in deltas) == (
|
||||
'{"cmd": "ls"}'
|
||||
)
|
||||
# tool_calls finish_reason → tool_use stop_reason.
|
||||
msg_delta = next(e for e in events if e["type"] == "message_delta")
|
||||
assert msg_delta["delta"]["stop_reason"] == "tool_use"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translator_handles_done_sentinel_and_blank_lines() -> None:
|
||||
"""Spec edge cases from openai SSE: ``data: [DONE]``, blank lines,
|
||||
invalid JSON. Translator should skip them gracefully."""
|
||||
chunks: list[dict | str] = [
|
||||
{
|
||||
"id": "x",
|
||||
"model": "m",
|
||||
"choices": [{"delta": {"role": "assistant"}}],
|
||||
},
|
||||
{"choices": [{"delta": {"content": "ok"}}]},
|
||||
"",
|
||||
": comment",
|
||||
"data: not-json",
|
||||
"data: [DONE]",
|
||||
{"choices": [{"delta": {}, "finish_reason": "stop"}]},
|
||||
]
|
||||
|
||||
out = []
|
||||
async for event_bytes in _openai_chunks_to_anthropic_events(
|
||||
_lines(*chunks), requested_model=None
|
||||
):
|
||||
out.append(event_bytes)
|
||||
|
||||
events = _parse_anthropic_sse(out)
|
||||
assert events[0]["type"] == "message_start"
|
||||
assert events[-1]["type"] == "message_stop"
|
||||
text = "".join(
|
||||
e["delta"]["text"]
|
||||
for e in events
|
||||
if e["type"] == "content_block_delta"
|
||||
)
|
||||
assert text == "ok"
|
||||
@@ -74,3 +74,39 @@ async def test_get_balance_returns_none_on_connect_timeout(
|
||||
balance = await provider.get_balance()
|
||||
|
||||
assert balance is None
|
||||
|
||||
|
||||
def test_normalize_request_path_keeps_v1_prefix() -> None:
|
||||
"""Routstr upstream stores ``base_url`` without ``/v1``; the prefix
|
||||
must stay on the path so ``build_request_url`` produces ``/v1/<endpoint>``
|
||||
instead of ``/<endpoint>`` (which the upstream Routstr 404s with HTML)."""
|
||||
provider = RoutstrUpstreamProvider(
|
||||
base_url="https://privateprovider.xyz", api_key="key"
|
||||
)
|
||||
|
||||
assert provider.normalize_request_path("v1/messages") == "v1/messages"
|
||||
assert provider.normalize_request_path("/v1/messages") == "v1/messages"
|
||||
assert (
|
||||
provider.normalize_request_path("v1/chat/completions")
|
||||
== "v1/chat/completions"
|
||||
)
|
||||
|
||||
|
||||
def test_build_request_url_for_v1_messages() -> None:
|
||||
"""Forwarding ``/v1/messages`` must hit the upstream's ``/v1/messages``."""
|
||||
provider = RoutstrUpstreamProvider(
|
||||
base_url="https://privateprovider.xyz", api_key="key"
|
||||
)
|
||||
|
||||
normalized = provider.normalize_request_path("v1/messages")
|
||||
|
||||
assert (
|
||||
provider.build_request_url(normalized)
|
||||
== "https://privateprovider.xyz/v1/messages"
|
||||
)
|
||||
|
||||
|
||||
def test_supports_anthropic_messages_natively() -> None:
|
||||
"""Routstr nodes serve ``/v1/messages`` directly, so the proxy must
|
||||
forward as-is instead of round-tripping through litellm."""
|
||||
assert RoutstrUpstreamProvider.supports_anthropic_messages is True
|
||||
|
||||
@@ -108,6 +108,39 @@ async def test_credit_balance() -> None:
|
||||
assert mock_session.refresh.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credit_balance_rejects_zero_amount() -> None:
|
||||
"""A zero/dust redemption must raise BEFORE any commit, so no orphan
|
||||
zero-balance key (balance 0, total_spent 0, total_requests 0) is persisted."""
|
||||
token_data = {
|
||||
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 0}]}],
|
||||
"unit": "sat",
|
||||
}
|
||||
token_json = json.dumps(token_data)
|
||||
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
|
||||
token_str = f"cashuA{token_b64}"
|
||||
|
||||
mock_key = Mock()
|
||||
mock_key.balance = 0
|
||||
mock_key.hashed_key = "test_hash"
|
||||
mock_session = AsyncMock()
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch(
|
||||
"routstr.wallet.recieve_token",
|
||||
return_value=(0, "sat", "http://mint:3338"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="must be positive"):
|
||||
await credit_balance(token_str, mock_key, mock_session)
|
||||
|
||||
# Critically: no balance UPDATE and no commit happened, so the caller's
|
||||
# uncommitted key row rolls back instead of persisting as an orphan.
|
||||
assert not mock_session.exec.called
|
||||
assert not mock_session.commit.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."""
|
||||
@@ -123,6 +156,7 @@ async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
@@ -166,6 +200,7 @@ async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
@@ -261,6 +296,7 @@ async def test_swap_to_primary_mint_success() -> None:
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
|
||||
@@ -64,7 +64,6 @@ async def test_non_streaming_includes_cost_sats() -> None:
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
mint=None,
|
||||
payment_token_hash=None,
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
@@ -170,7 +169,6 @@ async def test_streaming_includes_cost_sats_in_usage_chunk() -> None:
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
mint=None,
|
||||
payment_token_hash=None,
|
||||
)
|
||||
|
||||
chunks = await _collect_streaming(response)
|
||||
|
||||
@@ -71,8 +71,8 @@ export function LogDetailsDialog({
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre'>
|
||||
<div className='bg-muted max-h-96 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm break-words whitespace-pre-wrap'>
|
||||
{log.message}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -113,8 +113,8 @@ export function LogDetailsDialog({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
<div className='bg-muted max-h-64 overflow-auto rounded p-2'>
|
||||
<pre className='font-mono text-sm break-words whitespace-pre-wrap'>
|
||||
{String(log[field as keyof LogEntry] || 'N/A')}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -132,13 +132,13 @@ export function LogDetailsDialog({
|
||||
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
|
||||
{field}
|
||||
</span>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
|
||||
<div className='bg-muted max-h-80 overflow-auto rounded p-2'>
|
||||
{typeof log[field] === 'object' ? (
|
||||
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
|
||||
<pre className='font-mono text-xs break-words whitespace-pre-wrap'>
|
||||
{JSON.stringify(log[field], null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
<pre className='font-mono text-sm break-words whitespace-pre-wrap'>
|
||||
{String(log[field] || 'N/A')}
|
||||
</pre>
|
||||
)}
|
||||
@@ -173,8 +173,8 @@ export function LogDetailsDialog({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
|
||||
<pre className='text-xs break-all whitespace-pre-wrap'>
|
||||
<div className='bg-muted max-h-[32rem] overflow-auto rounded-md p-4'>
|
||||
<pre className='text-xs break-words whitespace-pre-wrap'>
|
||||
{JSON.stringify(log, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
|
||||
import { AdminSettings } from '@/components/settings/admin-settings';
|
||||
import { CliTokensSettings } from '@/components/settings/cli-tokens-settings';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function SettingsPage() {
|
||||
<TabsList variant='line' className='mb-4 w-full'>
|
||||
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
|
||||
<TabsTrigger value='server'>Server Config</TabsTrigger>
|
||||
<TabsTrigger value='cli-tokens'>CLI Tokens</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='server'>
|
||||
<ServerConfigSettings />
|
||||
@@ -26,6 +28,9 @@ export default function SettingsPage() {
|
||||
<TabsContent value='admin'>
|
||||
<AdminSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value='cli-tokens'>
|
||||
<CliTokensSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Empty,
|
||||
@@ -50,8 +50,14 @@ import {
|
||||
Receipt,
|
||||
Key,
|
||||
Zap,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
||||
import {
|
||||
AdminService,
|
||||
type Transaction,
|
||||
type LightningInvoice,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -86,85 +92,280 @@ function TransactionTable({
|
||||
|
||||
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' />
|
||||
<div className='min-w-[800px]'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>API Key</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.api_key_hashed_key ? (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<span className='max-w-[120px] truncate font-mono'>
|
||||
{tx.api_key_hashed_key.slice(0, 12)}...
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4'
|
||||
onClick={() =>
|
||||
onCopy(tx.api_key_hashed_key!, tx.id + '-apikey')
|
||||
}
|
||||
>
|
||||
{copiedId === tx.id + '-apikey' ? (
|
||||
<Check className='h-3 w-3' />
|
||||
) : (
|
||||
<Copy className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ArrowUpRight className='h-4 w-4 text-blue-500' />
|
||||
<span className='text-muted-foreground text-xs'>—</span>
|
||||
)}
|
||||
<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 ? (
|
||||
</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>
|
||||
</div>
|
||||
<ScrollBar orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function LightningInvoiceTable({
|
||||
invoices,
|
||||
copiedId,
|
||||
onCopy,
|
||||
}: {
|
||||
invoices: LightningInvoice[];
|
||||
copiedId: string | null;
|
||||
onCopy: (text: string, id: string) => void;
|
||||
}) {
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Zap className='h-4 w-4' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No invoices found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Lightning invoices created via /lightning/invoice will show here.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
const statusBadge = (status: LightningInvoice['status']) => {
|
||||
if (status === 'paid')
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-green-500/20 bg-green-500/10 text-green-500'
|
||||
>
|
||||
Paid
|
||||
</Badge>
|
||||
);
|
||||
if (status === 'expired')
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-red-500/20 bg-red-500/10 text-red-500'
|
||||
>
|
||||
Expired
|
||||
</Badge>
|
||||
);
|
||||
if (status === 'cancelled')
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-gray-500/20 bg-gray-500/10 text-gray-500'
|
||||
>
|
||||
Cancelled
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||
<div className='min-w-[900px]'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Purpose</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Payment Hash</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Paid</TableHead>
|
||||
<TableHead className='text-right'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((inv) => (
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell>
|
||||
<span className='capitalize'>{inv.purpose}</span>
|
||||
</TableCell>
|
||||
<TableCell className='font-mono'>
|
||||
{inv.amount_sats} sat
|
||||
</TableCell>
|
||||
<TableCell>{statusBadge(inv.status)}</TableCell>
|
||||
<TableCell>
|
||||
{inv.api_key_hash ? (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<span className='max-w-[120px] truncate font-mono'>
|
||||
{inv.api_key_hash.slice(0, 12)}...
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4'
|
||||
onClick={() =>
|
||||
onCopy(inv.api_key_hash!, inv.id + '-apikey')
|
||||
}
|
||||
>
|
||||
{copiedId === inv.id + '-apikey' ? (
|
||||
<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 items-center gap-1 text-xs'>
|
||||
<span className='max-w-[150px] truncate font-mono'>
|
||||
{tx.request_id}
|
||||
<span className='max-w-[140px] truncate font-mono'>
|
||||
{inv.payment_hash.slice(0, 14)}...
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4'
|
||||
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
|
||||
onClick={() => onCopy(inv.payment_hash, inv.id + '-hash')}
|
||||
>
|
||||
{copiedId === tx.id + '-req' ? (
|
||||
{copiedId === inv.id + '-hash' ? (
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell className='text-xs whitespace-nowrap'>
|
||||
{format(inv.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs whitespace-nowrap'>
|
||||
{inv.paid_at
|
||||
? format(inv.paid_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(inv.bolt11, inv.id + '-bolt11')}
|
||||
title='Copy BOLT11'
|
||||
>
|
||||
{copiedId === inv.id + '-bolt11' ? (
|
||||
<Check className='h-4 w-4' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<ScrollBar orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -196,21 +397,89 @@ export default function TransactionsPage() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||
}, [search, type, status]);
|
||||
|
||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['transactions', type, status, search],
|
||||
const PAGE_SIZE = 50;
|
||||
const [activeTab, setActiveTab] = useState<string>('x-cashu');
|
||||
const [xcashuPage, setXcashuPage] = useState(0);
|
||||
const [apikeyPage, setApikeyPage] = useState(0);
|
||||
const [lightningPage, setLightningPage] = useState(0);
|
||||
|
||||
const typeParam = type === 'all' ? undefined : type;
|
||||
const statusParam = status === 'all' ? undefined : status;
|
||||
const searchParam = search || undefined;
|
||||
|
||||
const xcashuQuery = useQuery({
|
||||
queryKey: [
|
||||
'transactions',
|
||||
'x-cashu',
|
||||
typeParam,
|
||||
statusParam,
|
||||
searchParam,
|
||||
xcashuPage,
|
||||
],
|
||||
queryFn: () =>
|
||||
AdminService.getTransactions(
|
||||
type === 'all' ? undefined : type,
|
||||
status === 'all' ? undefined : status,
|
||||
search || undefined,
|
||||
100
|
||||
typeParam,
|
||||
statusParam,
|
||||
searchParam,
|
||||
'x-cashu',
|
||||
PAGE_SIZE,
|
||||
xcashuPage * PAGE_SIZE
|
||||
),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const apikeyQuery = useQuery({
|
||||
queryKey: [
|
||||
'transactions',
|
||||
'apikey',
|
||||
typeParam,
|
||||
statusParam,
|
||||
searchParam,
|
||||
apikeyPage,
|
||||
],
|
||||
queryFn: () =>
|
||||
AdminService.getTransactions(
|
||||
typeParam,
|
||||
statusParam,
|
||||
searchParam,
|
||||
'apikey',
|
||||
PAGE_SIZE,
|
||||
apikeyPage * PAGE_SIZE
|
||||
),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled'];
|
||||
const lightningStatusParam = LIGHTNING_STATUSES.includes(status)
|
||||
? status
|
||||
: undefined;
|
||||
|
||||
const lightningQuery = useQuery({
|
||||
queryKey: [
|
||||
'lightning-invoices',
|
||||
lightningStatusParam,
|
||||
searchParam,
|
||||
lightningPage,
|
||||
],
|
||||
queryFn: () =>
|
||||
AdminService.getLightningInvoices(
|
||||
lightningStatusParam,
|
||||
undefined,
|
||||
searchParam,
|
||||
PAGE_SIZE,
|
||||
lightningPage * PAGE_SIZE
|
||||
),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearch('');
|
||||
setType('all');
|
||||
setStatus('all');
|
||||
setXcashuPage(0);
|
||||
setApikeyPage(0);
|
||||
setLightningPage(0);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
@@ -260,14 +529,24 @@ export default function TransactionsPage() {
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
|
||||
const xcashuTxs =
|
||||
data?.transactions.filter((tx) => !tx.source || tx.source === 'x-cashu') ??
|
||||
[];
|
||||
const apikeyTxs =
|
||||
data?.transactions.filter((tx) => tx.source === 'apikey') ?? [];
|
||||
// Reset pages when filters change
|
||||
useEffect(() => {
|
||||
setXcashuPage(0);
|
||||
setApikeyPage(0);
|
||||
setLightningPage(0);
|
||||
}, [type, status, search]);
|
||||
|
||||
const renderCardContent = (txs: Transaction[]) => {
|
||||
if (isLoading) {
|
||||
const isRefetching =
|
||||
xcashuQuery.isRefetching ||
|
||||
apikeyQuery.isRefetching ||
|
||||
lightningQuery.isRefetching;
|
||||
|
||||
const renderCardContent = (
|
||||
query: typeof xcashuQuery,
|
||||
page: number,
|
||||
setPage: (p: number) => void
|
||||
) => {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
@@ -279,13 +558,51 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const transactions = query.data?.transactions ?? [];
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<TransactionTable
|
||||
transactions={txs}
|
||||
copiedId={copiedId}
|
||||
onCopy={copyToClipboard}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
<>
|
||||
{totalPages > 1 && (
|
||||
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{' '}
|
||||
of {total}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Previous</span>
|
||||
</Button>
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<span className='hidden sm:inline'>Next</span>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TransactionTable
|
||||
transactions={transactions}
|
||||
copiedId={copiedId}
|
||||
onCopy={copyToClipboard}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -297,7 +614,11 @@ export default function TransactionsPage() {
|
||||
description='View all incoming and outgoing Cashu token transactions.'
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
onClick={() => {
|
||||
xcashuQuery.refetch();
|
||||
apikeyQuery.refetch();
|
||||
lightningQuery.refetch();
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={isRefetching}
|
||||
@@ -325,7 +646,7 @@ export default function TransactionsPage() {
|
||||
<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...'
|
||||
placeholder='Search by ID, token, request ID or key hash...'
|
||||
className='pl-8'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -356,6 +677,11 @@ export default function TransactionsPage() {
|
||||
<SelectItem value='pending'>Pending</SelectItem>
|
||||
<SelectItem value='collected'>Collected</SelectItem>
|
||||
<SelectItem value='swept'>Swept</SelectItem>
|
||||
<SelectItem value='paid'>Paid (Lightning)</SelectItem>
|
||||
<SelectItem value='expired'>Expired (Lightning)</SelectItem>
|
||||
<SelectItem value='cancelled'>
|
||||
Cancelled (Lightning)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -372,23 +698,36 @@ export default function TransactionsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue='x-cashu'>
|
||||
<Tabs
|
||||
defaultValue='x-cashu'
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
>
|
||||
<TabsList className='mb-4'>
|
||||
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
|
||||
<Zap className='h-4 w-4' />
|
||||
X-Cashu
|
||||
{data && (
|
||||
{xcashuQuery.data && (
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{xcashuTxs.length}
|
||||
{xcashuQuery.data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='apikey' className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
API Key Refunds
|
||||
{data && (
|
||||
API Key
|
||||
{apikeyQuery.data && (
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{apikeyTxs.length}
|
||||
{apikeyQuery.data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='lightning' className='flex items-center gap-2'>
|
||||
<Zap className='h-4 w-4' />
|
||||
Lightning
|
||||
{lightningQuery.data && (
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{lightningQuery.data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
@@ -407,7 +746,7 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{renderCardContent(xcashuTxs)}
|
||||
{renderCardContent(xcashuQuery, xcashuPage, setXcashuPage)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -416,7 +755,7 @@ export default function TransactionsPage() {
|
||||
<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>
|
||||
<CardTitle>API Key Transaction History</CardTitle>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Filtered by {activeFilterDescription}
|
||||
@@ -425,7 +764,82 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{renderCardContent(apikeyTxs)}
|
||||
{renderCardContent(apikeyQuery, apikeyPage, setApikeyPage)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='lightning'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>Lightning Invoice History</CardTitle>
|
||||
<CardDescription>
|
||||
Auto-refreshing every 10s. Paid invoices credit balance
|
||||
automatically.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{lightningQuery.isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`ln-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const total = lightningQuery.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
if (totalPages <= 1) return null;
|
||||
return (
|
||||
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{lightningPage * PAGE_SIZE + 1}–
|
||||
{Math.min((lightningPage + 1) * PAGE_SIZE, total)}{' '}
|
||||
of {total}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={lightningPage === 0}
|
||||
onClick={() =>
|
||||
setLightningPage(lightningPage - 1)
|
||||
}
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Previous</span>
|
||||
</Button>
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{lightningPage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={lightningPage >= totalPages - 1}
|
||||
onClick={() =>
|
||||
setLightningPage(lightningPage + 1)
|
||||
}
|
||||
>
|
||||
<span className='hidden sm:inline'>Next</span>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<LightningInvoiceTable
|
||||
invoices={lightningQuery.data?.invoices ?? []}
|
||||
copiedId={copiedId}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -540,7 +540,7 @@ export function AddProviderModelDialog({
|
||||
};
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Upstream Model ID</FormLabel>
|
||||
<FormLabel>Client Alias ID</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
@@ -565,8 +565,8 @@ export function AddProviderModelDialog({
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Model ID sent to the upstream provider. Defaults to the
|
||||
model's own ID.
|
||||
Alternate ID that clients can use to reference this
|
||||
model. Defaults to the model's own ID.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -471,7 +471,11 @@ export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
|
||||
testEndpointMutation.mutate(requestData);
|
||||
};
|
||||
|
||||
const enabledModels = models.filter((model) => model.isEnabled);
|
||||
const enabledModels = Array.from(
|
||||
new Map(
|
||||
models.filter((model) => model.isEnabled).map((m) => [m.id, m])
|
||||
).values()
|
||||
);
|
||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||
const endpointUrl = credentials
|
||||
? buildEndpointUrl(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { adminLogout } from '@/lib/api/services/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyToggle } from '@/components/currency-toggle';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { VersionStatus } from '@/components/version-status';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -90,8 +91,18 @@ export function AppPageShell({
|
||||
isSidebarCollapsed && 'px-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
isSidebarCollapsed && 'justify-center'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 overflow-hidden',
|
||||
!isSidebarCollapsed && 'flex-1'
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src='/icon.ico'
|
||||
alt='Routstr Node'
|
||||
@@ -110,26 +121,9 @@ export function AppPageShell({
|
||||
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
|
||||
Routstr Node
|
||||
</h1>
|
||||
<VersionStatus className='mt-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
|
||||
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
|
||||
)}
|
||||
onClick={() => setIsSidebarCollapsed((current) => !current)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>
|
||||
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,6 +224,28 @@ export function AppPageShell({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size={isSidebarCollapsed ? 'icon' : 'sm'}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground transition-[width,padding] duration-300 ease-in-out',
|
||||
isSidebarCollapsed
|
||||
? 'mx-auto h-8 w-8'
|
||||
: 'h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
|
||||
)}
|
||||
onClick={() => setIsSidebarCollapsed((current) => !current)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeftOpenIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className='h-4 w-4' />
|
||||
)}
|
||||
{isSidebarCollapsed ? (
|
||||
<span className='sr-only'>Expand sidebar</span>
|
||||
) : (
|
||||
'Collapse'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -279,9 +295,12 @@ export function AppPageShell({
|
||||
height={24}
|
||||
className='rounded-sm'
|
||||
/>
|
||||
<p className='truncate text-base font-medium tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-base font-medium tracking-tight'>
|
||||
Routstr Node
|
||||
</p>
|
||||
<VersionStatus className='mt-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
|
||||
@@ -197,7 +197,11 @@ export function ModelTester({ models }: ModelTesterProps) {
|
||||
testModelMutation.mutate(request);
|
||||
};
|
||||
|
||||
const enabledModels = models.filter((model) => model.isEnabled);
|
||||
const enabledModels = Array.from(
|
||||
new Map(
|
||||
models.filter((model) => model.isEnabled).map((m) => [m.id, m])
|
||||
).values()
|
||||
);
|
||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -124,12 +124,14 @@ export function ModelsPage() {
|
||||
>
|
||||
Basic Testing
|
||||
</TabsTrigger>
|
||||
{/*
|
||||
<TabsTrigger
|
||||
value='test-api'
|
||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||
>
|
||||
API Endpoints
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='manage' className='mt-0'>
|
||||
|
||||
@@ -32,9 +32,18 @@ interface SettingsData {
|
||||
onion_url?: string;
|
||||
cashu_mints?: string[];
|
||||
relays?: string[];
|
||||
receive_ln_address?: string;
|
||||
min_payout_sat?: number;
|
||||
payout_interval_seconds?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const PAYOUT_KEYS = [
|
||||
'receive_ln_address',
|
||||
'min_payout_sat',
|
||||
'payout_interval_seconds',
|
||||
] as const;
|
||||
|
||||
const HANDLED_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
@@ -48,6 +57,7 @@ const HANDLED_KEYS = [
|
||||
'admin_password',
|
||||
'id',
|
||||
'updated_at',
|
||||
...PAYOUT_KEYS,
|
||||
];
|
||||
|
||||
const IGNORED_KEYS = [
|
||||
@@ -369,6 +379,7 @@ export function AdminSettings() {
|
||||
const cashuMintsChanged = hasFieldChanged('cashu_mints');
|
||||
const relaysChanged = hasFieldChanged('relays');
|
||||
const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing');
|
||||
const payoutChanged = PAYOUT_KEYS.some(hasFieldChanged);
|
||||
const advancedKeys = Object.keys(settings).filter(
|
||||
(key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
|
||||
);
|
||||
@@ -401,8 +412,44 @@ export function AdminSettings() {
|
||||
setNewRelay('');
|
||||
};
|
||||
const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']);
|
||||
const resetPayout = () => resetFields([...PAYOUT_KEYS]);
|
||||
const resetAdvanced = () => resetFields(advancedKeys);
|
||||
|
||||
const payoutFields: ReadonlyArray<{
|
||||
key: (typeof PAYOUT_KEYS)[number];
|
||||
label: string;
|
||||
placeholder: string;
|
||||
type: 'text' | 'number';
|
||||
helpText: string;
|
||||
min?: number;
|
||||
}> = [
|
||||
{
|
||||
key: 'receive_ln_address',
|
||||
label: 'Lightning Receive Address',
|
||||
placeholder: 'you@walletofsatoshi.com or LNURL',
|
||||
type: 'text',
|
||||
helpText:
|
||||
'Lightning address (or LNURL) profits are paid out to. Leave empty to disable periodic payouts.',
|
||||
},
|
||||
{
|
||||
key: 'min_payout_sat',
|
||||
label: 'Minimum Payout (sat)',
|
||||
placeholder: '210',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
helpText:
|
||||
'Wallet payouts only fire when at least this many satoshis are available. Must be > 0.',
|
||||
},
|
||||
{
|
||||
key: 'payout_interval_seconds',
|
||||
label: 'Payout Interval (seconds)',
|
||||
placeholder: '900',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
helpText: 'How often the payout loop wakes up to check balances.',
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
@@ -620,6 +667,89 @@ export function AdminSettings() {
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* Lightning Payout Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lightning Payout Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Tune how node profit is paid out over Lightning. Amounts must be
|
||||
positive and above your wallet's minimum-invoice constraints.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{payoutFields.map((field) => {
|
||||
const value = settings[field.key];
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div key={field.key} className='space-y-2'>
|
||||
<Label htmlFor={field.key}>{field.label}</Label>
|
||||
<Input
|
||||
id={field.key}
|
||||
type='number'
|
||||
min={field.min}
|
||||
value={
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: value === undefined || value === null
|
||||
? ''
|
||||
: Number(value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
handleInputChange(field.key, undefined);
|
||||
} else {
|
||||
const parsed = Number(raw);
|
||||
handleInputChange(
|
||||
field.key,
|
||||
Number.isFinite(parsed) ? parsed : undefined
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{field.helpText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={field.key} className='space-y-2'>
|
||||
<Label htmlFor={field.key}>{field.label}</Label>
|
||||
<Input
|
||||
id={field.key}
|
||||
value={(value as string) || ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{field.helpText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
{payoutChanged ? (
|
||||
<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={resetPayout}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* Relays */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
265
ui/components/settings/cli-tokens-settings.tsx
Normal file
265
ui/components/settings/cli-tokens-settings.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
AdminService,
|
||||
type CliTokenListItem,
|
||||
type CliTokenCreated,
|
||||
} from '@/lib/api/services/admin';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle, Copy, Trash2, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function formatTs(ts: number | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function CliTokensSettings(): React.ReactElement {
|
||||
const [tokens, setTokens] = useState<CliTokenListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [expiresInDays, setExpiresInDays] = useState<string>('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const loadTokens = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await AdminService.listCliTokens();
|
||||
setTokens(data);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to load tokens';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTokens();
|
||||
}, [loadTokens]);
|
||||
|
||||
async function handleCreate(): Promise<void> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
toast.error('Name is required');
|
||||
return;
|
||||
}
|
||||
const days = expiresInDays.trim()
|
||||
? Number.parseInt(expiresInDays.trim(), 10)
|
||||
: undefined;
|
||||
if (days !== undefined && (Number.isNaN(days) || days <= 0)) {
|
||||
toast.error('Expiry must be a positive number of days');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await AdminService.createCliToken(trimmed, days);
|
||||
setNewToken(created);
|
||||
setName('');
|
||||
setExpiresInDays('');
|
||||
await loadTokens();
|
||||
toast.success('Token created. Copy it now — it will not be shown again.');
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to create token';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(id: string): Promise<void> {
|
||||
if (
|
||||
!confirm('Revoke this token? Any CLI/agent using it will lose access.')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AdminService.revokeCliToken(id);
|
||||
await loadTokens();
|
||||
toast.success('Token revoked');
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to revoke token';
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy(): Promise<void> {
|
||||
if (!newToken) return;
|
||||
await navigator.clipboard.writeText(newToken.token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create CLI Token</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a long-lived bearer token for the Routstr CLI or AI agents.
|
||||
Use this token in <code>~/.routstr/config.json</code> or with{' '}
|
||||
<code>routstr init --token <token></code>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{newToken && (
|
||||
<Alert className='border-green-500/50 bg-green-500/10'>
|
||||
<AlertDescription className='space-y-3'>
|
||||
<div className='font-medium text-green-700 dark:text-green-400'>
|
||||
Token created. Copy it now — it will not be shown again.
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='bg-muted flex-1 rounded px-3 py-2 text-xs break-all'>
|
||||
{newToken.token}
|
||||
</code>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-4 w-4' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setNewToken(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='cli-token-name'>Name</Label>
|
||||
<Input
|
||||
id='cli-token-name'
|
||||
placeholder='e.g. dev-laptop, ci-runner'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='cli-token-expiry'>
|
||||
Expires in days (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id='cli-token-expiry'
|
||||
type='number'
|
||||
min='1'
|
||||
placeholder='Never expires if blank'
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(e.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||
{creating ? 'Creating…' : 'Create Token'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Tokens</CardTitle>
|
||||
<CardDescription>
|
||||
Tokens authorize CLI/agent calls to admin endpoints. Revoke any
|
||||
token that may have been exposed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-12 w-full' />
|
||||
<Skeleton className='h-12 w-full' />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No tokens yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead>
|
||||
<tr className='text-muted-foreground border-b text-left'>
|
||||
<th className='py-2 pr-4 font-medium'>Name</th>
|
||||
<th className='py-2 pr-4 font-medium'>Token</th>
|
||||
<th className='py-2 pr-4 font-medium'>Created</th>
|
||||
<th className='py-2 pr-4 font-medium'>Last used</th>
|
||||
<th className='py-2 pr-4 font-medium'>Expires</th>
|
||||
<th className='py-2 font-medium'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.id} className='border-b last:border-0'>
|
||||
<td className='py-2 pr-4'>{t.name}</td>
|
||||
<td className='py-2 pr-4 font-mono text-xs'>
|
||||
{t.token_preview}
|
||||
</td>
|
||||
<td className='text-muted-foreground py-2 pr-4'>
|
||||
{formatTs(t.created_at)}
|
||||
</td>
|
||||
<td className='text-muted-foreground py-2 pr-4'>
|
||||
{formatTs(t.last_used_at)}
|
||||
</td>
|
||||
<td className='text-muted-foreground py-2 pr-4'>
|
||||
{t.expires_at ? formatTs(t.expires_at) : 'Never'}
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => void handleRevoke(t.id)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -41,52 +43,12 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { formatFromMsat } from '@/lib/currency';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function getTotals(balances: TemporaryBalance[]) {
|
||||
let totalBalance = 0;
|
||||
let totalSpent = 0;
|
||||
let totalRequests = 0;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
balances.forEach((balance) => {
|
||||
if (!balance.parent_key_hash) {
|
||||
totalBalance += balance.balance || 0;
|
||||
}
|
||||
totalSpent += balance.total_spent || 0;
|
||||
totalRequests += balance.total_requests || 0;
|
||||
});
|
||||
|
||||
return { totalBalance, totalSpent, totalRequests };
|
||||
}
|
||||
|
||||
function buildHierarchicalData(
|
||||
allBalances: TemporaryBalance[],
|
||||
filteredBalances: TemporaryBalance[]
|
||||
) {
|
||||
const parents = filteredBalances.filter((item) => !item.parent_key_hash);
|
||||
const result: Array<TemporaryBalance & { isChild?: boolean }> = [];
|
||||
|
||||
parents.forEach((parent) => {
|
||||
result.push(parent);
|
||||
|
||||
const children = allBalances.filter(
|
||||
(item) => item.parent_key_hash === parent.hashed_key
|
||||
);
|
||||
|
||||
children.forEach((child) => {
|
||||
result.push({ ...child, isChild: true });
|
||||
});
|
||||
});
|
||||
|
||||
const orphans = filteredBalances.filter(
|
||||
(item) =>
|
||||
item.parent_key_hash &&
|
||||
!result.some((r) => r.hashed_key === item.hashed_key)
|
||||
);
|
||||
|
||||
result.push(...orphans.map((item) => ({ ...item, isChild: true })));
|
||||
|
||||
return result;
|
||||
}
|
||||
const formatCreatedAt = (createdAt: number | null | undefined) =>
|
||||
createdAt ? format(createdAt * 1000, 'yyyy-MM-dd HH:mm:ss') : '—';
|
||||
|
||||
export function TemporaryBalances({
|
||||
refreshInterval = 10000,
|
||||
@@ -98,38 +60,55 @@ export function TemporaryBalances({
|
||||
usdPerSat: number | null;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Debounce the search input so we don't refetch on every keystroke.
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebouncedSearch(searchTerm), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Reset to the first page whenever the active search changes.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const searchParam = debouncedSearch || undefined;
|
||||
|
||||
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
|
||||
queryKey: ['temporary-balances'],
|
||||
queryFn: async () => AdminService.getTemporaryBalances(),
|
||||
queryKey: ['temporary-balances', searchParam, page],
|
||||
queryFn: async () =>
|
||||
AdminService.getTemporaryBalances(
|
||||
searchParam,
|
||||
PAGE_SIZE,
|
||||
page * PAGE_SIZE
|
||||
),
|
||||
refetchInterval: refreshInterval,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const formatBalance = (msat: number) =>
|
||||
formatFromMsat(msat, displayUnit, usdPerSat);
|
||||
|
||||
const filteredData = data
|
||||
? data.filter(
|
||||
(item) =>
|
||||
item.hashed_key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.refund_address?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
const totals = data
|
||||
? getTotals(data)
|
||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
||||
|
||||
const rows = data ? buildHierarchicalData(data, filteredData) : [];
|
||||
const rows = data?.balances ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totals = data?.totals ?? {
|
||||
total_balance: 0,
|
||||
total_spent: 0,
|
||||
total_requests: 0,
|
||||
};
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='space-y-1.5'>
|
||||
<CardTitle>Temporary Balances</CardTitle>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription className='max-w-2xl'>
|
||||
API keys with their current balances and usage statistics
|
||||
API keys with their current balances and usage statistics, newest
|
||||
first
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +135,7 @@ export function TemporaryBalances({
|
||||
(isFetching || isLoading) && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span className='sr-only'>Refresh temporary balances</span>
|
||||
<span className='sr-only'>Refresh API keys</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,7 +169,7 @@ export function TemporaryBalances({
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-5 w-5' />
|
||||
<AlertDescription>
|
||||
Error loading temporary balances: {(error as Error).message}
|
||||
Error loading API keys: {(error as Error).message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -207,7 +186,7 @@ export function TemporaryBalances({
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatBalance(totals.totalBalance)}
|
||||
{formatBalance(totals.total_balance)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -222,7 +201,7 @@ export function TemporaryBalances({
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatBalance(totals.totalSpent)}
|
||||
{formatBalance(totals.total_spent)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -237,12 +216,44 @@ export function TemporaryBalances({
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{totals.totalRequests.toLocaleString()}
|
||||
{totals.total_requests.toLocaleString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{page * PAGE_SIZE + 1}–
|
||||
{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Previous</span>
|
||||
</Button>
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<span className='hidden sm:inline'>Next</span>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<>
|
||||
<div className='hidden md:block'>
|
||||
@@ -257,6 +268,7 @@ export function TemporaryBalances({
|
||||
<TableHead className='text-right'>
|
||||
Total Requests
|
||||
</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Refund Address</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
Expiry Time
|
||||
@@ -264,178 +276,192 @@ export function TemporaryBalances({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((balance, index) => (
|
||||
<TableRow
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 &&
|
||||
!balance.isChild &&
|
||||
'opacity-60',
|
||||
balance.isChild && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{balance.isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
<span>{balance.hashed_key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{balance.isChild ? (
|
||||
<span className='text-muted-foreground italic'>
|
||||
(Parent)
|
||||
</span>
|
||||
) : (
|
||||
formatBalance(balance.balance)
|
||||
{rows.map((balance, index) => {
|
||||
const isChild = Boolean(balance.parent_key_hash);
|
||||
return (
|
||||
<TableRow
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 && !isChild && 'opacity-60',
|
||||
isChild && 'bg-muted/30'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
|
||||
{balance.refund_address || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<div className='inline-flex items-center justify-end gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
>
|
||||
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
<span>{balance.hashed_key}</span>
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{isChild ? (
|
||||
<span className='text-muted-foreground italic'>
|
||||
(Parent)
|
||||
</span>
|
||||
) : (
|
||||
formatBalance(balance.balance)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs whitespace-nowrap'>
|
||||
{formatCreatedAt(balance.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
|
||||
{balance.refund_address || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<div className='inline-flex items-center justify-end gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 md:hidden'>
|
||||
{rows.map((balance, index) => (
|
||||
<Card
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 &&
|
||||
!balance.isChild &&
|
||||
'opacity-80',
|
||||
balance.isChild && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<CardDescription className='font-mono text-xs break-all'>
|
||||
{balance.hashed_key}
|
||||
</CardDescription>
|
||||
{balance.isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Balance
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{balance.isChild
|
||||
? '(Uses Parent)'
|
||||
: formatBalance(balance.balance)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Spent</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
{rows.map((balance, index) => {
|
||||
const isChild = Boolean(balance.parent_key_hash);
|
||||
return (
|
||||
<Card
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 && !isChild && 'opacity-80',
|
||||
isChild && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<CardDescription className='font-mono text-xs break-all'>
|
||||
{balance.hashed_key}
|
||||
</CardDescription>
|
||||
{isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{balance.refund_address && (
|
||||
<div className='col-span-2'>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Refund Address
|
||||
Balance
|
||||
</p>
|
||||
<p className='font-mono text-xs break-all'>
|
||||
{balance.refund_address}
|
||||
<p className='font-mono text-sm'>
|
||||
{isChild
|
||||
? '(Uses Parent)'
|
||||
: formatBalance(balance.balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Spent
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Created
|
||||
</p>
|
||||
<p className='font-mono text-xs'>
|
||||
{formatCreatedAt(balance.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{balance.refund_address && (
|
||||
<div className='col-span-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Refund Address
|
||||
</p>
|
||||
<p className='font-mono text-xs break-all'>
|
||||
{balance.refund_address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{searchTerm ? (
|
||||
{debouncedSearch ? (
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
) : (
|
||||
<Key className='h-4 w-4' />
|
||||
)}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
{searchTerm
|
||||
? 'No temporary balances match your search'
|
||||
: 'No temporary balances found'}
|
||||
{debouncedSearch
|
||||
? 'No API keys match your search'
|
||||
: 'No API keys found'}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{searchTerm
|
||||
{debouncedSearch
|
||||
? 'Try a different key hash or refund address.'
|
||||
: 'Temporary balances will appear here once API keys are used.'}
|
||||
: 'API keys will appear here once they are created.'}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
{total > 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Showing {filteredData.length} of {data.length} temporary
|
||||
balances
|
||||
Showing {rows.length} of {total} API keys
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
282
ui/components/version-status.tsx
Normal file
282
ui/components/version-status.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
ExternalLinkIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
deriveStatus,
|
||||
formatReleaseDate,
|
||||
formatVersionLabel,
|
||||
parseVersion,
|
||||
type StatusKind,
|
||||
} from '@/lib/utils/version';
|
||||
|
||||
interface NodeInfo {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
name?: string;
|
||||
html_url: string;
|
||||
published_at?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
const NODE_QUERY_KEY = ['node-version'] as const;
|
||||
const RELEASE_QUERY_KEY = ['routstr-latest-release'] as const;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
|
||||
const GITHUB_RELEASES_URL = `https://api.github.com/repos/Routstr/routstr-core/releases/latest`;
|
||||
const RELEASES_PAGE_URL = `https://github.com/Routstr/routstr-core/releases`;
|
||||
|
||||
async function fetchNodeInfo(): Promise<NodeInfo> {
|
||||
const baseUrl = ConfigurationService.getLocalBaseUrl().replace(/\/+$/, '');
|
||||
const response = await fetch(`${baseUrl}/v1/info`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load node info');
|
||||
}
|
||||
return (await response.json()) as NodeInfo;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<GithubRelease | null> {
|
||||
const response = await fetch(GITHUB_RELEASES_URL, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (response.status === 403 || response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub responded ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as GithubRelease;
|
||||
}
|
||||
|
||||
function pickColorClass(status: StatusKind): string {
|
||||
if (status === 'outdated') return 'text-amber-600 dark:text-amber-400';
|
||||
if (status === 'unknown') return 'text-muted-foreground';
|
||||
if (status === 'ahead' || status === 'commit-drift') {
|
||||
return 'text-sky-600 dark:text-sky-400';
|
||||
}
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
}
|
||||
|
||||
function renderStatusIcon(status: StatusKind, className: string) {
|
||||
if (status === 'outdated') {
|
||||
return <AlertTriangleIcon className={className} />;
|
||||
}
|
||||
if (status === 'commit-drift' || status === 'ahead' || status === 'unknown') {
|
||||
return <InfoIcon className={className} />;
|
||||
}
|
||||
return <CheckCircle2Icon className={className} />;
|
||||
}
|
||||
|
||||
function describeStatus(status: StatusKind): string {
|
||||
if (status === 'outdated') return 'A newer release is available.';
|
||||
if (status === 'commit-drift') {
|
||||
return 'Running release version on a non-release commit.';
|
||||
}
|
||||
if (status === 'ahead') {
|
||||
return 'Running ahead of the latest published release.';
|
||||
}
|
||||
if (status === 'current') return 'Up to date with the latest release.';
|
||||
return 'Version status unavailable.';
|
||||
}
|
||||
|
||||
interface VersionStatusProps {
|
||||
variant?: 'expanded' | 'compact';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VersionStatus({
|
||||
variant = 'expanded',
|
||||
className,
|
||||
}: VersionStatusProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const nodeQuery = useQuery({
|
||||
queryKey: NODE_QUERY_KEY,
|
||||
queryFn: fetchNodeInfo,
|
||||
staleTime: THIRTY_MINUTES,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
queryKey: RELEASE_QUERY_KEY,
|
||||
queryFn: fetchLatestRelease,
|
||||
staleTime: THIRTY_MINUTES,
|
||||
refetchInterval: THIRTY_MINUTES,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const currentVersion = parseVersion(nodeQuery.data?.version);
|
||||
const latestVersion = parseVersion(releaseQuery.data?.tag_name);
|
||||
const status = deriveStatus(currentVersion, latestVersion);
|
||||
|
||||
const isRefreshing = releaseQuery.isFetching || nodeQuery.isFetching;
|
||||
|
||||
const handleRefresh = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: NODE_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: RELEASE_QUERY_KEY }),
|
||||
]);
|
||||
};
|
||||
|
||||
const colorClass = pickColorClass(status);
|
||||
const versionLabel = currentVersion
|
||||
? formatVersionLabel(currentVersion)
|
||||
: nodeQuery.isLoading
|
||||
? '…'
|
||||
: 'unknown';
|
||||
|
||||
if (!nodeQuery.data && nodeQuery.isLoading && variant === 'expanded') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusDescription = describeStatus(status);
|
||||
const ariaLabel = `Node version ${versionLabel}. ${statusDescription} Click for details.`;
|
||||
|
||||
const releaseRateLimited = releaseQuery.data === null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'hover:bg-accent/40 inline-flex items-center gap-1 rounded-md px-1 py-0.5 font-mono text-[10px] leading-tight transition-colors',
|
||||
colorClass,
|
||||
className
|
||||
)}
|
||||
title='View version details'
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{renderStatusIcon(status, 'h-3 w-3 shrink-0')}
|
||||
<span className='truncate'>{versionLabel}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={6}
|
||||
className='w-72 p-3'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 space-y-0.5'>
|
||||
<div className='flex items-center gap-1.5 text-sm font-medium'>
|
||||
{renderStatusIcon(status, cn('h-4 w-4 shrink-0', colorClass))}
|
||||
Node Version
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs leading-snug'>
|
||||
{statusDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-7 w-7 shrink-0'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
title='Check for latest release'
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Loader2Icon className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<RefreshCwIcon className='h-3.5 w-3.5' />
|
||||
)}
|
||||
<span className='sr-only'>Check for latest release</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-3 space-y-2'>
|
||||
<div className='border-border/60 bg-card/30 grid gap-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Current
|
||||
</span>
|
||||
<span className='font-mono text-xs'>{versionLabel}</span>
|
||||
</div>
|
||||
{currentVersion?.commit ? (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Commit
|
||||
</span>
|
||||
<code className='font-mono text-[11px]'>
|
||||
{currentVersion.commit}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='border-border/60 bg-card/30 grid gap-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Latest release
|
||||
</span>
|
||||
<span className='font-mono text-xs'>
|
||||
{releaseQuery.isLoading
|
||||
? 'loading…'
|
||||
: releaseQuery.isError
|
||||
? 'unavailable'
|
||||
: releaseRateLimited
|
||||
? 'rate-limited'
|
||||
: (releaseQuery.data?.tag_name ?? 'unknown')}
|
||||
</span>
|
||||
</div>
|
||||
{releaseQuery.data?.published_at ? (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Published
|
||||
</span>
|
||||
<span className='text-[11px]'>
|
||||
{formatReleaseDate(releaseQuery.data.published_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{releaseQuery.isError ? (
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
Failed to fetch latest release from GitHub.
|
||||
</p>
|
||||
) : releaseRateLimited ? (
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
GitHub rate limit reached. Try again later.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='border-border/60 mt-3 border-t pt-2'>
|
||||
<a
|
||||
href={releaseQuery.data?.html_url ?? RELEASES_PAGE_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
|
||||
>
|
||||
View release changelog
|
||||
<ExternalLinkIcon className='h-3 w-3' />
|
||||
</a>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -831,9 +831,18 @@ export class AdminService {
|
||||
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
|
||||
}
|
||||
|
||||
static async getTemporaryBalances(): Promise<TemporaryBalance[]> {
|
||||
return await apiClient.get<TemporaryBalance[]>(
|
||||
'/admin/api/temporary-balances'
|
||||
static async getTemporaryBalances(
|
||||
search?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<TemporaryBalancesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append('search', search);
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return await apiClient.get<TemporaryBalancesResponse>(
|
||||
`/admin/api/temporary-balances?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -891,19 +900,42 @@ export class AdminService {
|
||||
type?: string,
|
||||
status?: string,
|
||||
search?: string,
|
||||
limit: number = 100
|
||||
source?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<TransactionsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.append('type', type);
|
||||
if (status) params.append('status', status);
|
||||
if (search) params.append('search', search);
|
||||
if (source) params.append('source', source);
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return await apiClient.get<TransactionsResponse>(
|
||||
`/admin/api/transactions?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
static async getLightningInvoices(
|
||||
status?: string,
|
||||
purpose?: string,
|
||||
search?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<LightningInvoicesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (purpose) params.append('purpose', purpose);
|
||||
if (search) params.append('search', search);
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return await apiClient.get<LightningInvoicesResponse>(
|
||||
`/admin/api/lightning-invoices?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
static async createProviderAccountByType(providerType: string): Promise<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
@@ -962,6 +994,45 @@ export class AdminService {
|
||||
balance_data: number | null | Record<string, unknown>;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
||||
}
|
||||
|
||||
// ── CLI Tokens ──
|
||||
|
||||
static async listCliTokens(): Promise<CliTokenListItem[]> {
|
||||
return await apiClient.get<CliTokenListItem[]>('/admin/api/cli-tokens');
|
||||
}
|
||||
|
||||
static async createCliToken(
|
||||
name: string,
|
||||
expiresInDays?: number
|
||||
): Promise<CliTokenCreated> {
|
||||
return await apiClient.post<CliTokenCreated>('/admin/api/cli-tokens', {
|
||||
name,
|
||||
expires_in_days: expiresInDays ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
static async revokeCliToken(tokenId: string): Promise<{ ok: boolean }> {
|
||||
return await apiClient.delete<{ ok: boolean }>(
|
||||
`/admin/api/cli-tokens/${encodeURIComponent(tokenId)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliTokenListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
token_preview: string;
|
||||
created_at: number;
|
||||
last_used_at: number | null;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export interface CliTokenCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at: number;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
@@ -972,10 +1043,21 @@ export const TemporaryBalanceSchema = z.object({
|
||||
refund_address: z.string().nullable(),
|
||||
key_expiry_time: z.number().nullable(),
|
||||
parent_key_hash: z.string().nullable().optional(),
|
||||
created_at: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||
|
||||
export interface TemporaryBalancesResponse {
|
||||
balances: TemporaryBalance[];
|
||||
total: number;
|
||||
totals: {
|
||||
total_balance: number;
|
||||
total_spent: number;
|
||||
total_requests: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageMetricData {
|
||||
timestamp: string;
|
||||
total_requests: number;
|
||||
@@ -1136,9 +1218,29 @@ export interface Transaction {
|
||||
collected: boolean;
|
||||
swept: boolean;
|
||||
source: 'x-cashu' | 'apikey';
|
||||
api_key_hashed_key?: string;
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LightningInvoice {
|
||||
id: string;
|
||||
bolt11: string;
|
||||
amount_sats: number;
|
||||
description: string;
|
||||
payment_hash: string;
|
||||
status: 'pending' | 'paid' | 'expired' | 'cancelled';
|
||||
api_key_hash: string | null;
|
||||
purpose: 'create' | 'topup';
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
paid_at: number | null;
|
||||
}
|
||||
|
||||
export interface LightningInvoicesResponse {
|
||||
invoices: LightningInvoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
72
ui/lib/utils/version.ts
Normal file
72
ui/lib/utils/version.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface ParsedVersion {
|
||||
raw: string;
|
||||
base: string;
|
||||
parts: readonly number[];
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
export type StatusKind =
|
||||
| 'unknown'
|
||||
| 'outdated'
|
||||
| 'ahead'
|
||||
| 'commit-drift'
|
||||
| 'current';
|
||||
|
||||
export function parseVersion(
|
||||
raw: string | undefined | null
|
||||
): ParsedVersion | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim().replace(/^v/i, '');
|
||||
if (!trimmed) return null;
|
||||
const [base, commitPart] = trimmed.split('+', 2);
|
||||
const parts = (base ?? '')
|
||||
.split('.')
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (parts.length === 0) return null;
|
||||
return {
|
||||
raw,
|
||||
base: base ?? '',
|
||||
parts,
|
||||
commit: commitPart ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareVersionParts(
|
||||
a: readonly number[],
|
||||
b: readonly number[]
|
||||
): number {
|
||||
const length = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function deriveStatus(
|
||||
current: ParsedVersion | null,
|
||||
latest: ParsedVersion | null
|
||||
): StatusKind {
|
||||
if (!current || !latest) return 'unknown';
|
||||
const cmp = compareVersionParts(current.parts, latest.parts);
|
||||
if (cmp < 0) return 'outdated';
|
||||
if (cmp > 0) return 'ahead';
|
||||
return current.commit ? 'commit-drift' : 'current';
|
||||
}
|
||||
|
||||
export function formatReleaseDate(iso: string | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatVersionLabel(version: ParsedVersion | null): string {
|
||||
if (!version) return 'unknown';
|
||||
return version.raw.startsWith('v') ? version.raw : `v${version.raw}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user