mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
160 Commits
do-not-cre
...
0.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27dedfdf77 | ||
|
|
ebb2267844 | ||
|
|
c48693973b | ||
|
|
407f4745a0 | ||
|
|
806771df13 | ||
|
|
3c1b6d5f17 | ||
|
|
a9d5a52b32 | ||
|
|
b36bb56a30 | ||
|
|
97172641f8 | ||
|
|
46bbba7027 | ||
|
|
65ccf83dbd | ||
|
|
2baea4149e | ||
|
|
64d9460711 | ||
|
|
f55c3e9c8c | ||
|
|
e3ac06342f | ||
|
|
e2aa02307b | ||
|
|
66bc1260a4 | ||
|
|
9dc4c979b8 | ||
|
|
ec0fd1143b | ||
|
|
ae9748db02 | ||
|
|
ccee76b31e | ||
|
|
9f04525c82 | ||
|
|
c81de0de0a | ||
|
|
12c4c1f030 | ||
|
|
81658d0ba6 | ||
|
|
ee5ced10c7 | ||
|
|
2a27fb239e | ||
|
|
ffde661d93 | ||
|
|
7f49ba1771 | ||
|
|
949dc433f1 | ||
|
|
b7fcf000af | ||
|
|
a1af1383e1 | ||
|
|
5bedbd129f | ||
|
|
f588147b41 | ||
|
|
17bc949597 | ||
|
|
7705656016 | ||
|
|
434283c58d | ||
|
|
9fcf870e3f | ||
|
|
782b233d40 | ||
|
|
0c7373675f | ||
|
|
a33ea5da07 | ||
|
|
b509581950 | ||
|
|
38d7075652 | ||
|
|
ed27ad0dea | ||
|
|
6b1b4285fa | ||
|
|
7b56dcb902 | ||
|
|
2131cfc7f9 | ||
|
|
d6d9af91e4 | ||
|
|
cbd97e8e3a | ||
|
|
e4b26dd27b | ||
|
|
1e50afbd82 | ||
|
|
4d6e2cdf59 | ||
|
|
d079f45ba3 | ||
|
|
d08f07d6fb | ||
|
|
af0796689f | ||
|
|
f408785409 | ||
|
|
90f1ba89e5 | ||
|
|
e29c9241ce | ||
|
|
5a3774a414 | ||
|
|
e972b62758 | ||
|
|
b57f2d408e | ||
|
|
70d9e1a1ce | ||
|
|
c0a34a391f | ||
|
|
a97ea2995a | ||
|
|
5b50a78d95 | ||
|
|
ccab5e4216 | ||
|
|
ff9c645bb1 | ||
|
|
d4318dcc07 | ||
|
|
75ed865a5f | ||
|
|
7969a8da55 | ||
|
|
830c299130 | ||
|
|
e9dced8148 | ||
|
|
ecd46975b4 | ||
|
|
8f5f3d9738 | ||
|
|
355e3f19ef | ||
|
|
439ac48216 | ||
|
|
bf05c96dfc | ||
|
|
ed6e0c0189 | ||
|
|
412bfe479c | ||
|
|
861fda21b7 | ||
|
|
919dcf5535 | ||
|
|
cbc424e8e7 | ||
|
|
068fb3572f | ||
|
|
eaf74edbba | ||
|
|
eb6dc5189c | ||
|
|
cbdbe4b899 | ||
|
|
7870db9af1 | ||
|
|
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 | ||
|
|
9d5e14903b |
25
Dockerfile
25
Dockerfile
@@ -1,21 +1,20 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -16,28 +16,27 @@ 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
|
||||
|
||||
@@ -51,4 +50,4 @@ 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"]
|
||||
|
||||
6
Makefile
6
Makefile
@@ -98,7 +98,7 @@ docker-down:
|
||||
lint:
|
||||
@echo "🔍 Running linting checks..."
|
||||
$(RUFF) check .
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
$(MYPY) .
|
||||
|
||||
format:
|
||||
@echo "✨ Formatting code..."
|
||||
@@ -107,7 +107,7 @@ format:
|
||||
|
||||
type-check:
|
||||
@echo "🔎 Running type checks..."
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
$(MYPY) .
|
||||
|
||||
# Development setup
|
||||
dev-setup:
|
||||
@@ -234,7 +234,7 @@ ci-test:
|
||||
ci-lint:
|
||||
@echo "🤖 Running CI linting..."
|
||||
$(RUFF) check . --exit-non-zero-on-fix
|
||||
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
|
||||
$(MYPY) . --no-error-summary
|
||||
|
||||
# Debug helpers
|
||||
test-debug:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -113,42 +113,109 @@ All errors follow a consistent JSON structure:
|
||||
**Status:** 402
|
||||
**Resolution:** Top up API key balance
|
||||
|
||||
#### Invalid Token
|
||||
### Cashu Token Redemption Errors
|
||||
|
||||
These errors are returned when a Cashu token you pay with cannot be redeemed.
|
||||
They apply to every endpoint that accepts a token:
|
||||
|
||||
- **Per-request payment** via the `X-Cashu` header (chat completions + Responses API).
|
||||
- **API key top-up** via `POST /v1/wallet/topup`.
|
||||
- **Minting an API key** from a token sent in `Authorization: Bearer <cashu-token>`.
|
||||
|
||||
All three share one classifier, so the same failure yields the same HTTP status
|
||||
and sanitized message everywhere. Structured error envelopes (`X-Cashu` and
|
||||
`Authorization: Bearer <cashu-token>`) also expose the same `type` and `code` —
|
||||
branch on `type` (or `code` for finer granularity). `POST /v1/wallet/topup`
|
||||
keeps its existing plain-string `detail` envelope, so branch on status there.
|
||||
|
||||
| `type` | Status | `code` | Retryable | Meaning |
|
||||
|--------|--------|--------|-----------|---------|
|
||||
| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
|
||||
| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. |
|
||||
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. |
|
||||
| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. |
|
||||
| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. |
|
||||
| `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. |
|
||||
| `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). |
|
||||
| `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. |
|
||||
| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. |
|
||||
|
||||
!!! important "Retry only `mint_unreachable`"
|
||||
Only `mint_unreachable` (503) means the same token will work again later —
|
||||
everything else is a permanent property of the token and must not be
|
||||
blindly retried. Use exponential backoff for the 503. In particular, a
|
||||
`token_consumed` 500 means the mint already spent the token, so a retry
|
||||
would fail as `token_already_spent`.
|
||||
|
||||
#### Mint Unreachable (retryable)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Invalid Cashu token",
|
||||
"code": "invalid_token",
|
||||
"details": {
|
||||
"reason": "Token already spent"
|
||||
}
|
||||
"type": "mint_unreachable",
|
||||
"message": "Cashu mint is unreachable",
|
||||
"code": "cashu_mint_unreachable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Use a valid, unspent token
|
||||
**Status:** 503
|
||||
|
||||
#### Mint Unavailable
|
||||
**Resolution:** The token is valid — the mint is temporarily down. Retry with
|
||||
backoff, or pay with a token from a different mint.
|
||||
|
||||
#### Token Already Spent
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Cannot connect to Cashu mint",
|
||||
"code": "mint_unavailable",
|
||||
"details": {
|
||||
"mint_url": "https://mint.example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
"type": "token_already_spent",
|
||||
"message": "Cashu token already spent",
|
||||
"code": "cashu_token_already_spent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Try again later or use different mint
|
||||
**Status:** 400
|
||||
|
||||
**Resolution:** Use a fresh, unspent token. Do not retry with the same token.
|
||||
|
||||
#### Response envelope differs by endpoint
|
||||
|
||||
The `error` object above is identical everywhere, but the surrounding envelope
|
||||
depends on how you paid:
|
||||
|
||||
- **`X-Cashu` header payments** (chat + Responses API) return the object at the
|
||||
top level, alongside a `request_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" },
|
||||
"request_id": "req-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
The original token is echoed back in the `X-Cashu` **response header only when
|
||||
it is still spendable** (e.g. `mint_unreachable`, `invalid_cashu_token`, fee
|
||||
errors) so you can recover/retry it. It is **not** echoed for spent/consumed
|
||||
tokens (`cashu_token_already_spent`, `cashu_token_consumed`,
|
||||
`cashu_token_zero_value`, `internal_error`) — retrying those can never succeed.
|
||||
|
||||
- **`Authorization: Bearer <cashu-token>`** (API key minting) wraps it in
|
||||
FastAPI's `detail` field:
|
||||
|
||||
```json
|
||||
{ "detail": { "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" } } }
|
||||
```
|
||||
|
||||
- **`POST /v1/wallet/topup`** returns a plain string message under `detail` —
|
||||
it carries the shared HTTP **status** and **message** (e.g. `503` for an
|
||||
unreachable mint) but not the structured `type`/`code`, so branch on the
|
||||
status code here:
|
||||
|
||||
```json
|
||||
{ "detail": "Cashu mint is unreachable" }
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
@@ -347,7 +414,7 @@ class ErrorHandler:
|
||||
'rate_limit',
|
||||
'upstream_timeout',
|
||||
'model_overloaded',
|
||||
'mint_unavailable'
|
||||
'cashu_mint_unreachable'
|
||||
}
|
||||
|
||||
# Errors requiring user action
|
||||
|
||||
@@ -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-...`.
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add slug to upstream_providers
|
||||
|
||||
Revision ID: c6d7e8f9a0b1
|
||||
Revises: b5e7c9d1f3a2
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from routstr.core.provider_slugs import provider_slug_base, provider_slug_candidate
|
||||
|
||||
revision = "c6d7e8f9a0b1"
|
||||
down_revision = "b5e7c9d1f3a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _allocate_backfill_slug(provider_type: str, reserved_slugs: set[str]) -> str:
|
||||
base = provider_slug_base(provider_type)
|
||||
suffix_number = 1
|
||||
while True:
|
||||
candidate = provider_slug_candidate(base, suffix_number)
|
||||
if candidate not in reserved_slugs:
|
||||
reserved_slugs.add(candidate)
|
||||
return candidate
|
||||
suffix_number += 1
|
||||
|
||||
|
||||
def _backfill_provider_slugs(conn: sa.Connection) -> None:
|
||||
existing_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT slug FROM upstream_providers "
|
||||
"WHERE slug IS NOT NULL AND slug != ''"
|
||||
)
|
||||
)
|
||||
reserved_slugs = {str(row.slug).lower() for row in existing_rows}
|
||||
|
||||
rows_to_backfill = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, provider_type FROM upstream_providers "
|
||||
"WHERE slug IS NULL OR slug = '' "
|
||||
"ORDER BY id"
|
||||
)
|
||||
)
|
||||
for row in rows_to_backfill:
|
||||
slug = _allocate_backfill_slug(str(row.provider_type), reserved_slugs)
|
||||
conn.execute(
|
||||
sa.text("UPDATE upstream_providers SET slug = :slug WHERE id = :id"),
|
||||
{"slug": slug, "id": row.id},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
|
||||
|
||||
if "slug" not in columns:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("slug", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
_backfill_provider_slugs(conn)
|
||||
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_slug" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_upstream_providers_slug",
|
||||
"upstream_providers",
|
||||
["slug"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_slug" in existing_indexes:
|
||||
op.drop_index("ix_upstream_providers_slug", table_name="upstream_providers")
|
||||
|
||||
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
|
||||
if "slug" in columns:
|
||||
op.drop_column("upstream_providers", "slug")
|
||||
@@ -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.3"
|
||||
version = "0.4.4"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -14,7 +14,6 @@ dependencies = [
|
||||
"alembic>=1.13",
|
||||
"python-json-logger>=2.0.0",
|
||||
"cashu>=0.20",
|
||||
"secp256k1",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
"websockets>=12.0",
|
||||
"nostr>=0.0.2",
|
||||
@@ -87,4 +86,3 @@ disallow_untyped_decorators = true
|
||||
|
||||
[tool.uv.sources]
|
||||
routstr = { workspace = true }
|
||||
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
|
||||
|
||||
293
routstr/auth.py
293
routstr/auth.py
@@ -20,7 +20,11 @@ from .payment.cost_calculation import (
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
from .wallet import (
|
||||
classify_redemption_error,
|
||||
credit_balance,
|
||||
deserialize_token_from_string,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
payments_logger = get_logger("routstr.payments")
|
||||
@@ -78,6 +82,37 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def redemption_error_to_http_exception(error: Exception) -> HTTPException:
|
||||
"""Map a Cashu token redemption failure to a sanitized client-facing error.
|
||||
|
||||
Thin wrapper over the shared :func:`classify_redemption_error` so the bearer
|
||||
path stays identical to the X-Cashu and top-up paths.
|
||||
"""
|
||||
classified = classify_redemption_error(error)
|
||||
if classified is None:
|
||||
return HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Internal error during token redemption",
|
||||
"type": "api_error",
|
||||
"code": "internal_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
error_type, status_code, message, error_code = classified
|
||||
return HTTPException(
|
||||
status_code=status_code,
|
||||
detail={
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": error_type,
|
||||
"code": error_code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
@@ -216,7 +251,17 @@ async def validate_bearer_key(
|
||||
|
||||
try:
|
||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
except Exception as decode_error:
|
||||
# A malformed token is a bad token (400 invalid_cashu_token via
|
||||
# the shared taxonomy), not an auth failure (401) — otherwise it
|
||||
# would fall through to the generic "Invalid API key" handler.
|
||||
raise redemption_error_to_http_exception(
|
||||
ValueError(
|
||||
f"Invalid Cashu token: could not decode token ({decode_error})"
|
||||
)
|
||||
) from decode_error
|
||||
logger.debug(
|
||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
)
|
||||
@@ -334,14 +379,32 @@ async def validate_bearer_key(
|
||||
"error_type": type(credit_error).__name__,
|
||||
},
|
||||
)
|
||||
raise credit_error
|
||||
await session.rollback()
|
||||
raise redemption_error_to_http_exception(credit_error) from credit_error
|
||||
|
||||
if msats <= 0:
|
||||
logger.error(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
raise Exception("Token redemption failed")
|
||||
# Defense-in-depth: credit_balance already raises
|
||||
# ValueError("Redeemed token amount must be positive…") before
|
||||
# returning (wallet.py), so this branch is only reachable if a
|
||||
# zero/negative row was somehow persisted; drop it so we never
|
||||
# leave an orphan zero-balance key. Reuse the shared taxonomy
|
||||
# (cashu_error) so the envelope matches the mapper above.
|
||||
await session.delete(new_key)
|
||||
await session.commit()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Failed to redeem Cashu token: token yielded no value",
|
||||
"type": "cashu_error",
|
||||
"code": "cashu_token_zero_value",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(new_key)
|
||||
await session.commit()
|
||||
@@ -359,6 +422,7 @@ async def validate_bearer_key(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
@@ -373,7 +437,7 @@ async def validate_bearer_key(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"message": "Invalid or expired Cashu key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
@@ -529,31 +593,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",
|
||||
@@ -565,7 +617,6 @@ async def pay_for_request(
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
@@ -577,6 +628,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)
|
||||
@@ -615,12 +704,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,
|
||||
)
|
||||
)
|
||||
@@ -636,6 +732,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]
|
||||
@@ -670,12 +767,18 @@ async def revert_pay_for_request(
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
|
||||
key: ApiKey,
|
||||
response_data: dict,
|
||||
session: AsyncSession,
|
||||
deducted_max_cost: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
|
||||
The response's usage object is normalized with the default union parser in
|
||||
``calculate_cost``.
|
||||
"""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
model = response_data.get("model", "unknown")
|
||||
@@ -758,7 +861,7 @@ async def adjust_payment_for_tokens(
|
||||
extra={"error": str(e), "fee_msats": fee_msats},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
match await calculate_cost(response_data, deducted_max_cost):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
@@ -976,32 +1079,37 @@ async def adjust_payment_for_tokens(
|
||||
# actual cost exceeded discounted reservation (due to tolerance_percentage)
|
||||
if cost_difference > 0:
|
||||
# Always release the reservation and charge min(actual_cost, balance).
|
||||
# Using a CASE expression makes this a single atomic UPDATE — no
|
||||
# multi-level fallback needed and balance can never go negative.
|
||||
# CASE expressions keep this atomic and safe even when the
|
||||
# stale-reservation sweeper has already released the reservation.
|
||||
chargeable = case(
|
||||
(col(ApiKey.balance) >= total_cost_msats, total_cost_msats),
|
||||
else_=col(ApiKey.balance),
|
||||
)
|
||||
overrun_safe_reserved = case(
|
||||
(
|
||||
col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||
col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
reserved_balance=overrun_safe_reserved,
|
||||
balance=col(ApiKey.balance) - chargeable,
|
||||
total_spent=col(ApiKey.total_spent) + chargeable,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
reserved_balance=overrun_safe_reserved,
|
||||
total_spent=col(ApiKey.total_spent) + min(billing_key.balance, total_cost_msats),
|
||||
)
|
||||
)
|
||||
@@ -1009,51 +1117,38 @@ async def adjust_payment_for_tokens(
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
cost.total_msats = total_cost_msats
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await _accumulate_fee(total_cost_msats)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
"event": "finalize",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"cost_reserved": deducted_max_cost,
|
||||
"cost_charged": total_cost_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"finalize_type": "overrun",
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Guard fired: reservation was already released by a concurrent
|
||||
# finalization for this key. Nothing left to do.
|
||||
logger.warning(
|
||||
"Finalization skipped - reservation already released",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
cost.total_msats = total_cost_msats
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await _accumulate_fee(total_cost_msats)
|
||||
payments_logger.info(
|
||||
"FINALIZE",
|
||||
extra={
|
||||
"event": "finalize",
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"cost_reserved": deducted_max_cost,
|
||||
"cost_charged": total_cost_msats,
|
||||
"input_tokens": cost.input_tokens,
|
||||
"output_tokens": cost.output_tokens,
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"finalize_type": "overrun",
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
@@ -1258,3 +1353,53 @@ async def periodic_key_reset() -> None:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in periodic_key_reset: {e}")
|
||||
|
||||
|
||||
async def periodic_dead_key_prune() -> None:
|
||||
"""Periodically prune dead API keys. Interval <= 0 disables it.
|
||||
|
||||
See ``prune_dead_api_keys`` for eligibility.
|
||||
"""
|
||||
from .core.db import create_session, prune_dead_api_keys
|
||||
|
||||
interval = settings.dead_key_prune_interval_seconds
|
||||
if interval <= 0:
|
||||
logger.info("Dead-key pruning disabled (interval <= 0)")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
async with create_session() as session:
|
||||
await prune_dead_api_keys(
|
||||
session, settings.dead_key_min_age_seconds
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in periodic_dead_key_prune: {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 col, select, update
|
||||
from sqlmodel import col, or_, select, update
|
||||
|
||||
from .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import (
|
||||
@@ -20,7 +20,14 @@ from .core.db import (
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
from .wallet import (
|
||||
classify_redemption_error,
|
||||
credit_balance,
|
||||
is_mint_connection_error,
|
||||
recieve_token,
|
||||
send_to_lnurl,
|
||||
send_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
@@ -45,10 +52,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 +62,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)
|
||||
@@ -155,30 +163,18 @@ async def topup_wallet_endpoint(
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Token already spent")
|
||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Token value is too small to cover swap fees. {error_msg}",
|
||||
)
|
||||
elif "failed to melt" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to swap foreign mint token. {error_msg}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
# Shared taxonomy so top-up matches the bearer/X-Cashu paths (503 for an
|
||||
# unreachable mint, 422 for fee/swap failures, 400 for token faults).
|
||||
classified = classify_redemption_error(e)
|
||||
if classified is None:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
_type, status_code, message, _code = classified
|
||||
raise HTTPException(status_code=status_code, detail=message)
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
@@ -273,7 +269,20 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
out_tx = out_tx_result.first()
|
||||
if out_tx is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
# The "in" row exists with a request_id, but the "out" (refund)
|
||||
# row hasn't been written yet — the upstream request is still in
|
||||
# flight and the refund will be minted once it completes. Tell the
|
||||
# client to retry instead of 404ing permanently (race condition
|
||||
# where /v1/wallet/refund is polled before the refund exists).
|
||||
logger.debug(
|
||||
"refund_wallet_endpoint: refund pending (in row exists, out row not yet created)",
|
||||
extra={"request_id": in_tx.request_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=425,
|
||||
detail="Refund is pending; retry shortly.",
|
||||
headers={"Retry-After": "2"},
|
||||
)
|
||||
if out_tx.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
|
||||
@@ -312,9 +321,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
|
||||
@@ -341,7 +377,7 @@ async def refund_wallet_endpoint(
|
||||
.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)
|
||||
.values(balance=0, reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
@@ -354,21 +390,26 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
|
||||
# --- 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}
|
||||
|
||||
@@ -396,12 +437,19 @@ async def refund_wallet_endpoint(
|
||||
# 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)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
or "connection" in error_msg.lower()
|
||||
or isinstance(e, Exception)
|
||||
and "ConnectError" in str(type(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 is_mint_connection_error(e):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
@@ -519,10 +567,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
|
||||
@@ -547,6 +609,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,
|
||||
@@ -593,26 +656,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}",
|
||||
@@ -626,7 +669,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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -8,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, RootModel
|
||||
from pydantic.v1 import ValidationError as PydanticValidationError
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..payment.models import _row_to_model, list_models
|
||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||
@@ -22,12 +24,14 @@ from .db import (
|
||||
ApiKey,
|
||||
CashuTransaction,
|
||||
CliToken,
|
||||
LightningInvoice,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
)
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
from .provider_slugs import allocate_unique_provider_slug
|
||||
from .settings import SettingsService, settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -67,26 +71,89 @@ async def require_admin_api(request: Request) -> None:
|
||||
|
||||
|
||||
@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):
|
||||
@@ -392,19 +459,18 @@ class ModelCreate(BaseModel):
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def upsert_provider_model(
|
||||
provider_id: int, payload: ModelCreate
|
||||
provider_id: str, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
print(payload)
|
||||
logger.info(
|
||||
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
||||
)
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
provider_pk = _provider_pk(provider)
|
||||
|
||||
# Try to get existing model
|
||||
existing_row = await session.get(ModelRow, (payload.id, provider_id))
|
||||
existing_row = await session.get(ModelRow, (payload.id, provider_pk))
|
||||
|
||||
if existing_row:
|
||||
# Update existing model
|
||||
@@ -460,7 +526,7 @@ async def upsert_provider_model(
|
||||
alias_ids=(
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
upstream_provider_id=provider_pk,
|
||||
enabled=payload.enabled,
|
||||
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
||||
)
|
||||
@@ -479,7 +545,7 @@ async def upsert_provider_model(
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_legacy(
|
||||
provider_id: int, model_id: str, payload: ModelCreate
|
||||
provider_id: str, model_id: str, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
||||
logger.info(
|
||||
@@ -492,13 +558,12 @@ async def update_provider_model_legacy(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_model(provider_id: int, model_id: str) -> dict[str, object]:
|
||||
async def get_provider_model(provider_id: str, model_id: str) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
provider_pk = _provider_pk(provider)
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
row = await session.get(ModelRow, (model_id, provider_pk))
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
@@ -512,9 +577,11 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, object]:
|
||||
async def delete_provider_model(provider_id: str, model_id: str) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
provider_pk = _provider_pk(provider)
|
||||
row = await session.get(ModelRow, (model_id, provider_pk))
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
@@ -529,10 +596,12 @@ async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, ob
|
||||
"/api/upstream-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def delete_all_provider_models(provider_id: int) -> dict[str, object]:
|
||||
async def delete_all_provider_models(provider_id: str) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
provider_pk = _provider_pk(provider)
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(ModelRow.upstream_provider_id == provider_id)
|
||||
select(ModelRow).where(ModelRow.upstream_provider_id == provider_pk)
|
||||
) # type: ignore
|
||||
rows = result.all()
|
||||
for row in rows:
|
||||
@@ -551,7 +620,7 @@ class BatchOverrideRequest(BaseModel):
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def batch_override_provider_models(
|
||||
provider_id: int, payload: BatchOverrideRequest
|
||||
provider_id: str, payload: BatchOverrideRequest
|
||||
) -> dict[str, object]:
|
||||
"""Batch override models for a specific provider."""
|
||||
logger.info(
|
||||
@@ -559,15 +628,14 @@ async def batch_override_provider_models(
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
provider_pk = _provider_pk(provider)
|
||||
|
||||
overridden_count = 0
|
||||
|
||||
for model_data in payload.models:
|
||||
# Try to get existing model regardless of whether it's enabled or not
|
||||
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
|
||||
existing_row = await session.get(ModelRow, (model_data.id, provider_pk))
|
||||
|
||||
if existing_row:
|
||||
# Update existing
|
||||
@@ -621,7 +689,7 @@ async def batch_override_provider_models(
|
||||
if model_data.alias_ids
|
||||
else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
upstream_provider_id=provider_pk,
|
||||
enabled=model_data.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
@@ -638,6 +706,85 @@ async def batch_override_provider_models(
|
||||
}
|
||||
|
||||
|
||||
_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$")
|
||||
|
||||
|
||||
def _validate_slug(value: str) -> str:
|
||||
candidate = value.strip().lower()
|
||||
if not _SLUG_PATTERN.fullmatch(candidate):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"slug must be 3-64 chars, lowercase letters/digits/hyphens, "
|
||||
"and may not start or end with a hyphen"
|
||||
),
|
||||
)
|
||||
if candidate.isdigit():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="slug must not be all digits",
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
async def _ensure_unique_slug(
|
||||
session: AsyncSession, slug: str, exclude_id: int | None = None
|
||||
) -> None:
|
||||
stmt = select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug)
|
||||
result = await session.exec(stmt)
|
||||
existing = result.first()
|
||||
if existing and existing.id != exclude_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Provider with this slug already exists",
|
||||
)
|
||||
|
||||
|
||||
async def _get_upstream_provider_by_ref(
|
||||
session: AsyncSession, provider_ref: str
|
||||
) -> UpstreamProviderRow:
|
||||
if provider_ref.isdigit():
|
||||
provider = await session.get(UpstreamProviderRow, int(provider_ref))
|
||||
else:
|
||||
slug = _validate_slug(provider_ref)
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug)
|
||||
)
|
||||
provider = result.first()
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return provider
|
||||
|
||||
|
||||
def _provider_pk(provider: UpstreamProviderRow) -> int:
|
||||
if provider.id is None:
|
||||
raise HTTPException(status_code=500, detail="Provider has no database id")
|
||||
return provider.id
|
||||
|
||||
|
||||
def _serialize_provider(
|
||||
provider: UpstreamProviderRow, redact_api_key: bool = True
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": provider.id,
|
||||
"slug": provider.slug,
|
||||
"provider_type": provider.provider_type,
|
||||
"base_url": provider.base_url,
|
||||
"api_key": "[REDACTED]"
|
||||
if (redact_api_key and provider.api_key)
|
||||
else provider.api_key
|
||||
if not redact_api_key
|
||||
else "",
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
class UpstreamProviderCreate(BaseModel):
|
||||
provider_type: str
|
||||
base_url: str
|
||||
@@ -646,6 +793,7 @@ class UpstreamProviderCreate(BaseModel):
|
||||
enabled: bool = True
|
||||
provider_fee: float = 1.01
|
||||
provider_settings: dict | None = None
|
||||
slug: str | None = None
|
||||
|
||||
|
||||
class UpstreamProviderUpdate(BaseModel):
|
||||
@@ -656,6 +804,50 @@ class UpstreamProviderUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
provider_fee: float | None = None
|
||||
provider_settings: dict | None = None
|
||||
slug: str | None = None
|
||||
|
||||
|
||||
class UpstreamProviderUpdateBySlug(BaseModel):
|
||||
slug: str
|
||||
new_slug: str | None = None
|
||||
provider_type: str | None = None
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
api_version: str | None = None
|
||||
enabled: bool | None = None
|
||||
provider_fee: float | None = None
|
||||
provider_settings: dict | None = None
|
||||
|
||||
|
||||
async def _apply_provider_update(
|
||||
session: AsyncSession,
|
||||
provider: UpstreamProviderRow,
|
||||
payload: UpstreamProviderUpdate,
|
||||
new_slug: str | None = None,
|
||||
) -> None:
|
||||
if new_slug is not None:
|
||||
validated = _validate_slug(new_slug)
|
||||
await _ensure_unique_slug(session, validated, exclude_id=provider.id)
|
||||
provider.slug = validated
|
||||
|
||||
if payload.provider_type is not None:
|
||||
provider.provider_type = payload.provider_type
|
||||
if payload.base_url is not None:
|
||||
provider.base_url = payload.base_url
|
||||
if payload.api_key is not None:
|
||||
provider.api_key = payload.api_key
|
||||
if payload.api_version is not None:
|
||||
provider.api_version = payload.api_version
|
||||
if payload.enabled is not None:
|
||||
provider.enabled = payload.enabled
|
||||
if payload.provider_fee is not None:
|
||||
provider.provider_fee = payload.provider_fee
|
||||
if payload.provider_settings is not None:
|
||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
|
||||
|
||||
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||
@@ -663,21 +855,7 @@ async def get_upstream_providers() -> list[dict[str, object]]:
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
providers = result.all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"provider_type": p.provider_type,
|
||||
"base_url": p.base_url,
|
||||
"api_key": "[REDACTED]" if p.api_key else "",
|
||||
"api_version": p.api_version,
|
||||
"enabled": p.enabled,
|
||||
"provider_fee": p.provider_fee,
|
||||
"provider_settings": json.loads(p.provider_settings)
|
||||
if p.provider_settings
|
||||
else None,
|
||||
}
|
||||
for p in providers
|
||||
]
|
||||
return [_serialize_provider(p) for p in providers]
|
||||
|
||||
|
||||
@admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||
@@ -697,7 +875,14 @@ async def create_upstream_provider(
|
||||
detail="Provider with this base URL and API key already exists",
|
||||
)
|
||||
|
||||
if payload.slug:
|
||||
slug = _validate_slug(payload.slug)
|
||||
await _ensure_unique_slug(session, slug)
|
||||
else:
|
||||
slug = await allocate_unique_provider_slug(session, payload.provider_type)
|
||||
|
||||
provider = UpstreamProviderRow(
|
||||
slug=slug,
|
||||
provider_type=payload.provider_type,
|
||||
base_url=payload.base_url,
|
||||
api_key=payload.api_key,
|
||||
@@ -714,99 +899,81 @@ async def create_upstream_provider(
|
||||
|
||||
await reinitialize_upstreams()
|
||||
await refresh_model_maps()
|
||||
return {
|
||||
"id": provider.id,
|
||||
"provider_type": provider.provider_type,
|
||||
"base_url": provider.base_url,
|
||||
"api_key": "[REDACTED]",
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": payload.provider_settings,
|
||||
}
|
||||
return _serialize_provider(provider)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def get_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
async def get_upstream_provider(provider_id: str) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return {
|
||||
"id": provider.id,
|
||||
"provider_type": provider.provider_type,
|
||||
"base_url": provider.base_url,
|
||||
"api_key": "[REDACTED]" if provider.api_key else "",
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
return _serialize_provider(provider)
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def update_upstream_provider(
|
||||
provider_id: int, payload: UpstreamProviderUpdate
|
||||
provider_id: str, payload: UpstreamProviderUpdate
|
||||
) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
|
||||
if payload.provider_type is not None:
|
||||
provider.provider_type = payload.provider_type
|
||||
if payload.base_url is not None:
|
||||
provider.base_url = payload.base_url
|
||||
if payload.api_key is not None:
|
||||
provider.api_key = payload.api_key
|
||||
if payload.api_version is not None:
|
||||
provider.api_version = payload.api_version
|
||||
if payload.enabled is not None:
|
||||
provider.enabled = payload.enabled
|
||||
if payload.provider_fee is not None:
|
||||
provider.provider_fee = payload.provider_fee
|
||||
if payload.provider_settings is not None:
|
||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
await _apply_provider_update(session, provider, payload, new_slug=payload.slug)
|
||||
|
||||
await reinitialize_upstreams()
|
||||
await refresh_model_maps()
|
||||
return {
|
||||
"id": provider.id,
|
||||
"provider_type": provider.provider_type,
|
||||
"base_url": provider.base_url,
|
||||
"api_key": "[REDACTED]",
|
||||
"api_version": provider.api_version,
|
||||
"enabled": provider.enabled,
|
||||
"provider_fee": provider.provider_fee,
|
||||
"provider_settings": json.loads(provider.provider_settings)
|
||||
if provider.provider_settings
|
||||
else None,
|
||||
}
|
||||
return _serialize_provider(provider)
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def update_upstream_provider_by_slug(
|
||||
payload: UpstreamProviderUpdateBySlug,
|
||||
) -> dict[str, object]:
|
||||
lookup = _validate_slug(payload.slug)
|
||||
async with create_session() as session:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.slug == lookup
|
||||
)
|
||||
)
|
||||
provider = result.first()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
update_payload = UpstreamProviderUpdate(
|
||||
provider_type=payload.provider_type,
|
||||
base_url=payload.base_url,
|
||||
api_key=payload.api_key,
|
||||
api_version=payload.api_version,
|
||||
enabled=payload.enabled,
|
||||
provider_fee=payload.provider_fee,
|
||||
provider_settings=payload.provider_settings,
|
||||
)
|
||||
await _apply_provider_update(
|
||||
session, provider, update_payload, new_slug=payload.new_slug
|
||||
)
|
||||
|
||||
await reinitialize_upstreams()
|
||||
await refresh_model_maps()
|
||||
return _serialize_provider(provider)
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def delete_upstream_provider(provider_id: int) -> dict[str, object]:
|
||||
async def delete_upstream_provider(provider_id: str) -> dict[str, object]:
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
deleted_id = _provider_pk(provider)
|
||||
await session.delete(provider)
|
||||
await session.commit()
|
||||
await reinitialize_upstreams()
|
||||
await refresh_model_maps()
|
||||
return {"ok": True, "deleted_id": provider_id}
|
||||
return {"ok": True, "deleted_id": deleted_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
|
||||
@@ -821,17 +988,16 @@ async def get_provider_types() -> list[dict[str, object]]:
|
||||
"/api/upstream-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
async def get_provider_models(provider_id: str) -> dict[str, object]:
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
provider_pk = _provider_pk(provider)
|
||||
|
||||
db_models = await list_models(
|
||||
session=session,
|
||||
upstream_id=provider_id,
|
||||
upstream_id=provider_pk,
|
||||
include_disabled=True,
|
||||
apply_fees=False,
|
||||
)
|
||||
@@ -921,13 +1087,11 @@ class TopupTokenRequest(BaseModel):
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def topup_provider_with_token(
|
||||
provider_id: int, payload: TopupTokenRequest
|
||||
provider_id: str, payload: TopupTokenRequest
|
||||
) -> dict:
|
||||
"""Redeem a Cashu token for an upstream provider."""
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -958,15 +1122,13 @@ async def topup_provider_with_token(
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def initiate_provider_topup(
|
||||
provider_id: int, payload: TopupRequest
|
||||
provider_id: str, payload: TopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
@@ -1086,15 +1248,13 @@ async def initiate_provider_topup(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
||||
async def check_topup_status(provider_id: str, invoice_id: str) -> dict[str, object]:
|
||||
"""Check the status of a Lightning Network top-up invoice."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
|
||||
# For Routstr providers, proxy the status check
|
||||
if provider.provider_type == "routstr":
|
||||
@@ -1141,14 +1301,12 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
||||
"/api/upstream-providers/{provider_id}/balance",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
async def get_provider_balance(provider_id: str) -> dict[str, object]:
|
||||
"""Get the current balance for an upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
|
||||
# For Routstr providers, proxy the balance check
|
||||
if provider.provider_type == "routstr":
|
||||
@@ -1477,19 +1635,63 @@ async def get_transactions_api(
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
async def refund_routstr_provider_balance(provider_id: str) -> dict[str, object]:
|
||||
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.routstr import RoutstrUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider_row = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider_row:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
provider_row = await _get_upstream_provider_by_ref(session, provider_id)
|
||||
|
||||
if provider_row.provider_type != "routstr":
|
||||
raise HTTPException(
|
||||
|
||||
@@ -9,9 +9,10 @@ from typing import AsyncGenerator
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.util.exc import CommandError
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import UniqueConstraint, delete
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -33,6 +34,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 +54,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,12 +96,93 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
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("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
|
||||
|
||||
|
||||
async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> int:
|
||||
"""Delete dead parentless API keys; return the count removed.
|
||||
|
||||
Dead = 0 balance/reservation/spend/requests, older than the grace period,
|
||||
no parent, no children, no pending invoice. Cashu rows are unlinked (not
|
||||
deleted) first to keep the audit trail.
|
||||
"""
|
||||
cutoff = int(time.time()) - min_age_seconds
|
||||
|
||||
child = aliased(ApiKey)
|
||||
has_children = (
|
||||
select(child.hashed_key).where(
|
||||
col(child.parent_key_hash) == col(ApiKey.hashed_key)
|
||||
)
|
||||
).exists()
|
||||
pending_invoice = (
|
||||
select(LightningInvoice.id)
|
||||
.where(col(LightningInvoice.api_key_hash) == col(ApiKey.hashed_key))
|
||||
.where(col(LightningInvoice.status) == "pending")
|
||||
).exists()
|
||||
|
||||
eligible_hashes = (
|
||||
select(ApiKey.hashed_key)
|
||||
.where(col(ApiKey.balance) == 0)
|
||||
.where(col(ApiKey.reserved_balance) == 0)
|
||||
.where(col(ApiKey.total_spent) == 0)
|
||||
.where(col(ApiKey.total_requests) == 0)
|
||||
.where(col(ApiKey.parent_key_hash).is_(None))
|
||||
.where(
|
||||
(col(ApiKey.created_at).is_(None)) | (col(ApiKey.created_at) < cutoff)
|
||||
)
|
||||
.where(~pending_invoice)
|
||||
.where(~has_children)
|
||||
)
|
||||
|
||||
# Unlink transactions rather than cascade-deleting them, so the financial
|
||||
# audit trail survives. The eligibility predicate is re-evaluated inside both
|
||||
# statements so a key that gained balance mid-run is left untouched.
|
||||
await session.exec( # type: ignore[call-overload]
|
||||
update(CashuTransaction)
|
||||
.where(col(CashuTransaction.api_key_hashed_key).in_(eligible_hashes))
|
||||
.values(api_key_hashed_key=None)
|
||||
)
|
||||
|
||||
result = await session.exec( # type: ignore[call-overload]
|
||||
delete(ApiKey).where(col(ApiKey.hashed_key).in_(eligible_hashes))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
pruned = int(result.rowcount or 0)
|
||||
logger.info(
|
||||
"Pruned dead API keys",
|
||||
extra={"pruned_keys": pruned, "min_age_seconds": min_age_seconds},
|
||||
)
|
||||
return pruned
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "models"
|
||||
id: str = Field(primary_key=True)
|
||||
@@ -132,6 +230,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
|
||||
@@ -209,6 +319,12 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
),
|
||||
)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
slug: str | None = Field(
|
||||
default=None,
|
||||
unique=True,
|
||||
index=True,
|
||||
description="Stable external slug used for updates via API key.",
|
||||
)
|
||||
provider_type: str = Field(
|
||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||
)
|
||||
|
||||
@@ -7,11 +7,26 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""Exception raised when an upstream provider fails."""
|
||||
"""Exception raised when an upstream provider fails.
|
||||
|
||||
def __init__(self, message: str, status_code: int = 502):
|
||||
``code`` carries a stable, machine-readable classification (e.g.
|
||||
``UPSTREAM_RATE_LIMIT``) so callers can distinguish failure kinds without
|
||||
string-matching the message. ``details`` holds optional structured,
|
||||
redaction-safe context. Both default to ``None`` for backwards
|
||||
compatibility.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 502,
|
||||
code: str | None = None,
|
||||
details: dict[str, object] | None = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.details = details
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ from pythonjsonlogger import jsonlogger
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from .redaction import redact_obj, redact_org_ids
|
||||
|
||||
# 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.
|
||||
@@ -180,6 +182,37 @@ class RequestIdFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
# Standard ``LogRecord`` attributes that are never user-supplied ``extra``
|
||||
# fields; skipped when redacting structured extras (``msg``/``message`` are
|
||||
# handled separately above).
|
||||
_NON_EXTRA_RECORD_ATTRS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"pathname",
|
||||
"filename",
|
||||
"module",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"lineno",
|
||||
"funcName",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
"taskName",
|
||||
"message",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SecurityFilter(logging.Filter):
|
||||
"""Filter to remove sensitive information from logs."""
|
||||
|
||||
@@ -203,6 +236,7 @@ class SecurityFilter(logging.Filter):
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
message = redact_org_ids(message)
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
@@ -224,6 +258,16 @@ class SecurityFilter(logging.Filter):
|
||||
record.msg = message
|
||||
record.args = ()
|
||||
|
||||
# Structured `extra={...}` fields are emitted by the JSON formatter
|
||||
# straight from the record dict and never pass through the message
|
||||
# formatting above. Redact organization IDs from any string-valued
|
||||
# extra so they cannot leak via structured logs.
|
||||
for attr, value in list(record.__dict__.items()):
|
||||
if attr in _NON_EXTRA_RECORD_ATTRS:
|
||||
continue
|
||||
if isinstance(value, (str, dict, list, tuple)):
|
||||
record.__dict__[attr] = redact_obj(value)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ 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_dead_key_prune,
|
||||
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,
|
||||
@@ -23,6 +28,7 @@ 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 ..upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing
|
||||
from ..upstream.litellm_routing import configure_litellm
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
|
||||
from .admin import admin_router
|
||||
@@ -30,6 +36,7 @@ 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__
|
||||
@@ -52,15 +59,23 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
stale_reservation_task = None
|
||||
dead_key_prune_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()
|
||||
|
||||
# TEMPORARY: backfill DeepSeek V4 pricing missing from litellm's cost
|
||||
# map (BerriAI/litellm#30430). Remove this call and
|
||||
# deepseek_v4_pricing_shim.py once litellm ships these models.
|
||||
register_deepseek_v4_pricing()
|
||||
|
||||
# Run database migrations on startup
|
||||
run_migrations()
|
||||
|
||||
@@ -119,9 +134,14 @@ 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()
|
||||
)
|
||||
dead_key_prune_task = asyncio.create_task(periodic_dead_key_prune())
|
||||
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
|
||||
|
||||
@@ -155,12 +175,18 @@ 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 dead_key_prune_task is not None:
|
||||
dead_key_prune_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 = []
|
||||
@@ -182,12 +208,18 @@ 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 dead_key_prune_task is not None:
|
||||
tasks_to_wait.append(dead_key_prune_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)
|
||||
@@ -347,7 +379,10 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
"UI dist directory not found at %s; serving API only. Run `make ui-build` "
|
||||
"to build the static UI served from here, or `make ui-dev` for the Next.js "
|
||||
"dev server with hot reload on :3000 (it targets this backend on :8000).",
|
||||
UI_DIST_PATH,
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@@ -364,6 +399,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)
|
||||
|
||||
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)
|
||||
65
routstr/core/provider_slugs.py
Normal file
65
routstr/core/provider_slugs.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from itertools import count
|
||||
from typing import Collection
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .db import UpstreamProviderRow
|
||||
|
||||
_SLUG_BASE_PATTERN = re.compile(r"[^a-z0-9]+")
|
||||
_MAX_SLUG_LENGTH = 64
|
||||
|
||||
|
||||
def provider_slug_base(provider_type: str) -> str:
|
||||
"""Return a deterministic slug base for a provider type."""
|
||||
base = _SLUG_BASE_PATTERN.sub("-", provider_type.lower()).strip("-")
|
||||
if not base:
|
||||
base = "provider"
|
||||
elif base.isdigit():
|
||||
base = f"provider-{base}"
|
||||
elif len(base) < 3:
|
||||
base = f"{base}-provider"
|
||||
|
||||
if len(base) > _MAX_SLUG_LENGTH:
|
||||
base = base[:_MAX_SLUG_LENGTH].rstrip("-") or "provider"
|
||||
return base
|
||||
|
||||
|
||||
def provider_slug_candidate(base: str, suffix_number: int) -> str:
|
||||
if suffix_number == 1:
|
||||
return base
|
||||
|
||||
suffix = f"-{suffix_number}"
|
||||
max_base_length = _MAX_SLUG_LENGTH - len(suffix)
|
||||
return f"{base[:max_base_length].rstrip('-')}{suffix}"
|
||||
|
||||
|
||||
async def allocate_unique_provider_slug(
|
||||
session: AsyncSession,
|
||||
provider_type: str,
|
||||
reserved_slugs: Collection[str] = (),
|
||||
) -> str:
|
||||
"""Allocate a stable, deterministic provider slug.
|
||||
|
||||
The first provider of a type gets ``openai``; later collisions get
|
||||
``openai-2``, ``openai-3``, etc. ``reserved_slugs`` covers rows staged in
|
||||
memory but not flushed yet, such as settings/env seeding.
|
||||
"""
|
||||
base = provider_slug_base(provider_type)
|
||||
reserved = {slug.lower() for slug in reserved_slugs}
|
||||
|
||||
for suffix_number in count(1):
|
||||
candidate = provider_slug_candidate(base, suffix_number)
|
||||
if candidate in reserved:
|
||||
continue
|
||||
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.slug == candidate)
|
||||
)
|
||||
if result.first() is None:
|
||||
return candidate
|
||||
|
||||
raise RuntimeError("unreachable")
|
||||
51
routstr/core/redaction.py
Normal file
51
routstr/core/redaction.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Redaction helpers for sensitive provider identifiers.
|
||||
|
||||
Single source of truth for stripping account-scoped identifiers (e.g. OpenAI
|
||||
organization IDs) from any text before it is logged, returned to a caller, or
|
||||
written to an audit entry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# OpenAI-style organization identifiers look like ``org-<base62>``. Require at
|
||||
# least 6 trailing chars so the already-redacted literal ``org-[REDACTED]`` is
|
||||
# never re-matched (``[`` is not in the character class).
|
||||
_ORG_ID_PATTERN = re.compile(r"\borg-[A-Za-z0-9]{6,}\b")
|
||||
|
||||
ORG_ID_PLACEHOLDER = "org-[REDACTED]"
|
||||
|
||||
|
||||
def redact_org_ids(text: str) -> str:
|
||||
"""Replace OpenAI-style organization IDs with ``org-[REDACTED]``.
|
||||
|
||||
Args:
|
||||
text: Arbitrary text that may embed an ``org-*`` identifier.
|
||||
|
||||
Returns:
|
||||
The text with every organization ID replaced. Non-string input is
|
||||
returned unchanged after coercion to ``str``.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return _ORG_ID_PATTERN.sub(ORG_ID_PLACEHOLDER, text)
|
||||
|
||||
|
||||
def redact_obj(obj: Any) -> Any:
|
||||
"""Recursively redact organization IDs in arbitrary nested structures.
|
||||
|
||||
Strings are redacted in place; dicts and lists/tuples are walked so that
|
||||
identifiers nested inside structured payloads (e.g. log ``extra`` fields or
|
||||
error ``details``) are also stripped. Other types are returned unchanged.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return redact_org_ids(obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: redact_obj(value) for key, value in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [redact_obj(value) for value in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(redact_obj(value) for value in obj)
|
||||
return obj
|
||||
@@ -67,6 +67,20 @@ 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"
|
||||
)
|
||||
# Background prune of dead (zero balance, never used) API keys.
|
||||
# Interval 0 disables it; min-age is a grace period (default 1 week).
|
||||
dead_key_prune_interval_seconds: int = Field(
|
||||
default=3600, env="DEAD_KEY_PRUNE_INTERVAL_SECONDS"
|
||||
)
|
||||
dead_key_min_age_seconds: int = Field(
|
||||
default=604_800, env="DEAD_KEY_MIN_AGE_SECONDS"
|
||||
)
|
||||
|
||||
# Network
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||
|
||||
@@ -20,7 +20,7 @@ import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
BASE_VERSION = "0.4.3"
|
||||
BASE_VERSION = "0.4.4"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_GIT_TIMEOUT_SECONDS = 2.0
|
||||
@@ -57,11 +57,6 @@ def _on_tagged_release() -> bool:
|
||||
described = _run_git("describe", "--tags", "--exact-match", "HEAD")
|
||||
if described and described.lstrip("v") == BASE_VERSION:
|
||||
return True
|
||||
current_branch = _run_git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
if current_branch == "main":
|
||||
branch_tag = _run_git("describe", "--tags", "--abbrev=0", "HEAD")
|
||||
if branch_tag and branch_tag.lstrip("v") == BASE_VERSION:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,9 +3,17 @@ import math
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
from .usage import normalize_usage, parse_token_count
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
"CostDataError",
|
||||
"MaxCostData",
|
||||
"calculate_cost",
|
||||
"parse_token_count",
|
||||
]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -33,8 +41,31 @@ class CostDataError(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
def _empty_cost(cls: type[CostData] = CostData) -> CostData:
|
||||
"""Build an all-zero cost object — a full refund for an empty response.
|
||||
|
||||
Shared by the two paths that must not bill: an upstream response with no
|
||||
usage data at all, and one that reports a USD cost but carries zero tokens
|
||||
in every bucket.
|
||||
"""
|
||||
return cls(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_cost(
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
response_data: dict,
|
||||
max_cost: int,
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""Calculate the cost of an API request based on token usage.
|
||||
|
||||
@@ -44,6 +75,9 @@ async def calculate_cost(
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
|
||||
The response's usage object is normalized with the default union parser;
|
||||
this function holds no vendor-dialect knowledge of its own.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
@@ -54,8 +88,9 @@ async def calculate_cost(
|
||||
},
|
||||
)
|
||||
|
||||
# Check for usage data
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
usage = normalize_usage(response_data.get("usage"))
|
||||
|
||||
if usage is None:
|
||||
logger.warning(
|
||||
"No usage data in response — billing at MaxCostData with zero "
|
||||
"tokens. Dashboard will show this request as `(0+0)`. Most "
|
||||
@@ -70,34 +105,41 @@ async def calculate_cost(
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
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,
|
||||
)
|
||||
return _empty_cost(MaxCostData)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
usage_data = response_data.get("usage") or {}
|
||||
if not isinstance(usage_data, dict):
|
||||
usage_data = {}
|
||||
|
||||
# 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")
|
||||
|
||||
# Extract cache tokens (handles OpenAI vs Anthropic formats)
|
||||
cache_read_tokens, cache_creation_tokens, input_tokens = _extract_cache_tokens(
|
||||
usage_data, input_tokens
|
||||
)
|
||||
input_tokens = usage.input_tokens
|
||||
output_tokens = usage.output_tokens
|
||||
cache_read_tokens = usage.cache_read_tokens
|
||||
cache_creation_tokens = usage.cache_write_tokens
|
||||
|
||||
# Try USD cost first
|
||||
usd_cost = _resolve_usd_cost(usage_data, response_data)
|
||||
if usd_cost > 0:
|
||||
truly_empty = (
|
||||
input_tokens == 0
|
||||
and output_tokens == 0
|
||||
and cache_read_tokens == 0
|
||||
and cache_creation_tokens == 0
|
||||
)
|
||||
if truly_empty:
|
||||
logger.warning(
|
||||
"Upstream reported a USD cost but the response carries no "
|
||||
"tokens at all (input, output, cache-read and cache-creation "
|
||||
"are all zero) — refunding in full rather than billing the "
|
||||
"USD-derived cost for an empty response.",
|
||||
extra={
|
||||
"model": response_data.get("model", "unknown"),
|
||||
"usd_cost": usd_cost,
|
||||
"usage_keys": sorted(usage_data.keys())
|
||||
if isinstance(usage_data, dict)
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return _empty_cost()
|
||||
if input_tokens == 0 and output_tokens == 0:
|
||||
logger.warning(
|
||||
"Upstream reported a USD cost but no token counts — "
|
||||
@@ -202,22 +244,6 @@ async def 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):
|
||||
@@ -230,37 +256,6 @@ def _coerce_usd(value: object) -> float:
|
||||
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.
|
||||
|
||||
@@ -336,6 +331,22 @@ def _get_pricing_rates(
|
||||
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,
|
||||
@@ -347,6 +358,10 @@ def _calculate_from_usd_cost(
|
||||
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)
|
||||
@@ -434,10 +449,21 @@ def _calculate_from_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
# Fold the cache-read/write cost into the visible ``input_msats`` so a
|
||||
# dashboard that renders I / O / T sees ``input + output == total``
|
||||
# exactly. This mirrors ``_fold_cache_into_input_tokens`` (which rolls the
|
||||
# cache token counts into the visible prompt total). The standalone
|
||||
# ``cache_read_msats`` / ``cache_creation_msats`` fields stay populated for
|
||||
# clients that want the breakdown; nothing sums the components to derive
|
||||
# ``total_msats`` (it is computed independently above), so this is
|
||||
# display-only and does not change what is billed.
|
||||
visible_output_msats = int(calc_output_msats)
|
||||
visible_input_msats = token_based_cost - visible_output_msats
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(calc_input_msats),
|
||||
output_msats=int(calc_output_msats),
|
||||
input_msats=visible_input_msats,
|
||||
output_msats=visible_output_msats,
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
input_tokens=input_tokens,
|
||||
|
||||
@@ -11,6 +11,8 @@ from PIL import Image
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.exceptions import UpstreamError
|
||||
from ..core.redaction import redact_org_ids
|
||||
from ..core.settings import settings
|
||||
from ..wallet import deserialize_token_from_string
|
||||
|
||||
@@ -418,16 +420,27 @@ def create_error_response(
|
||||
status_code: int,
|
||||
request: Request,
|
||||
token: str | None = None,
|
||||
code: str | int | None = None,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> Response:
|
||||
"""Create a standardized error response."""
|
||||
"""Create a standardized error response.
|
||||
|
||||
``code`` is a stable, machine-readable classification (e.g.
|
||||
``UPSTREAM_RATE_LIMIT``); when omitted it defaults to the HTTP status code
|
||||
for backwards compatibility. ``details`` carries optional structured,
|
||||
redaction-safe context.
|
||||
"""
|
||||
error_obj: dict[str, object] = {
|
||||
"message": redact_org_ids(message),
|
||||
"type": error_type,
|
||||
"code": code if code is not None else status_code,
|
||||
}
|
||||
if details is not None:
|
||||
error_obj["details"] = details
|
||||
return Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": error_type,
|
||||
"code": status_code,
|
||||
},
|
||||
"error": error_obj,
|
||||
"request_id": getattr(request.state, "request_id", "unknown"),
|
||||
}
|
||||
),
|
||||
@@ -435,3 +448,20 @@ def create_error_response(
|
||||
media_type="application/json",
|
||||
headers={"X-Cashu": token} if token else {},
|
||||
)
|
||||
|
||||
|
||||
def create_upstream_error_response(
|
||||
error: UpstreamError,
|
||||
request: Request,
|
||||
fallback_status: int = 502,
|
||||
) -> Response:
|
||||
"""Build an error response from an :class:`UpstreamError`, preserving its
|
||||
structured ``code``, ``details``, and original ``status_code``."""
|
||||
return create_error_response(
|
||||
"upstream_error",
|
||||
str(error),
|
||||
error.status_code or fallback_status,
|
||||
request=request,
|
||||
code=getattr(error, "code", None),
|
||||
details=getattr(error, "details", None),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
@@ -17,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
|
||||
@@ -67,6 +85,64 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def litellm_cost_entry(model_id: str) -> dict | None:
|
||||
"""Look up ``model_id`` in litellm's bundled cost map.
|
||||
|
||||
litellm ships per-model USD rates keyed by the exact OpenRouter id
|
||||
(``deepseek/deepseek-chat``) or the bare model name (``gpt-4o``,
|
||||
``claude-sonnet-4-5``), so both spellings are tried. Keys are lowercase, so
|
||||
a mixed-case upstream id (``deepseek-ai/DeepSeek-V4-Flash``) is retried via
|
||||
a case-insensitive scan. Returns the matched cost dict, or ``None``.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
candidates = (model_id, model_id.split("/", 1)[-1])
|
||||
for key in candidates:
|
||||
info = litellm.model_cost.get(key)
|
||||
if isinstance(info, dict):
|
||||
return info
|
||||
|
||||
lowered = {c.lower() for c in candidates}
|
||||
for key, info in litellm.model_cost.items():
|
||||
if isinstance(key, str) and key.lower() in lowered and isinstance(info, dict):
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
||||
"""Fill missing cache rates from litellm's bundled cost map.
|
||||
|
||||
The OpenRouter model feed omits ``input_cache_read``/``input_cache_write``
|
||||
for many models (most DeepSeek entries, openai/gpt-4o, ...). Without a
|
||||
cache rate, billing falls back to the full input rate, which overcharges
|
||||
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
|
||||
cache writes (1.25x). The lookup (see ``litellm_cost_entry``) tries both
|
||||
id spellings and a case-insensitive fallback.
|
||||
|
||||
Rates already present (e.g. provided by OpenRouter) are authoritative and
|
||||
never overwritten. Unknown models are returned unchanged.
|
||||
"""
|
||||
needs_read = (pricing.input_cache_read or 0.0) <= 0.0
|
||||
needs_write = (pricing.input_cache_write or 0.0) <= 0.0
|
||||
if not (needs_read or needs_write):
|
||||
return pricing
|
||||
|
||||
info = litellm_cost_entry(model_id)
|
||||
if info is None:
|
||||
return pricing
|
||||
|
||||
updated = Pricing.parse_obj(pricing.dict())
|
||||
if needs_read:
|
||||
read_rate = info.get("cache_read_input_token_cost")
|
||||
if isinstance(read_rate, (int, float)) and read_rate > 0:
|
||||
updated.input_cache_read = float(read_rate)
|
||||
if needs_write:
|
||||
write_rate = info.get("cache_creation_input_token_cost")
|
||||
if isinstance(write_rate, (int, float)) and write_rate > 0:
|
||||
updated.input_cache_write = float(write_rate)
|
||||
return updated
|
||||
|
||||
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
@@ -155,13 +231,29 @@ def _row_to_model(
|
||||
)
|
||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
parsed_pricing = Pricing.parse_obj(pricing)
|
||||
|
||||
# Fill missing cache-read/write rates from litellm's cost map BEFORE applying
|
||||
# the provider fee, so they carry the same markup as every other component.
|
||||
# DB-stored override pricing (e.g. generic providers) omits cache rates;
|
||||
# without this, ``_row_to_model`` bills cache reads at the full input rate —
|
||||
# the ``_apply_provider_fee_to_model`` path backfills, but the override path
|
||||
# used for admin-configured providers did not.
|
||||
#
|
||||
# Key on ``forwarded_model_id`` (the actual upstream model name litellm
|
||||
# prices) when set: an alias row (id="local-alias",
|
||||
# forwarded_model_id="deepseek-v4-flash") would otherwise look up the alias
|
||||
# and miss the cache rate.
|
||||
pricing_model_id = getattr(row, "forwarded_model_id", None) or row.id
|
||||
parsed_pricing = backfill_cache_pricing(pricing_model_id, parsed_pricing)
|
||||
|
||||
if apply_provider_fee:
|
||||
parsed_pricing = Pricing.parse_obj(
|
||||
{k: float(v) * provider_fee for k, v in parsed_pricing.dict().items()}
|
||||
)
|
||||
model = Model(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
@@ -418,7 +510,9 @@ class ModelTestRequest(V2BaseModel):
|
||||
request_data: dict
|
||||
|
||||
|
||||
@models_router.post("/api/models/test")
|
||||
@models_router.post(
|
||||
"/api/models/test", dependencies=[Depends(_require_admin_api)]
|
||||
)
|
||||
async def test_model(
|
||||
payload: ModelTestRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -446,16 +540,35 @@ async def test_model(
|
||||
"status_code": 404,
|
||||
}
|
||||
|
||||
base_url = provider.base_url.rstrip("/")
|
||||
if payload.endpoint_type == "chat-completions":
|
||||
url = f"{base_url}/chat/completions"
|
||||
else:
|
||||
url = f"{base_url}/{payload.endpoint_type}"
|
||||
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}",
|
||||
|
||||
131
routstr/payment/usage.py
Normal file
131
routstr/payment/usage.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Vendor-agnostic normalization of upstream usage objects.
|
||||
|
||||
Upstream providers report token usage in vendor dialects that differ in field
|
||||
names and in whether cached tokens are included in the input count:
|
||||
|
||||
* OpenAI / Azure / xAI / Groq / Moonshot / Qwen / Gemini-compat: cache reads in
|
||||
``prompt_tokens_details.cached_tokens``, included in ``prompt_tokens``.
|
||||
* OpenRouter: same as OpenAI plus cache *writes* in
|
||||
``prompt_tokens_details.cache_write_tokens``, also included in
|
||||
``prompt_tokens``.
|
||||
* litellm-normalized: same nesting, but names the write field
|
||||
``prompt_tokens_details.cache_creation_tokens`` (and additionally mirrors the
|
||||
Anthropic top-level fields), with ``prompt_tokens`` as the grand total.
|
||||
* Anthropic native: ``cache_read_input_tokens`` / ``cache_creation_input_tokens``
|
||||
top-level, additive to (not included in) ``input_tokens``.
|
||||
* DeepSeek: ``prompt_cache_hit_tokens`` / ``prompt_cache_miss_tokens``, with
|
||||
``prompt_tokens = hit + miss``.
|
||||
|
||||
What decides whether cached tokens must be subtracted out of the input count is
|
||||
**which prompt field the vendor uses**, not which cache field appears:
|
||||
|
||||
* ``prompt_tokens`` present -> cached + cache-write tokens are *included* in it
|
||||
(OpenAI family, DeepSeek, OpenRouter, litellm); subtract both so
|
||||
``input_tokens`` holds only the regular-rate portion.
|
||||
* only ``input_tokens`` (Anthropic native) -> cached tokens are *additive*;
|
||||
leave ``input_tokens`` untouched.
|
||||
|
||||
``normalize_usage`` maps all of them onto one canonical ``NormalizedUsage``
|
||||
shape so billing code needs no vendor knowledge. The known dialects' field
|
||||
names do not collide, so a single union parser is safe; a vendor whose fields
|
||||
would genuinely conflict needs a dedicated branch here.
|
||||
"""
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
|
||||
class NormalizedUsage(BaseModel):
|
||||
"""Canonical token usage: input_tokens never includes cached tokens."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
cache_write_tokens: int = 0
|
||||
|
||||
|
||||
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 _first_token_count(usage_data: dict, *fields: str) -> int:
|
||||
"""Return the first positive token count among the given fields."""
|
||||
for field in fields:
|
||||
value = parse_token_count(usage_data.get(field, 0))
|
||||
if value > 0:
|
||||
return value
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_cache_tokens(usage_data: dict) -> tuple[int, int]:
|
||||
"""Pull (cache_read, cache_write) across all known dialects.
|
||||
|
||||
Precedence (highest first), independent for reads and writes:
|
||||
|
||||
* Anthropic top-level: ``cache_read_input_tokens`` /
|
||||
``cache_creation_input_tokens``.
|
||||
* Nested ``prompt_tokens_details``: ``cached_tokens`` for reads;
|
||||
``cache_creation_tokens`` (litellm) or ``cache_write_tokens``
|
||||
(OpenRouter) for writes.
|
||||
* DeepSeek: ``prompt_cache_hit_tokens`` for reads (no write concept).
|
||||
"""
|
||||
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
|
||||
cache_write = parse_token_count(usage_data.get("cache_creation_input_tokens", 0))
|
||||
|
||||
prompt_details = usage_data.get("prompt_tokens_details")
|
||||
if isinstance(prompt_details, dict):
|
||||
if not cache_read:
|
||||
cache_read = parse_token_count(prompt_details.get("cached_tokens", 0))
|
||||
if not cache_write:
|
||||
cache_write = _first_token_count(
|
||||
prompt_details, "cache_creation_tokens", "cache_write_tokens"
|
||||
)
|
||||
|
||||
if not cache_read:
|
||||
# DeepSeek: prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens
|
||||
cache_read = parse_token_count(usage_data.get("prompt_cache_hit_tokens", 0))
|
||||
|
||||
return cache_read, cache_write
|
||||
|
||||
|
||||
def normalize_usage(usage_data: object) -> NormalizedUsage | None:
|
||||
"""Map a vendor usage dict onto the canonical shape, or None if absent.
|
||||
|
||||
Cached reads and writes are subtracted from the input count exactly once,
|
||||
only for dialects that report a ``prompt_tokens`` grand total that already
|
||||
includes them (OpenAI family, DeepSeek, OpenRouter, litellm). Anthropic
|
||||
native reports them additively under ``input_tokens`` and is left untouched.
|
||||
"""
|
||||
if not isinstance(usage_data, dict):
|
||||
return None
|
||||
|
||||
output_tokens = _first_token_count(
|
||||
usage_data, "completion_tokens", "output_tokens"
|
||||
)
|
||||
cache_read, cache_write = _extract_cache_tokens(usage_data)
|
||||
|
||||
# ``prompt_tokens`` is the inclusive grand total; ``input_tokens`` (Anthropic
|
||||
# native) excludes cached tokens. The field chosen decides whether to subtract.
|
||||
if "prompt_tokens" in usage_data:
|
||||
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
|
||||
input_tokens = max(0, input_tokens - cache_read - cache_write)
|
||||
else:
|
||||
input_tokens = parse_token_count(usage_data.get("input_tokens", 0))
|
||||
|
||||
return NormalizedUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read,
|
||||
cache_write_tokens=cache_write,
|
||||
)
|
||||
278
routstr/proxy.py
278
routstr/proxy.py
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlmodel import select
|
||||
|
||||
from .algorithm import create_model_mappings
|
||||
@@ -18,16 +18,19 @@ 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,
|
||||
check_token_balance,
|
||||
create_error_response,
|
||||
create_upstream_error_response,
|
||||
get_max_cost_for_model,
|
||||
)
|
||||
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()
|
||||
@@ -151,50 +154,31 @@ async def refresh_model_maps_periodically() -> None:
|
||||
)
|
||||
|
||||
|
||||
_API_PATH_PREFIXES = ("v1/", "responses")
|
||||
|
||||
_NOT_FOUND_HTML_FILE = Path(__file__).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 for unknown paths.
|
||||
"""
|
||||
accept = request.headers.get("accept", "").lower()
|
||||
prefers_json = "application/json" in accept and "text/html" not in accept
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
if not prefers_json 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,
|
||||
},
|
||||
)
|
||||
_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:
|
||||
if not path.startswith(_API_PATH_PREFIXES):
|
||||
return _build_not_found_response(request, path)
|
||||
# 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)
|
||||
|
||||
@@ -202,6 +186,38 @@ async def proxy(
|
||||
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_upstream_error_response(e, 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:
|
||||
@@ -252,17 +268,24 @@ 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
|
||||
continue
|
||||
|
||||
if last_error is not None:
|
||||
return create_upstream_error_response(last_error, request)
|
||||
return create_error_response(
|
||||
"upstream_error",
|
||||
str(last_error) if last_error else "All upstreams failed",
|
||||
502,
|
||||
request=request,
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
@@ -318,9 +341,7 @@ async def proxy(
|
||||
except UpstreamError as e:
|
||||
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
|
||||
if i == len(upstreams) - 1:
|
||||
last_error_response = create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
last_error_response = create_upstream_error_response(e, request)
|
||||
continue
|
||||
return last_error_response or create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
@@ -329,50 +350,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)
|
||||
@@ -397,10 +454,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
|
||||
@@ -408,34 +469,59 @@ 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
|
||||
if i == len(upstreams) - 1:
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
return create_error_response(
|
||||
"upstream_error", str(e), 502, request=request
|
||||
)
|
||||
return create_upstream_error_response(e, request)
|
||||
|
||||
# Otherwise loop continues to next provider
|
||||
continue
|
||||
|
||||
@@ -24,7 +24,7 @@ class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AnthropicUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -97,6 +97,8 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
|
||||
# Instantiate provider and check balance
|
||||
provider = RoutstrUpstreamProvider.from_db_row(row)
|
||||
if provider is None:
|
||||
return
|
||||
balance = await provider.get_balance()
|
||||
|
||||
if balance is None:
|
||||
|
||||
@@ -38,7 +38,7 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
self.api_version = api_version
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AzureUpstreamProvider | None":
|
||||
if not provider_row.api_version:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
157
routstr/upstream/cache_breakpoints.py
Normal file
157
routstr/upstream/cache_breakpoints.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Inject explicit prompt-cache breakpoints into OpenAI-shaped requests.
|
||||
|
||||
Some upstreams cache *explicitly*: the request must carry
|
||||
``cache_control: {"type": "ephemeral"}`` markers on the content blocks that
|
||||
should be cached. Two model families use this identical wire format:
|
||||
|
||||
* **Anthropic Claude** — direct or via OpenRouter's ``anthropic/*`` models.
|
||||
* **Alibaba's explicit-cache models on OpenRouter** — ``qwen/qwen3-max``,
|
||||
``qwen/qwen-plus``, ``qwen/qwen3.6-plus``, ``qwen/qwen3-coder-plus``,
|
||||
``qwen/qwen3-coder-flash`` and ``deepseek/deepseek-v3.2`` — which OpenRouter
|
||||
documents as using "the same syntax as Anthropic explicit caching".
|
||||
|
||||
Every other provider routstr proxies (OpenAI, Azure, xAI/Grok, Groq, Moonshot,
|
||||
default DeepSeek, Gemini implicit, Fireworks) caches *automatically* and needs
|
||||
no markers — they are left untouched.
|
||||
|
||||
A client that doesn't know it is talking to one of these models *through*
|
||||
routstr (e.g. an OpenAI-compatible coding agent pointed at a routstr URL) never
|
||||
emits the markers — it only adds them when it recognises the provider as
|
||||
OpenRouter. So caching silently never engages over routstr even though the same
|
||||
client caches fine talking to OpenRouter directly.
|
||||
|
||||
This module restores caching by stamping the standard breakpoints onto the
|
||||
forwarded body — the system prompt, the last tool, and the last conversation
|
||||
message (the format allows up to four; we use three, matching the common
|
||||
agent convention) — but only when the client supplied none of its own, so
|
||||
explicit client control always wins. The caller is responsible for only
|
||||
applying this toward an upstream that accepts the markers (OpenRouter /
|
||||
Anthropic), so they never leak to a provider that would reject them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# The single ephemeral marker stamped onto each chosen breakpoint. A 5-minute
|
||||
# TTL (the default for ``ephemeral``) — deliberately not the 1h tier, which
|
||||
# carries a higher cache-write premium and should stay opt-in.
|
||||
EPHEMERAL_CACHE_CONTROL: dict[str, str] = {"type": "ephemeral"}
|
||||
|
||||
# Alibaba's explicit-cache models on OpenRouter. Matched as substrings of the
|
||||
# model id (any spelling routstr carries). Snapshot endpoints that OpenRouter
|
||||
# documents as *not* supporting explicit caching (e.g. ``qwen3.5-plus-02-15``)
|
||||
# are different families and deliberately absent from this list.
|
||||
_ALIBABA_EXPLICIT_CACHE_SLUGS: tuple[str, ...] = (
|
||||
"qwen3-max",
|
||||
"qwen-plus",
|
||||
"qwen3.6-plus",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-flash",
|
||||
"deepseek-v3.2",
|
||||
)
|
||||
|
||||
|
||||
def is_explicit_cache_model(model_id: str | None, *fallbacks: str | None) -> bool:
|
||||
"""True when the target model uses the explicit ``cache_control`` dialect.
|
||||
|
||||
Covers the Claude family (broadly — every Claude model supports it) and
|
||||
Alibaba's documented explicit-cache models, across the id spellings routstr
|
||||
carries: the OpenRouter id (``anthropic/claude-...``, ``qwen/qwen3-max``),
|
||||
the bare upstream id, and any forwarded/canonical alias.
|
||||
"""
|
||||
for candidate in (model_id, *fallbacks):
|
||||
if not candidate:
|
||||
continue
|
||||
lowered = candidate.lower()
|
||||
if "claude" in lowered or "anthropic/" in lowered:
|
||||
return True
|
||||
if any(slug in lowered for slug in _ALIBABA_EXPLICIT_CACHE_SLUGS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_cache_control(obj: Any) -> bool:
|
||||
"""Recursively detect any client-supplied ``cache_control`` marker."""
|
||||
if isinstance(obj, dict):
|
||||
if "cache_control" in obj:
|
||||
return True
|
||||
return any(_has_cache_control(v) for v in obj.values())
|
||||
if isinstance(obj, list):
|
||||
return any(_has_cache_control(v) for v in obj)
|
||||
return False
|
||||
|
||||
|
||||
def body_has_cache_control(data: dict) -> bool:
|
||||
"""True when the request already carries cache_control on messages/tools."""
|
||||
return _has_cache_control(data.get("messages")) or _has_cache_control(
|
||||
data.get("tools")
|
||||
)
|
||||
|
||||
|
||||
def _stamp_text_content(message: dict) -> bool:
|
||||
"""Add the ephemeral marker to a message's last text block.
|
||||
|
||||
A string content is promoted to the array form Anthropic requires for
|
||||
cache markers; an existing array gets the marker on its last text part.
|
||||
Returns True when a marker was placed.
|
||||
"""
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
if not content:
|
||||
return False
|
||||
message["content"] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
"cache_control": dict(EPHEMERAL_CACHE_CONTROL),
|
||||
}
|
||||
]
|
||||
return True
|
||||
if isinstance(content, list):
|
||||
for part in reversed(content):
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
part["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _stamp_system_prompt(messages: list) -> None:
|
||||
for message in messages:
|
||||
if isinstance(message, dict) and message.get("role") in (
|
||||
"system",
|
||||
"developer",
|
||||
):
|
||||
_stamp_text_content(message)
|
||||
return
|
||||
|
||||
|
||||
def _stamp_last_tool(tools: Any) -> None:
|
||||
if isinstance(tools, list) and tools and isinstance(tools[-1], dict):
|
||||
tools[-1]["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
|
||||
|
||||
|
||||
def _stamp_last_conversation_message(messages: list) -> None:
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get("role") in ("user", "assistant"):
|
||||
if _stamp_text_content(message):
|
||||
return
|
||||
|
||||
|
||||
def inject_anthropic_cache_breakpoints(data: dict) -> bool:
|
||||
"""Stamp ephemeral cache breakpoints onto an OpenAI-shaped chat body.
|
||||
|
||||
Mutates ``data`` in place (the established convention in
|
||||
``prepare_request_body``) and returns True when anything changed. No-ops
|
||||
when the body isn't chat-shaped or the client already set cache_control.
|
||||
"""
|
||||
messages = data.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return False
|
||||
if body_has_cache_control(data):
|
||||
return False
|
||||
|
||||
_stamp_system_prompt(messages)
|
||||
_stamp_last_tool(data.get("tools"))
|
||||
_stamp_last_conversation_message(messages)
|
||||
return True
|
||||
73
routstr/upstream/deepseek_v4_pricing_shim.py
Normal file
73
routstr/upstream/deepseek_v4_pricing_shim.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""TEMPORARY: local DeepSeek V4 pricing shim.
|
||||
|
||||
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
|
||||
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
|
||||
``cache_read_input_token_cost`` and cache reads fall back to the full input
|
||||
rate — a large overcharge on cache hits (DeepSeek V4 hits are ~0.008-0.02x
|
||||
input, i.e. cached tokens cost 50-120x less than regular input).
|
||||
|
||||
This module injects the missing entries into ``litellm.model_cost`` at startup
|
||||
so the existing backfill path resolves them. Rates mirror the canonical
|
||||
``deepseek`` provider entries now in litellm's ``model_prices`` map
|
||||
(``input_cost_per_token`` is the cache-*miss* rate;
|
||||
``cache_read_input_token_cost`` is the cache-*hit* rate), sourced from
|
||||
https://api-docs.deepseek.com/quick_start/pricing via
|
||||
https://github.com/BerriAI/litellm/pull/26380 (issue
|
||||
https://github.com/BerriAI/litellm/issues/30430).
|
||||
|
||||
=== REMOVAL (once litellm ships these models) ===
|
||||
Delete this file and the single ``register_deepseek_v4_pricing()`` call in
|
||||
``routstr/core/main.py``. Nothing else depends on it. Entries are only added
|
||||
when absent, so a stale shim is harmless after upstream lands — but remove it.
|
||||
"""
|
||||
|
||||
import litellm
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# USD per token. Mirrors the canonical ``deepseek`` provider entries in
|
||||
# litellm's model_prices map (source: DeepSeek API pricing docs). Keep these in
|
||||
# sync with ``litellm.model_cost["deepseek/deepseek-v4-*"]``.
|
||||
_DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = {
|
||||
"deepseek-v4-flash": {
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"cache_read_input_token_cost": 2.8e-09,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"input_cost_per_token_cache_hit": 2.8e-09,
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
"input_cost_per_token": 4.35e-07,
|
||||
"output_cost_per_token": 8.7e-07,
|
||||
"cache_read_input_token_cost": 3.625e-09,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"input_cost_per_token_cache_hit": 3.625e-09,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def register_deepseek_v4_pricing() -> None:
|
||||
"""Inject DeepSeek V4 pricing into ``litellm.model_cost`` if absent.
|
||||
|
||||
Idempotent and non-destructive: a key already present in the cost map
|
||||
(e.g. once litellm ships it) is left untouched. Registers both the bare
|
||||
(``deepseek-v4-flash``) and prefixed (``deepseek/deepseek-v4-flash``)
|
||||
spellings since ``backfill_cache_pricing`` tries both.
|
||||
"""
|
||||
added = []
|
||||
for bare, rates in _DEEPSEEK_V4_RATES.items():
|
||||
for key in (bare, f"deepseek/{bare}"):
|
||||
if key in litellm.model_cost:
|
||||
continue
|
||||
entry: dict[str, object] = dict(rates)
|
||||
entry["litellm_provider"] = "deepseek"
|
||||
entry["mode"] = "chat"
|
||||
litellm.model_cost[key] = entry
|
||||
added.append(key)
|
||||
if added:
|
||||
logger.info(
|
||||
"Registered temporary DeepSeek V4 pricing shim",
|
||||
extra={"models": added},
|
||||
)
|
||||
@@ -20,7 +20,7 @@ class FireworksUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "FireworksUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -51,7 +51,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
return self._client
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GeminiUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -5,6 +5,12 @@ from typing import TYPE_CHECKING
|
||||
import httpx
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .pricing_resolver import (
|
||||
FallbackPricingResolver,
|
||||
ResolvedPricing,
|
||||
_as_float,
|
||||
estimate_context_length,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
@@ -45,7 +51,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GenericUpstreamProvider":
|
||||
return cls(
|
||||
@@ -64,6 +70,40 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def _native_pricing(
|
||||
self, model_id: str, model_spec: dict
|
||||
) -> ResolvedPricing | None:
|
||||
"""Read pricing/metadata from Venice's bespoke ``model_spec`` schema.
|
||||
|
||||
Returns ``None`` when the upstream reported no *usable* native price —
|
||||
absent, non-numeric, negative, or both-zero — so the caller falls
|
||||
through to the shared resolution chain instead of fabricating a number
|
||||
or trusting a bogus one. This mirrors the money-safety guards the
|
||||
litellm and OpenRouter rungs already apply: a both-zero price would
|
||||
serve the model free, a negative one would credit the caller, and a
|
||||
non-numeric string would otherwise throw and drop the whole catalog.
|
||||
"""
|
||||
pricing_info = model_spec.get("pricing", {})
|
||||
input_usd = _as_float(pricing_info.get("input", {}).get("usd"))
|
||||
output_usd = _as_float(pricing_info.get("output", {}).get("usd"))
|
||||
if input_usd is None or output_usd is None:
|
||||
return None
|
||||
if input_usd < 0 or output_usd < 0 or (input_usd == 0 and output_usd == 0):
|
||||
return None
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
if capabilities.get("supportsVision", False):
|
||||
input_modalities.append("image")
|
||||
|
||||
return ResolvedPricing(
|
||||
prompt=input_usd / 1_000_000,
|
||||
completion=output_usd / 1_000_000,
|
||||
context_length=model_spec.get("availableContextTokens"),
|
||||
source="native",
|
||||
input_modalities=input_modalities,
|
||||
)
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from upstream API using /models endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
@@ -78,6 +118,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
resolver = FallbackPricingResolver()
|
||||
models_list = []
|
||||
for model_data in data.get("data", []):
|
||||
model_id = model_data.get("id", "")
|
||||
@@ -89,41 +130,44 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
owned_by = model_data.get("owned_by", "unknown")
|
||||
model_spec = model_data.get("model_spec", {})
|
||||
|
||||
context_length = 4096
|
||||
if model_spec.get("availableContextTokens"):
|
||||
context_length = model_spec["availableContextTokens"]
|
||||
elif any(
|
||||
pattern in model_id.lower() for pattern in ["32k", "32000"]
|
||||
):
|
||||
context_length = 32768
|
||||
elif any(
|
||||
pattern in model_id.lower() for pattern in ["16k", "16000"]
|
||||
):
|
||||
context_length = 16384
|
||||
elif any(pattern in model_id.lower() for pattern in ["8k", "8000"]):
|
||||
context_length = 8192
|
||||
elif "gpt-4" in model_id.lower():
|
||||
context_length = 8192
|
||||
elif "claude" in model_id.lower():
|
||||
context_length = 200000
|
||||
resolved = self._native_pricing(model_id, model_spec)
|
||||
if resolved is None:
|
||||
resolved = await resolver.resolve(model_id)
|
||||
|
||||
pricing_info = model_spec.get("pricing", {})
|
||||
input_pricing = pricing_info.get("input", {})
|
||||
output_pricing = pricing_info.get("output", {})
|
||||
if resolved is None:
|
||||
# Fail closed: never invent a price. Import the model
|
||||
# disabled with a warning so the operator can price it
|
||||
# (the admin UI surfaces disabled remote models).
|
||||
logger.warning(
|
||||
f"No pricing source resolved for '{model_id}' from "
|
||||
f"{self.upstream_name}; importing it disabled",
|
||||
extra={"model_id": model_id, "base_url": self.base_url},
|
||||
)
|
||||
resolved = ResolvedPricing(
|
||||
prompt=0.0,
|
||||
completion=0.0,
|
||||
context_length=None,
|
||||
source="unresolved",
|
||||
)
|
||||
enabled = False
|
||||
else:
|
||||
enabled = True
|
||||
|
||||
prompt_price = input_pricing.get("usd", 0.001) / 1000000
|
||||
completion_price = output_pricing.get("usd", 0.001) / 1000000
|
||||
# Prefer the source's own modality string (OpenRouter ships
|
||||
# one, e.g. "text+image->text"); otherwise derive it from the
|
||||
# captured input/output modalities in the same "in->out" shape
|
||||
# rather than flattening vision models to "text->text".
|
||||
modality = resolved.modality or (
|
||||
f"{'+'.join(resolved.input_modalities)}"
|
||||
f"->{'+'.join(resolved.output_modalities)}"
|
||||
)
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
output_modalities = ["text"]
|
||||
|
||||
if capabilities.get("supportsVision", False):
|
||||
input_modalities.append("image")
|
||||
|
||||
modality = "text"
|
||||
if capabilities.get("supportsVision", False):
|
||||
modality = "text->text"
|
||||
# A source can carry a price but no context (e.g. a litellm
|
||||
# entry missing max_input_tokens); fall back to an id-based
|
||||
# estimate so we never persist a zero-length window.
|
||||
context_length = resolved.context_length or estimate_context_length(
|
||||
model_id
|
||||
)
|
||||
|
||||
spec_name = model_spec.get("name", model_name)
|
||||
description = f"{spec_name}"
|
||||
@@ -139,30 +183,33 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
context_length=context_length,
|
||||
architecture=Architecture(
|
||||
modality=modality,
|
||||
input_modalities=input_modalities,
|
||||
output_modalities=output_modalities,
|
||||
tokenizer="unknown",
|
||||
instruct_type=None,
|
||||
input_modalities=resolved.input_modalities,
|
||||
output_modalities=resolved.output_modalities,
|
||||
tokenizer=resolved.tokenizer,
|
||||
instruct_type=resolved.instruct_type,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=prompt_price,
|
||||
completion=completion_price,
|
||||
prompt=resolved.prompt,
|
||||
completion=resolved.completion,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
max_prompt_cost=0.001,
|
||||
max_completion_cost=0.001,
|
||||
max_cost=0.001,
|
||||
input_cache_read=resolved.input_cache_read,
|
||||
input_cache_write=resolved.input_cache_write,
|
||||
),
|
||||
sats_pricing=None,
|
||||
per_request_limits=None,
|
||||
top_provider=TopProvider(
|
||||
context_length=context_length,
|
||||
max_completion_tokens=context_length // 2,
|
||||
is_moderated=False,
|
||||
max_completion_tokens=(
|
||||
resolved.max_completion_tokens
|
||||
if resolved.max_completion_tokens is not None
|
||||
else context_length // 2
|
||||
),
|
||||
is_moderated=bool(resolved.is_moderated),
|
||||
),
|
||||
enabled=True,
|
||||
enabled=enabled,
|
||||
upstream_provider_id=None,
|
||||
canonical_slug=None,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ class GroqUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
from ..core.provider_slugs import allocate_unique_provider_slug
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
@@ -215,9 +216,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
# Keep provider DB id on runtime instance so model mapping can
|
||||
# bind DB overrides to the correct upstream.
|
||||
setattr(provider, "db_id", provider_row.id)
|
||||
await provider.refresh_models_cache()
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
@@ -250,6 +248,7 @@ async def _seed_providers_from_settings(
|
||||
|
||||
providers_to_add: list[UpstreamProviderRow] = []
|
||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||
reserved_slugs: set[str] = set()
|
||||
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
@@ -279,8 +278,13 @@ async def _seed_providers_from_settings(
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
slug = await allocate_unique_provider_slug(
|
||||
session, provider_type, reserved_slugs
|
||||
)
|
||||
reserved_slugs.add(slug)
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
slug=slug,
|
||||
provider_type=provider_type,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
@@ -299,8 +303,13 @@ async def _seed_providers_from_settings(
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
slug = await allocate_unique_provider_slug(
|
||||
session, "ollama", reserved_slugs
|
||||
)
|
||||
reserved_slugs.add(slug)
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
slug=slug,
|
||||
provider_type="ollama",
|
||||
base_url=ollama_base_url,
|
||||
api_key=ollama_api_key,
|
||||
@@ -320,8 +329,13 @@ async def _seed_providers_from_settings(
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
slug = await allocate_unique_provider_slug(
|
||||
session, "azure", reserved_slugs
|
||||
)
|
||||
reserved_slugs.add(slug)
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
slug=slug,
|
||||
provider_type="azure",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
@@ -342,8 +356,13 @@ async def _seed_providers_from_settings(
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
slug = await allocate_unique_provider_slug(
|
||||
session, "custom", reserved_slugs
|
||||
)
|
||||
reserved_slugs.add(slug)
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
slug=slug,
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
@@ -356,7 +375,7 @@ async def _seed_providers_from_settings(
|
||||
session.add(provider)
|
||||
logger.info(
|
||||
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
||||
extra={"base_url": provider.base_url},
|
||||
extra={"base_url": provider.base_url, "slug": provider.slug},
|
||||
)
|
||||
|
||||
|
||||
@@ -391,9 +410,7 @@ def _instantiate_provider(
|
||||
return provider
|
||||
|
||||
if provider_row.provider_type == "custom":
|
||||
return BaseUpstreamProvider(
|
||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
return BaseUpstreamProvider.from_db_row(provider_row)
|
||||
|
||||
logger.error(
|
||||
f"Unknown provider type: {provider_row.provider_type}",
|
||||
|
||||
@@ -29,7 +29,9 @@ import litellm
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.exceptions import UpstreamError
|
||||
from ..core.redaction import redact_org_ids
|
||||
from ..payment.models import Model
|
||||
from .rate_limit import classify_rate_limit
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -505,23 +507,33 @@ async def dispatch_anthropic_messages(
|
||||
try:
|
||||
result = await litellm.anthropic.messages.acreate(**kwargs)
|
||||
except Exception as exc:
|
||||
exc_message = getattr(exc, "message", None) or str(exc) or repr(exc)
|
||||
raw_message = getattr(exc, "message", None) or str(exc) or repr(exc)
|
||||
# Redact provider account identifiers before the message reaches logs
|
||||
# or the surfaced error.
|
||||
exc_message = redact_org_ids(raw_message)
|
||||
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))
|
||||
response_text = redact_org_ids(
|
||||
getattr(exc_response, "text", str(exc_response))
|
||||
)
|
||||
except Exception:
|
||||
response_text = "<unreadable>"
|
||||
status_for_classify = exc_status if isinstance(exc_status, int) else 502
|
||||
rate_limit = classify_rate_limit(
|
||||
status_for_classify, exc_message, getattr(exc, "headers", None)
|
||||
)
|
||||
logger.error(
|
||||
"litellm dispatch failed",
|
||||
extra={
|
||||
"error": exc_message,
|
||||
"error_type": type(exc).__name__,
|
||||
"status_code": exc_status,
|
||||
"error_code": rate_limit.code if rate_limit else None,
|
||||
"llm_provider": getattr(exc, "llm_provider", None),
|
||||
"body": getattr(exc, "body", None),
|
||||
"body": redact_org_ids(str(getattr(exc, "body", "") or "")) or None,
|
||||
"response_text": response_text,
|
||||
"model": litellm_model,
|
||||
"api_base": base_url,
|
||||
@@ -529,7 +541,9 @@ async def dispatch_anthropic_messages(
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Upstream error via litellm: {exc_message}",
|
||||
status_code=exc_status if isinstance(exc_status, int) else 502,
|
||||
status_code=status_for_classify,
|
||||
code=rate_limit.code if rate_limit else None,
|
||||
details=rate_limit.as_details() if rate_limit else None,
|
||||
) from exc
|
||||
|
||||
if not client_stream and hasattr(result, "__aiter__"):
|
||||
|
||||
@@ -43,7 +43,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OllamaUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -20,7 +20,7 @@ class OpenAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OpenAIUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -18,6 +18,33 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
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.
|
||||
|
||||
@@ -30,7 +57,7 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OpenRouterUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -23,7 +23,7 @@ class PerplexityUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PerplexityUpstreamProvider":
|
||||
return cls(
|
||||
|
||||
@@ -46,7 +46,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PPQAIUpstreamProvider":
|
||||
return cls(
|
||||
@@ -229,17 +229,14 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||
extra={"error": error_message},
|
||||
)
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
|
||||
async with create_session() as session:
|
||||
statement = select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == self.base_url,
|
||||
UpstreamProviderRow.api_key == self.api_key,
|
||||
provider = (
|
||||
await session.get(UpstreamProviderRow, self.db_id)
|
||||
if self.db_id is not None
|
||||
else None
|
||||
)
|
||||
result = await session.exec(statement)
|
||||
provider = result.first()
|
||||
|
||||
if provider:
|
||||
provider.enabled = False
|
||||
|
||||
203
routstr/upstream/pricing_resolver.py
Normal file
203
routstr/upstream/pricing_resolver.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Shared price/metadata resolution chain for upstream model discovery.
|
||||
|
||||
Most OpenAI-compatible ``/models`` responses carry no pricing. Rather than let
|
||||
a provider fabricate one, this module resolves a model through decreasingly
|
||||
trustworthy sources — litellm's bundled cost map (curated list prices, mirrors
|
||||
provider docs), then the OpenRouter feed (resale prices, broader coverage) —
|
||||
and returns ``None`` when none of them know the model, so the caller can fail
|
||||
closed instead of inventing a number.
|
||||
|
||||
Provider-native pricing (a gateway's own ``/models`` schema, e.g. Venice's
|
||||
``model_spec``) is authoritative and handled by the provider before this chain
|
||||
is consulted; only the shared fallback lives here so a later refactor can hoist
|
||||
it into the base provider unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedPricing:
|
||||
"""Per-token pricing plus whatever metadata the answering source carried.
|
||||
|
||||
Prices are USD per token. ``source`` records provenance
|
||||
(``native``/``litellm``/``openrouter``/``unresolved``) so later work can
|
||||
surface where each price came from.
|
||||
"""
|
||||
|
||||
prompt: float
|
||||
completion: float
|
||||
context_length: int | None
|
||||
source: str
|
||||
modality: str | None = None
|
||||
max_completion_tokens: int | None = None
|
||||
input_cache_read: float = 0.0
|
||||
input_cache_write: float = 0.0
|
||||
input_modalities: list[str] = field(default_factory=lambda: ["text"])
|
||||
output_modalities: list[str] = field(default_factory=lambda: ["text"])
|
||||
tokenizer: str = "unknown"
|
||||
instruct_type: str | None = None
|
||||
is_moderated: bool | None = None
|
||||
|
||||
|
||||
def estimate_context_length(model_id: str) -> int:
|
||||
"""Best-effort context window from a model id when no source reports one.
|
||||
|
||||
The last rung of the fallback chain, reached only for a model whose price
|
||||
resolved but whose context did not (or that imported disabled). Context is
|
||||
not a billing input, so a rough id-based guess is acceptable here where a
|
||||
guessed *price* never would be.
|
||||
"""
|
||||
lowered = model_id.lower()
|
||||
if any(pattern in lowered for pattern in ["32k", "32000"]):
|
||||
return 32768
|
||||
if any(pattern in lowered for pattern in ["16k", "16000"]):
|
||||
return 16384
|
||||
if any(pattern in lowered for pattern in ["8k", "8000"]):
|
||||
return 8192
|
||||
if "gpt-4" in lowered:
|
||||
return 8192
|
||||
if "claude" in lowered:
|
||||
return 200000
|
||||
return 4096
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
"""OpenRouter reports prices as strings; coerce, ``None`` if unparseable."""
|
||||
try:
|
||||
return float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(value: object) -> int | None:
|
||||
"""Coerce an already-numeric token count to ``int``, else ``None``."""
|
||||
return int(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _from_litellm(model_id: str) -> ResolvedPricing | None:
|
||||
# Lazy import so the resolver stays import-light and shares the exact
|
||||
# lookup semantics used by cache-rate backfill.
|
||||
from ..payment.models import litellm_cost_entry
|
||||
|
||||
info = litellm_cost_entry(model_id)
|
||||
if info is None:
|
||||
return None
|
||||
|
||||
prompt = info.get("input_cost_per_token")
|
||||
completion = info.get("output_cost_per_token")
|
||||
if not isinstance(prompt, (int, float)) or not isinstance(completion, (int, float)):
|
||||
return None
|
||||
# A both-zero entry is litellm listing a model without a real price (free
|
||||
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
|
||||
# the model for free. Reject it (and any negative) so the caller falls
|
||||
# through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
|
||||
if prompt < 0 or completion < 0 or (prompt == 0 and completion == 0):
|
||||
return None
|
||||
|
||||
input_modalities = ["text"]
|
||||
if info.get("supports_vision"):
|
||||
input_modalities.append("image")
|
||||
|
||||
return ResolvedPricing(
|
||||
prompt=float(prompt),
|
||||
completion=float(completion),
|
||||
# max_input_tokens is the context window; max_tokens is litellm's
|
||||
# completion cap (it tracks max_output_tokens for ~94% of models), so
|
||||
# it is never a context source. A missing window falls to the id-based
|
||||
# estimate downstream rather than borrowing the output cap.
|
||||
context_length=_as_int(info.get("max_input_tokens")),
|
||||
source="litellm",
|
||||
max_completion_tokens=_as_int(info.get("max_output_tokens")),
|
||||
input_cache_read=float(info.get("cache_read_input_token_cost") or 0.0),
|
||||
input_cache_write=float(info.get("cache_creation_input_token_cost") or 0.0),
|
||||
input_modalities=input_modalities,
|
||||
)
|
||||
|
||||
|
||||
def _match_openrouter(model_id: str, feed: list[dict]) -> dict | None:
|
||||
"""Find ``model_id`` in the OpenRouter feed, exact id before bare tail.
|
||||
|
||||
Bare-tail matching (``deepseek-chat`` ↔ ``deepseek/deepseek-chat``) is a
|
||||
looser, lower-trust match — OpenRouter fans a model out across resellers —
|
||||
so an exact id match always wins first. When several entries share the bare
|
||||
tail, the one with the highest *combined* (prompt + completion) per-token
|
||||
cost wins: the choice must be deterministic (not feed-order-dependent) and
|
||||
money-safe whichever way traffic leans, since undercharging is the hazard.
|
||||
Ranking on prompt alone could pick an entry that is cheap on input but dear
|
||||
on output. The live feed has no such collisions today; this only governs
|
||||
the latent case.
|
||||
"""
|
||||
bare = model_id.split("/", 1)[-1]
|
||||
exact = next((m for m in feed if m.get("id") == model_id), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
matches = [m for m in feed if m.get("id", "").split("/", 1)[-1] == bare]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
def _combined_cost(m: dict) -> float:
|
||||
pricing = m.get("pricing", {})
|
||||
return (_as_float(pricing.get("prompt")) or 0.0) + (
|
||||
_as_float(pricing.get("completion")) or 0.0
|
||||
)
|
||||
|
||||
return max(matches, key=_combined_cost)
|
||||
|
||||
|
||||
def _from_openrouter(model_id: str, feed: list[dict]) -> ResolvedPricing | None:
|
||||
entry = _match_openrouter(model_id, feed)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
pricing = entry.get("pricing", {})
|
||||
prompt = _as_float(pricing.get("prompt"))
|
||||
completion = _as_float(pricing.get("completion"))
|
||||
if prompt is None or completion is None:
|
||||
return None
|
||||
|
||||
architecture = entry.get("architecture", {})
|
||||
top_provider = entry.get("top_provider", {})
|
||||
|
||||
return ResolvedPricing(
|
||||
prompt=prompt,
|
||||
completion=completion,
|
||||
context_length=_as_int(entry.get("context_length")),
|
||||
source="openrouter",
|
||||
modality=architecture.get("modality"),
|
||||
max_completion_tokens=_as_int(top_provider.get("max_completion_tokens")),
|
||||
input_cache_read=_as_float(pricing.get("input_cache_read")) or 0.0,
|
||||
input_cache_write=_as_float(pricing.get("input_cache_write")) or 0.0,
|
||||
input_modalities=architecture.get("input_modalities") or ["text"],
|
||||
output_modalities=architecture.get("output_modalities") or ["text"],
|
||||
tokenizer=architecture.get("tokenizer") or "unknown",
|
||||
instruct_type=architecture.get("instruct_type"),
|
||||
is_moderated=top_provider.get("is_moderated"),
|
||||
)
|
||||
|
||||
|
||||
class FallbackPricingResolver:
|
||||
"""Resolves models via litellm → OpenRouter for one discovery pass.
|
||||
|
||||
The OpenRouter catalog is fetched at most once and only when a model
|
||||
actually misses litellm, so a provider full of litellm-known models never
|
||||
touches the network. Instantiate one per ``fetch_models`` call.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._openrouter_feed: list[dict] | None = None
|
||||
|
||||
async def resolve(self, model_id: str) -> ResolvedPricing | None:
|
||||
"""Resolve ``model_id``; ``None`` if no source knows it."""
|
||||
resolved = _from_litellm(model_id)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
|
||||
if self._openrouter_feed is None:
|
||||
# Lazy import so tests can patch the feed at its source.
|
||||
from ..payment.models import async_fetch_openrouter_models
|
||||
|
||||
self._openrouter_feed = await async_fetch_openrouter_models()
|
||||
return _from_openrouter(model_id, self._openrouter_feed)
|
||||
136
routstr/upstream/rate_limit.py
Normal file
136
routstr/upstream/rate_limit.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Detection and parsing of upstream provider rate-limit errors.
|
||||
|
||||
Upstream OpenAI-compatible providers signal rate limits via HTTP 429 and/or a
|
||||
human-readable message such as::
|
||||
|
||||
Rate limit reached for gpt-5.5-2026-04-23 (for limit gpt-5.5) in organization
|
||||
org-XXXX on tokens per min (TPM): Limit 180000000, Used 180000000,
|
||||
Requested 8929. Please try again in 2ms.
|
||||
|
||||
This module classifies those failures into a stable :data:`UPSTREAM_RATE_LIMIT`
|
||||
code and extracts useful debugging fields. All retained text is redacted of
|
||||
organization IDs first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from ..core.redaction import redact_org_ids
|
||||
|
||||
# Stable error code callers can switch on to distinguish upstream rate limits
|
||||
# from generic request failures. The literal value matches the identifier named
|
||||
# in issue #555 ("UPSTREAM_RATE_LIMIT") so the public API contract is exact.
|
||||
UPSTREAM_RATE_LIMIT = "UPSTREAM_RATE_LIMIT"
|
||||
|
||||
# Message fragments that indicate a rate-limit even when the status code is not
|
||||
# 429 (some providers wrap it in a 400/500 envelope).
|
||||
_RATE_LIMIT_MARKERS = (
|
||||
"rate limit reached",
|
||||
"rate_limit_exceeded",
|
||||
"rate limit exceeded",
|
||||
"too many requests",
|
||||
)
|
||||
|
||||
_MODEL_RE = re.compile(r"Rate limit reached for ([^\s(]+)", re.IGNORECASE)
|
||||
_LIMIT_NAME_RE = re.compile(r"\(for limit ([^)]+)\)", re.IGNORECASE)
|
||||
_METRIC_RE = re.compile(r"on ([a-z ]+\((?:TPM|RPM|TPD|RPD|IPM)\))", re.IGNORECASE)
|
||||
_LIMIT_RE = re.compile(r"Limit (\d+)", re.IGNORECASE)
|
||||
_USED_RE = re.compile(r"Used (\d+)", re.IGNORECASE)
|
||||
_REQUESTED_RE = re.compile(r"Requested (\d+)", re.IGNORECASE)
|
||||
_RETRY_RE = re.compile(r"try again in ([\d.]+)\s*(ms|s)", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitInfo:
|
||||
"""Structured, redaction-safe view of an upstream rate-limit error."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
model: str | None = None
|
||||
limit_name: str | None = None
|
||||
metric: str | None = None
|
||||
limit: int | None = None
|
||||
used: int | None = None
|
||||
requested: int | None = None
|
||||
retry_after_seconds: float | None = None
|
||||
|
||||
def as_details(self) -> dict[str, object]:
|
||||
"""Return a JSON-serialisable dict for embedding in an error envelope."""
|
||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||
|
||||
|
||||
def _looks_like_rate_limit(status_code: int, message: str) -> bool:
|
||||
if status_code == 429:
|
||||
return True
|
||||
lowered = message.lower()
|
||||
return any(marker in lowered for marker in _RATE_LIMIT_MARKERS)
|
||||
|
||||
|
||||
def _parse_retry_after_header(headers: dict[str, str] | None) -> float | None:
|
||||
"""Parse a ``Retry-After`` header (delta-seconds form) into seconds."""
|
||||
if not headers:
|
||||
return None
|
||||
raw = headers.get("retry-after") or headers.get("Retry-After")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _int_or_none(match: re.Match[str] | None) -> int | None:
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return int(match.group(1))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def classify_rate_limit(
|
||||
status_code: int,
|
||||
message: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> RateLimitInfo | None:
|
||||
"""Classify an upstream error as a rate-limit and extract its fields.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code from the upstream response.
|
||||
message: Upstream error message (may contain sensitive identifiers).
|
||||
headers: Optional upstream response headers, used for ``Retry-After``.
|
||||
|
||||
Returns:
|
||||
A :class:`RateLimitInfo` when the error is a rate-limit, else ``None``.
|
||||
"""
|
||||
message = message or ""
|
||||
if not _looks_like_rate_limit(status_code, message):
|
||||
return None
|
||||
|
||||
redacted = redact_org_ids(message)
|
||||
|
||||
model_match = _MODEL_RE.search(redacted)
|
||||
metric_match = _METRIC_RE.search(redacted)
|
||||
|
||||
retry_after = _parse_retry_after_header(headers)
|
||||
if retry_after is None:
|
||||
retry_match = _RETRY_RE.search(redacted)
|
||||
if retry_match is not None:
|
||||
value = float(retry_match.group(1))
|
||||
retry_after = value / 1000.0 if retry_match.group(2).lower() == "ms" else value
|
||||
|
||||
limit_name_match = _LIMIT_NAME_RE.search(redacted)
|
||||
|
||||
return RateLimitInfo(
|
||||
code=UPSTREAM_RATE_LIMIT,
|
||||
message=redacted,
|
||||
model=model_match.group(1) if model_match else None,
|
||||
limit_name=limit_name_match.group(1).strip() if limit_name_match else None,
|
||||
metric=metric_match.group(1).strip() if metric_match else None,
|
||||
limit=_int_or_none(_LIMIT_RE.search(redacted)),
|
||||
used=_int_or_none(_USED_RE.search(redacted)),
|
||||
requested=_int_or_none(_REQUESTED_RE.search(redacted)),
|
||||
retry_after_seconds=retry_after,
|
||||
)
|
||||
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
|
||||
@@ -55,7 +55,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
||||
return path.lstrip("/")
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "RoutstrUpstreamProvider":
|
||||
import json
|
||||
|
||||
@@ -21,7 +21,7 @@ class XAIUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
||||
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import typing
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
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
|
||||
@@ -30,11 +33,172 @@ _CashuMintInfo.model_rebuild(force=True)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MintConnectionError(Exception):
|
||||
"""The mint could not be reached (network transport failure).
|
||||
|
||||
Maps to a 503, not a 4xx: the token is fine, the mint is just unavailable.
|
||||
"""
|
||||
|
||||
|
||||
class TokenConsumedError(Exception):
|
||||
"""A failure that happened AFTER the token's proofs were spent (melt
|
||||
succeeded, or redemption already returned) — e.g. minting on the primary
|
||||
mint or the DB credit then failed.
|
||||
|
||||
Non-retryable: the same token will not work again. Seals the cause chain so
|
||||
a transport error underneath is never re-surfaced as a retryable
|
||||
mint_unreachable.
|
||||
"""
|
||||
|
||||
|
||||
# httpx base classes cover their subclasses. HTTPStatusError is excluded on
|
||||
# purpose — that means the mint answered, just with an error status.
|
||||
_TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = (
|
||||
httpx.NetworkError,
|
||||
httpx.TimeoutException,
|
||||
ConnectionError, # refused/reset/aborted
|
||||
socket.gaierror, # DNS failure
|
||||
asyncio.TimeoutError,
|
||||
)
|
||||
|
||||
|
||||
def is_mint_connection_error(error: BaseException) -> bool:
|
||||
"""True if ``error`` (or anything in its cause/context chain) is a mint
|
||||
transport failure. Walks the chain because some sites re-raise transport
|
||||
errors wrapped in ValueError/MintConnectionError; matches on TYPE, not text.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = error
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
if isinstance(current, TokenConsumedError):
|
||||
# Sealed: the token was already spent, so whatever transport error
|
||||
# sits underneath must not make this look retryable.
|
||||
return False
|
||||
if isinstance(current, MintConnectionError):
|
||||
return True
|
||||
if isinstance(current, _TRANSPORT_EXC_TYPES):
|
||||
return True
|
||||
current = current.__cause__ or current.__context__
|
||||
return False
|
||||
|
||||
|
||||
# Redemption ``code`` values whose token is spent/consumed/unusable — the
|
||||
# X-Cashu path must NOT echo the original token for these (echoing invites a
|
||||
# retry with a token that can never succeed again).
|
||||
SPENT_TOKEN_CODES: frozenset[str] = frozenset(
|
||||
{
|
||||
"cashu_token_already_spent",
|
||||
"cashu_token_consumed",
|
||||
"cashu_token_zero_value",
|
||||
"internal_error",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def classify_redemption_error(
|
||||
error: Exception,
|
||||
) -> tuple[str, int, str, str] | None:
|
||||
"""Map a token-redemption failure to ``(type, status, message, code)``.
|
||||
|
||||
Single source of truth for every endpoint that redeems a token (bearer,
|
||||
X-Cashu, top-up) so the same failure yields the same taxonomy everywhere.
|
||||
``type`` and ``code`` are stable client contract; ``message`` is sanitized
|
||||
(raw error text stays in logs). Returns None for an unclassified internal
|
||||
fault — the caller emits a generic 500.
|
||||
"""
|
||||
if isinstance(error, TokenConsumedError):
|
||||
return (
|
||||
"token_consumed",
|
||||
500,
|
||||
"Token was redeemed but could not be credited; do not retry",
|
||||
"cashu_token_consumed",
|
||||
)
|
||||
if is_mint_connection_error(error):
|
||||
return (
|
||||
"mint_unreachable",
|
||||
503,
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
)
|
||||
lowered = str(error).lower()
|
||||
if "already spent" in lowered:
|
||||
return (
|
||||
"token_already_spent",
|
||||
400,
|
||||
"Cashu token already spent",
|
||||
"cashu_token_already_spent",
|
||||
)
|
||||
if (
|
||||
"insufficient" in lowered
|
||||
or "melt fee" in lowered
|
||||
or "exceed token amount" in lowered
|
||||
or "estimate fees" in lowered
|
||||
):
|
||||
return (
|
||||
"mint_error",
|
||||
422,
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
)
|
||||
if "failed to melt" in lowered:
|
||||
return (
|
||||
"mint_error",
|
||||
422,
|
||||
"Failed to swap token from foreign mint",
|
||||
"cashu_foreign_mint_swap_failed",
|
||||
)
|
||||
if ("invalid" in lowered or "decode" in lowered) and "token" in lowered:
|
||||
# Anchored to "token" so internal faults whose text merely contains
|
||||
# "invalid"/"decode" fall through to the 500 branch, not a token error.
|
||||
return (
|
||||
"invalid_token",
|
||||
400,
|
||||
"Invalid Cashu token",
|
||||
"invalid_cashu_token",
|
||||
)
|
||||
if "must be positive" in lowered or "yielded no value" in lowered:
|
||||
# Redeemed to <= 0 (empty/dust token, or value fully consumed by fees).
|
||||
# Consumed, so non-retryable, but its own code — not the generic bucket.
|
||||
return (
|
||||
"cashu_error",
|
||||
400,
|
||||
"Failed to redeem Cashu token: token yielded no value",
|
||||
"cashu_token_zero_value",
|
||||
)
|
||||
if isinstance(error, ValueError):
|
||||
return (
|
||||
"cashu_error",
|
||||
400,
|
||||
"Failed to redeem Cashu token",
|
||||
"cashu_token_redemption_failed",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def get_balance(unit: str) -> int:
|
||||
wallet = await get_wallet(settings.primary_mint, unit)
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
async def _redeem_same_mint(
|
||||
wallet: Wallet, token_obj: Token
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
"""Redeem proofs at their own issuing mint (no cross-mint swap).
|
||||
|
||||
split() re-mints the incoming proofs into fresh ones we own so the sender
|
||||
can't double-spend them. With include_fees=True the mint deducts its NUT-02
|
||||
per-proof input fee, so we end up holding only `amount - input_fees`. Credit
|
||||
that, not the face value, or routstr over-credits the user and its wallet
|
||||
drifts insolvent.
|
||||
"""
|
||||
await wallet.load_mint(keyset_id=token_obj.keysets[0])
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
input_fees = wallet.get_fees_for_proofs(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
return int(token_obj.amount) - input_fees, token_obj.unit, token_obj.mint
|
||||
|
||||
|
||||
async def recieve_token(
|
||||
token: str,
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
@@ -48,27 +212,61 @@ 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)
|
||||
|
||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||
return await _redeem_same_mint(wallet, token_obj)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -77,16 +275,83 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
|
||||
return token
|
||||
|
||||
|
||||
# A foreign mint's fee_reserve is a non-binding estimate (NUT-05): the mint may
|
||||
# demand more when re-quoting or at melt execution. Instead of padding the
|
||||
# estimate with a safety buffer (which strands the margin at the foreign mint
|
||||
# on every swap), the swap retries with the amount recomputed from the fees the
|
||||
# mint actually demands, up to this many attempts.
|
||||
_MAX_SWAP_ATTEMPTS = 3
|
||||
|
||||
_MINT_ERROR_CODE_RE = re.compile(r"\(Code: (\d+)\)")
|
||||
_MELT_SHORTFALL_RE = re.compile(r"Provided: (\d+), needed: (\d+)")
|
||||
|
||||
# Insufficient-melt-inputs failures differ across mint implementations. 11005 is
|
||||
# the registered "Transaction is not balanced" code (cdk), specific enough to
|
||||
# trust on the code alone. 11000 is nutshell's generic, unregistered
|
||||
# TransactionError covering many unrelated failures, so it only counts as a fee
|
||||
# shortfall alongside the "not enough inputs" detail text. With no code suffix at
|
||||
# all, that same text is the only signal.
|
||||
|
||||
|
||||
def _net_minted_amount(amount_msat: int, token_unit: str, fees: int) -> int:
|
||||
"""
|
||||
Convert the token value minus fees (given in the token unit) into an
|
||||
amount in the primary mint's unit.
|
||||
"""
|
||||
fee_msat = fees * 1000 if token_unit == "sat" else fees
|
||||
remaining_msat = amount_msat - fee_msat
|
||||
if settings.primary_mint_unit == "sat":
|
||||
return int(remaining_msat // 1000)
|
||||
return int(remaining_msat)
|
||||
|
||||
|
||||
def _melt_insufficient_shortfall(error: Exception) -> int | None:
|
||||
"""
|
||||
Classify a melt failure: return the observed shortfall (in the token unit)
|
||||
when the mint rejected the inputs as insufficient, or None when the failure
|
||||
is unrelated to fees and must not be retried (e.g. a Lightning payment
|
||||
failure, where a smaller invoice would not help).
|
||||
|
||||
Cashu errors carry no structured amounts (NUT-00 defines only detail/code,
|
||||
flattened to "Mint Error: <detail> (Code: <code>)" by cashu-py), so the
|
||||
classification uses the code and the shortfall must be inferred: the
|
||||
"Provided: X, needed: Y" amounts are nutshell-specific free text and only
|
||||
refine the shortfall when present; otherwise shrink one unit at a time.
|
||||
"""
|
||||
message = str(error)
|
||||
code_match = _MINT_ERROR_CODE_RE.search(message)
|
||||
code = code_match.group(1) if code_match is not None else None
|
||||
has_shortfall_text = "not enough inputs" in message.lower()
|
||||
|
||||
match code:
|
||||
case "11005": # registered TransactionUnbalanced: trust the code
|
||||
pass
|
||||
case "11000" if has_shortfall_text: # generic nutshell error: needs the text
|
||||
pass
|
||||
case None if has_shortfall_text: # no code suffix: text is the only signal
|
||||
pass
|
||||
case _: # other codes, a bare 11000, or no signal: must not retry
|
||||
return None
|
||||
|
||||
amounts = _MELT_SHORTFALL_RE.search(message)
|
||||
if amounts is not None:
|
||||
provided, needed = int(amounts.group(1)), int(amounts.group(2))
|
||||
if needed > provided:
|
||||
return needed - provided
|
||||
return 1
|
||||
|
||||
|
||||
async def _calculate_swap_amount(
|
||||
amount_msat: int,
|
||||
token_unit: str,
|
||||
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
|
||||
@@ -113,26 +378,20 @@ async def _calculate_swap_amount(
|
||||
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
|
||||
|
||||
fee_reserve = dummy_melt_quote.fee_reserve
|
||||
if token_unit == "sat":
|
||||
fee_msat = fee_reserve * 1000
|
||||
else:
|
||||
fee_msat = fee_reserve
|
||||
|
||||
amount_msat_after_fee = amount_msat - fee_msat
|
||||
|
||||
if settings.primary_mint_unit == "sat":
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
else:
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
input_fees = token_wallet.get_fees_for_proofs(proofs)
|
||||
total_fees = fee_reserve + input_fees
|
||||
minted_amount = _net_minted_amount(amount_msat, token_unit, total_fees)
|
||||
|
||||
if minted_amount <= 0:
|
||||
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
|
||||
raise ValueError(f"Fees ({total_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,
|
||||
"estimated_fee": total_fees,
|
||||
"estimated_fee_unit": token_unit,
|
||||
"input_fees": input_fees,
|
||||
"minted_amount": minted_amount,
|
||||
"minted_unit": settings.primary_mint_unit,
|
||||
},
|
||||
@@ -144,6 +403,8 @@ async def _calculate_swap_amount(
|
||||
"swap_to_primary_mint: fee estimation failed",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
if is_mint_connection_error(e):
|
||||
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||
raise ValueError(f"Failed to estimate fees: {e}") from e
|
||||
|
||||
|
||||
@@ -171,10 +432,9 @@ async def swap_to_primary_mint(
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
# If the token is already from the primary mint, we don't need to swap
|
||||
# and we definitely don't want to calculate or pay fees.
|
||||
# If the token is already from the primary mint, we don't need a cross-mint
|
||||
# swap — redeem it same-mint. There's no melt/Lightning fee, but the mint's
|
||||
# NUT-02 input fee still applies; _redeem_same_mint accounts for it.
|
||||
if token_obj.mint == settings.primary_mint:
|
||||
logger.info(
|
||||
"swap_to_primary_mint: token already on primary mint, skipping swap",
|
||||
@@ -184,8 +444,9 @@ async def swap_to_primary_mint(
|
||||
"unit": token_obj.unit,
|
||||
},
|
||||
)
|
||||
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
return token_amount, token_obj.unit, token_obj.mint
|
||||
return await _redeem_same_mint(token_wallet, token_obj)
|
||||
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
minted_amount = await _calculate_swap_amount(
|
||||
amount_msat,
|
||||
@@ -193,85 +454,195 @@ 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)
|
||||
logger.info(
|
||||
"swap_to_primary_mint: mint quote received",
|
||||
extra={"mint_quote_id": mint_quote.quote},
|
||||
)
|
||||
# The estimate above is non-binding: the mint may demand a higher fee on the
|
||||
# real quote or reject the melt outright. Retry the quote/melt cycle with the
|
||||
# amount recomputed from the fees the mint actually demands.
|
||||
observed_extra_fee = 0
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
logger.info(
|
||||
"swap_to_primary_mint: mint quote received",
|
||||
extra={"mint_quote_id": mint_quote.quote, "attempt": attempt},
|
||||
)
|
||||
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
total_needed = melt_quote.amount + melt_quote.fee_reserve
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt quote received",
|
||||
extra={
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"total_needed": total_needed,
|
||||
"token_amount": token_amount,
|
||||
},
|
||||
)
|
||||
|
||||
if total_needed > token_amount:
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: insufficient token amount for melt fees",
|
||||
melt_quote = await token_wallet.melt_quote(mint_quote.request)
|
||||
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={
|
||||
"token_amount": token_amount,
|
||||
"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,
|
||||
"shortfall": total_needed - token_amount,
|
||||
"token_amount": token_amount,
|
||||
"attempt": attempt,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
|
||||
f"melt fees. Needed: {total_needed} {token_obj.unit} "
|
||||
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
|
||||
)
|
||||
|
||||
try:
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"foreign_mint": token_obj.mint,
|
||||
"token_amount": token_amount,
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"total_needed": total_needed,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Failed to melt token from foreign mint {token_obj.mint}: {e}"
|
||||
) from e
|
||||
if total_needed > token_amount:
|
||||
recomputed = _net_minted_amount(
|
||||
amount_msat,
|
||||
token_obj.unit,
|
||||
melt_quote.fee_reserve + input_fees + observed_extra_fee,
|
||||
)
|
||||
if attempt >= _MAX_SWAP_ATTEMPTS or recomputed <= 0:
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: insufficient token amount for melt fees",
|
||||
extra={
|
||||
"token_amount": token_amount,
|
||||
"melt_amount": melt_quote.amount,
|
||||
"melt_fee_reserve": melt_quote.fee_reserve,
|
||||
"input_fees": input_fees,
|
||||
"total_needed": total_needed,
|
||||
"shortfall": total_needed - token_amount,
|
||||
"attempts": attempt,
|
||||
},
|
||||
)
|
||||
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} + input_fees: {input_fees})"
|
||||
)
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: melt quote exceeds token amount, retrying",
|
||||
extra={
|
||||
"total_needed": total_needed,
|
||||
"token_amount": token_amount,
|
||||
"retry_minted_amount": recomputed,
|
||||
"attempt": attempt,
|
||||
},
|
||||
)
|
||||
minted_amount = recomputed
|
||||
continue
|
||||
|
||||
try:
|
||||
_ = await token_wallet.melt(
|
||||
proofs=token_obj.proofs,
|
||||
invoice=mint_quote.request,
|
||||
fee_reserve_sat=melt_quote.fee_reserve,
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
except Exception as e:
|
||||
# A down mint won't fix itself by retrying with a smaller amount.
|
||||
if is_mint_connection_error(e):
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed — mint unreachable",
|
||||
extra={"error": str(e), "foreign_mint": token_obj.mint},
|
||||
)
|
||||
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||
shortfall = _melt_insufficient_shortfall(e)
|
||||
recomputed = 0
|
||||
if shortfall is not None:
|
||||
observed_extra_fee += shortfall
|
||||
recomputed = _net_minted_amount(
|
||||
amount_msat,
|
||||
token_obj.unit,
|
||||
melt_quote.fee_reserve + input_fees + observed_extra_fee,
|
||||
)
|
||||
if shortfall is None or attempt >= _MAX_SWAP_ATTEMPTS or recomputed <= 0:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"foreign_mint": token_obj.mint,
|
||||
"token_amount": token_amount,
|
||||
"melt_quote_id": melt_quote.quote,
|
||||
"total_needed": total_needed,
|
||||
"attempts": attempt,
|
||||
},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Failed to melt token from foreign mint {token_obj.mint}: {e}"
|
||||
) from e
|
||||
logger.warning(
|
||||
"swap_to_primary_mint: mint demanded more than quoted at melt, retrying",
|
||||
extra={
|
||||
"shortfall": shortfall,
|
||||
"retry_minted_amount": recomputed,
|
||||
"attempt": attempt,
|
||||
},
|
||||
)
|
||||
minted_amount = recomputed
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt succeeded, minting on primary",
|
||||
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 TokenConsumedError(
|
||||
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 TokenConsumedError:
|
||||
raise
|
||||
except Exception as recovery_err:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: recovery failed",
|
||||
extra={"error": str(recovery_err)},
|
||||
)
|
||||
raise TokenConsumedError(
|
||||
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,
|
||||
},
|
||||
)
|
||||
# Foreign proofs already melted (spent) — non-retryable.
|
||||
raise TokenConsumedError(
|
||||
"Mint on primary failed after successful melt"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: completed successfully",
|
||||
@@ -310,20 +681,52 @@ 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},
|
||||
)
|
||||
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
# The token is already redeemed (spent) here, so any crediting failure
|
||||
# is post-redemption and non-retryable — surface it as TokenConsumedError
|
||||
# (a key that vanished mid-flight, or an unexpected DB fault), never a
|
||||
# retryable/token-error taxonomy.
|
||||
try:
|
||||
# Atomic UPDATE to prevent race conditions during concurrent topups.
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
# If pruning removed this key after redemption, do not commit a no-op
|
||||
# balance update and pretend the top-up succeeded.
|
||||
if (getattr(result, "rowcount", 0) or 0) == 0:
|
||||
raise TokenConsumedError(
|
||||
"Token redeemed but the API key disappeared before the "
|
||||
"credit could be recorded"
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
except TokenConsumedError:
|
||||
raise
|
||||
except Exception as db_error:
|
||||
raise TokenConsumedError(
|
||||
"Token redeemed but crediting the balance failed"
|
||||
) from db_error
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Balance updated successfully",
|
||||
@@ -445,7 +848,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,11 +863,19 @@ async def fetch_all_balances(
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Build the set of mints to inspect. Received tokens are stored against
|
||||
# ``primary_mint`` (which defaults to a real mint even when ``cashu_mints``
|
||||
# is empty), so include it as a fallback — otherwise a node that accepts
|
||||
# payments would still report empty balances when ``cashu_mints`` is unset.
|
||||
mint_urls: list[str] = list(settings.cashu_mints)
|
||||
if settings.primary_mint and settings.primary_mint not in mint_urls:
|
||||
mint_urls.append(settings.primary_mint)
|
||||
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
fetch_balance(session, mint_url, unit)
|
||||
for mint_url in settings.cashu_mints
|
||||
for mint_url in mint_urls
|
||||
for unit in units
|
||||
]
|
||||
|
||||
@@ -493,7 +904,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
229
tests/integration/test_admin_model_cache_pricing.py
Normal file
229
tests/integration/test_admin_model_cache_pricing.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import admin_sessions
|
||||
from routstr.core.db import ModelRow, UpstreamProviderRow
|
||||
from routstr.payment.cost_calculation import CostData, calculate_cost
|
||||
from routstr.proxy import get_model_instance, reinitialize_upstreams
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
token = "test-admin-cache-pricing-token"
|
||||
admin_sessions[token] = int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _model_payload(
|
||||
provider_id: int,
|
||||
*,
|
||||
cache_read: float,
|
||||
cache_write: float,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": "custom-cache-model",
|
||||
"name": "Custom Cache Model",
|
||||
"description": "custom model with explicit cache pricing",
|
||||
"created": 0,
|
||||
"context_length": 128000,
|
||||
"architecture": {
|
||||
"modality": "text",
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "unknown",
|
||||
"instruct_type": None,
|
||||
},
|
||||
"pricing": {
|
||||
"prompt": 1.4e-7,
|
||||
"completion": 2.8e-7,
|
||||
"input_cache_read": cache_read,
|
||||
"input_cache_write": cache_write,
|
||||
"request": 0.0,
|
||||
"image": 0.0,
|
||||
"web_search": 0.0,
|
||||
"internal_reasoning": 0.0,
|
||||
},
|
||||
"per_request_limits": None,
|
||||
"top_provider": None,
|
||||
"upstream_provider_id": provider_id,
|
||||
"canonical_slug": None,
|
||||
"alias_ids": [],
|
||||
"enabled": True,
|
||||
"forwarded_model_id": "custom-cache-model",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_provider_model_api_persists_cache_pricing_on_create_and_update(
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="generic",
|
||||
base_url="https://custom-upstream.example/v1",
|
||||
api_key="test-key",
|
||||
provider_fee=1.0,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
assert provider.id is not None
|
||||
await reinitialize_upstreams()
|
||||
|
||||
headers = _admin_headers()
|
||||
create_payload = _model_payload(
|
||||
provider.id,
|
||||
cache_read=2.8e-9,
|
||||
cache_write=3.5e-9,
|
||||
)
|
||||
|
||||
with patch("routstr.payment.models.sats_usd_price", return_value=1e-6):
|
||||
create_response = await integration_client.post(
|
||||
f"/admin/api/upstream-providers/{provider.id}/models",
|
||||
headers=headers,
|
||||
json=create_payload,
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
create_body = create_response.json()
|
||||
assert create_body["pricing"]["input_cache_read"] == pytest.approx(2.8e-9)
|
||||
assert create_body["pricing"]["input_cache_write"] == pytest.approx(3.5e-9)
|
||||
|
||||
row = await integration_session.get(ModelRow, ("custom-cache-model", provider.id))
|
||||
assert row is not None
|
||||
stored_pricing = json.loads(row.pricing)
|
||||
assert stored_pricing["input_cache_read"] == pytest.approx(2.8e-9)
|
||||
assert stored_pricing["input_cache_write"] == pytest.approx(3.5e-9)
|
||||
|
||||
update_payload = _model_payload(
|
||||
provider.id,
|
||||
cache_read=1.25e-9,
|
||||
cache_write=4.5e-9,
|
||||
)
|
||||
|
||||
with patch("routstr.payment.models.sats_usd_price", return_value=1e-6):
|
||||
update_response = await integration_client.post(
|
||||
f"/admin/api/upstream-providers/{provider.id}/models",
|
||||
headers=headers,
|
||||
json=update_payload,
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
update_body = update_response.json()
|
||||
assert update_body["pricing"]["input_cache_read"] == pytest.approx(1.25e-9)
|
||||
assert update_body["pricing"]["input_cache_write"] == pytest.approx(4.5e-9)
|
||||
|
||||
await integration_session.refresh(row)
|
||||
updated_pricing = json.loads(row.pricing)
|
||||
assert updated_pricing["input_cache_read"] == pytest.approx(1.25e-9)
|
||||
assert updated_pricing["input_cache_write"] == pytest.approx(4.5e-9)
|
||||
|
||||
model = get_model_instance("custom-cache-model")
|
||||
assert model is not None
|
||||
assert model.sats_pricing is not None
|
||||
assert model.sats_pricing.input_cache_read == pytest.approx(0.00125)
|
||||
assert model.sats_pricing.input_cache_write == pytest.approx(0.0045)
|
||||
|
||||
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=1e-6):
|
||||
cost = await calculate_cost(
|
||||
{
|
||||
"model": "custom-cache-model",
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 100,
|
||||
"prompt_tokens_details": {"cached_tokens": 800},
|
||||
},
|
||||
},
|
||||
max_cost=1_000_000,
|
||||
)
|
||||
|
||||
assert isinstance(cost, CostData)
|
||||
assert cost.input_tokens == 200
|
||||
assert cost.cache_read_input_tokens == 800
|
||||
assert cost.cache_read_msats == 1000
|
||||
assert cost.output_msats == 28000
|
||||
assert cost.input_msats == 29000
|
||||
assert cost.total_msats == 57000
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_upstream_response_cost_uses_model_cache_pricing(
|
||||
integration_session: AsyncSession,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""A model's configured cache price must discount upstream cached-token usage."""
|
||||
provider = UpstreamProviderRow(
|
||||
provider_type="generic",
|
||||
base_url="https://cache-priced-upstream.example/v1",
|
||||
api_key="test-key",
|
||||
provider_fee=1.0,
|
||||
)
|
||||
integration_session.add(provider)
|
||||
await integration_session.commit()
|
||||
await integration_session.refresh(provider)
|
||||
assert provider.id is not None
|
||||
|
||||
row = ModelRow(
|
||||
id="cache-priced-model",
|
||||
name="Cache Priced Model",
|
||||
description="model seeded with explicit cache pricing",
|
||||
created=0,
|
||||
context_length=128000,
|
||||
architecture=json.dumps(
|
||||
{
|
||||
"modality": "text",
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "unknown",
|
||||
"instruct_type": None,
|
||||
}
|
||||
),
|
||||
pricing=json.dumps(
|
||||
{
|
||||
"prompt": 1.4e-7,
|
||||
"completion": 2.8e-7,
|
||||
"input_cache_read": 1.25e-9,
|
||||
"input_cache_write": 4.5e-9,
|
||||
}
|
||||
),
|
||||
upstream_provider_id=provider.id,
|
||||
enabled=True,
|
||||
forwarded_model_id="cache-priced-model",
|
||||
)
|
||||
integration_session.add(row)
|
||||
await integration_session.commit()
|
||||
|
||||
with patch("routstr.payment.models.sats_usd_price", return_value=1e-6):
|
||||
await reinitialize_upstreams()
|
||||
|
||||
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=1e-6):
|
||||
cost = await calculate_cost(
|
||||
{
|
||||
"model": "cache-priced-model",
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 100,
|
||||
"prompt_tokens_details": {"cached_tokens": 800},
|
||||
},
|
||||
},
|
||||
max_cost=1_000_000,
|
||||
)
|
||||
|
||||
assert isinstance(cost, CostData)
|
||||
# prompt 1.4e-7 USD/token -> 0.14 sats/token -> 140 msats/token
|
||||
# cache 1.25e-9 USD/token -> 0.00125 sats/token -> 1.25 msats/token
|
||||
# completion 2.8e-7 USD/token -> 0.28 sats/token -> 280 msats/token
|
||||
assert cost.input_tokens == 200
|
||||
assert cost.cache_read_input_tokens == 800
|
||||
assert cost.cache_read_msats == 1000
|
||||
assert cost.input_msats == 29000
|
||||
assert cost.output_msats == 28000
|
||||
assert cost.total_msats == 57000
|
||||
@@ -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
|
||||
|
||||
146
tests/integration/test_free_response_stale_reservation.py
Normal file
146
tests/integration/test_free_response_stale_reservation.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Regression tests for charging after stale reservation cleanup."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
|
||||
def _make_key(balance: int, reserved: int) -> ApiKey:
|
||||
return ApiKey(
|
||||
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||
balance=balance,
|
||||
reserved_balance=reserved,
|
||||
total_spent=0,
|
||||
total_requests=1,
|
||||
)
|
||||
|
||||
|
||||
def _cost_data(total_msats: int) -> CostData:
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=total_msats // 2,
|
||||
output_msats=total_msats - total_msats // 2,
|
||||
total_msats=total_msats,
|
||||
total_usd=0.0,
|
||||
input_tokens=100,
|
||||
output_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overrun_charges_after_reservation_swept(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Overrun finalize must charge even when the reservation was already released."""
|
||||
from routstr.auth import adjust_payment_for_tokens
|
||||
|
||||
deducted_max_cost = 990 # discounted reservation
|
||||
actual_token_cost = 1000 # actual cost overruns the reservation
|
||||
|
||||
# Sweeper has zeroed reserved_balance but left balance untouched.
|
||||
key = _make_key(balance=1000, reserved=0)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
response_data = {
|
||||
"model": "test-model",
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 100},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(
|
||||
key, response_data, integration_session, deducted_max_cost
|
||||
)
|
||||
|
||||
await integration_session.refresh(key)
|
||||
|
||||
assert key.total_spent == actual_token_cost, (
|
||||
f"Request was not billed (total_spent={key.total_spent}) — free response bug"
|
||||
)
|
||||
assert key.balance == 1000 - actual_token_cost, (
|
||||
f"Balance not charged: {key.balance}"
|
||||
)
|
||||
assert key.balance >= 0
|
||||
assert key.reserved_balance == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_response_path_closed_end_to_end(
|
||||
integration_session: AsyncSession,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""A reservation released by the real sweeper must not yield a free response."""
|
||||
from routstr.auth import adjust_payment_for_tokens, pay_for_request
|
||||
from routstr.core.db import create_session, release_stale_reservations
|
||||
|
||||
deducted_max_cost = 990
|
||||
actual_token_cost = 1000
|
||||
key_hash = f"test_sweep_{uuid.uuid4().hex}"
|
||||
|
||||
async with create_session() as session:
|
||||
session.add(
|
||||
ApiKey(
|
||||
hashed_key=key_hash,
|
||||
balance=1000,
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Reserve the request, then backdate reserved_at so the sweeper treats it as
|
||||
# stale (simulates a stream that outlived stale_reservation_timeout_seconds).
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, key_hash)
|
||||
assert key is not None
|
||||
await pay_for_request(key, deducted_max_cost, session)
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == deducted_max_cost
|
||||
key.reserved_at = int(time.time()) - 10_000
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
# Sweeper releases the stale reservation without charging.
|
||||
async with create_session() as session:
|
||||
released = await release_stale_reservations(session, max_age_seconds=300)
|
||||
assert released == 1
|
||||
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, key_hash)
|
||||
assert key is not None
|
||||
assert key.reserved_balance == 0, "Precondition: sweeper zeroed the reservation"
|
||||
|
||||
response_data = {
|
||||
"model": "test-model",
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 100},
|
||||
}
|
||||
with patch(
|
||||
"routstr.auth.calculate_cost",
|
||||
return_value=_cost_data(actual_token_cost),
|
||||
):
|
||||
await adjust_payment_for_tokens(
|
||||
key, response_data, session, deducted_max_cost
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
final = await session.get(ApiKey, key_hash)
|
||||
assert final is not None
|
||||
|
||||
assert final.total_spent == actual_token_cost, (
|
||||
f"Free response: total_spent={final.total_spent}, expected {actual_token_cost}"
|
||||
)
|
||||
assert final.balance == 1000 - actual_token_cost, (
|
||||
f"Balance not charged after sweep: {final.balance}"
|
||||
)
|
||||
assert final.balance >= 0
|
||||
assert final.reserved_balance == 0
|
||||
@@ -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)
|
||||
128
tests/integration/test_provider_self_lookup.py
Normal file
128
tests/integration/test_provider_self_lookup.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""A live upstream provider resolves its OWN database row by stable identity
|
||||
(its primary key), not by its mutable/secret ``api_key``.
|
||||
|
||||
Today ``from_db_row`` drops ``provider_row.id`` and the two self-referential
|
||||
paths — PPQ.AI's insufficient-balance self-disable and the base
|
||||
``refresh_models_cache`` — re-find their own row with
|
||||
``WHERE base_url == self.base_url AND api_key == self.api_key``. That uses a
|
||||
rotatable secret as a self-handle: the moment the row's key changes underneath a
|
||||
live object (a rotation racing an in-flight request), the object can no longer
|
||||
find itself. These tests pin the invariant that a provider looks itself up by
|
||||
identity, so the lookup survives a key change (and, later, key encryption).
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
from routstr.upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_object_carries_its_persistent_identity(
|
||||
integration_session: object,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""``from_db_row`` gives the in-memory object its row's identity (``db_id``)."""
|
||||
row = UpstreamProviderRow(
|
||||
provider_type="ppqai",
|
||||
base_url="https://api.ppq.ai",
|
||||
api_key="sk-original",
|
||||
enabled=True,
|
||||
provider_fee=1.0,
|
||||
)
|
||||
integration_session.add(row) # type: ignore[attr-defined]
|
||||
await integration_session.commit() # type: ignore[attr-defined]
|
||||
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||
|
||||
provider = PPQAIUpstreamProvider.from_db_row(row)
|
||||
assert provider is not None
|
||||
|
||||
assert provider.db_id == row.id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_disable_targets_own_row_after_key_rotation(
|
||||
integration_session: object,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""PPQ.AI self-disable must disable *its* row even after the key rotated.
|
||||
|
||||
RED (current): the object holds the pre-rotation key, so the
|
||||
``(base_url, api_key)`` lookup misses the row → the provider is never
|
||||
disabled. GREEN: lookup by ``id`` finds it and disables it.
|
||||
"""
|
||||
row = UpstreamProviderRow(
|
||||
provider_type="ppqai",
|
||||
base_url="https://api.ppq.ai",
|
||||
api_key="sk-original",
|
||||
enabled=True,
|
||||
provider_fee=1.0,
|
||||
)
|
||||
integration_session.add(row) # type: ignore[attr-defined]
|
||||
await integration_session.commit() # type: ignore[attr-defined]
|
||||
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||
|
||||
provider = PPQAIUpstreamProvider.from_db_row(row) # captures sk-original
|
||||
assert provider is not None
|
||||
|
||||
# Key is rotated in the DB while `provider` is still live.
|
||||
row.api_key = "sk-rotated"
|
||||
integration_session.add(row) # type: ignore[attr-defined]
|
||||
await integration_session.commit() # type: ignore[attr-defined]
|
||||
|
||||
with patch("routstr.proxy.reinitialize_upstreams", new=AsyncMock()):
|
||||
await provider.on_upstream_error_redirect(402, "Insufficient balance")
|
||||
|
||||
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||
assert row.enabled is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_models_cache_finds_own_row_after_key_rotation(
|
||||
integration_session: object,
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""``refresh_models_cache`` must resolve its own row after a key rotation.
|
||||
|
||||
``refresh_models_cache`` swallows every exception (it only logs), so the
|
||||
observable proof it found its row is that it reaches ``list_models`` — which
|
||||
is called with the row's ``id`` only *after* the row is resolved. RED
|
||||
(current): the stale-key ``(base_url, api_key)`` lookup returns nothing, the
|
||||
method raises ``404`` internally and returns before ``list_models`` is ever
|
||||
called. GREEN: lookup by ``id`` finds the row and ``list_models`` runs for
|
||||
that ``id``.
|
||||
"""
|
||||
row = UpstreamProviderRow(
|
||||
provider_type="ppqai",
|
||||
base_url="https://api.ppq.ai",
|
||||
api_key="sk-original",
|
||||
enabled=True,
|
||||
provider_fee=1.0,
|
||||
)
|
||||
integration_session.add(row) # type: ignore[attr-defined]
|
||||
await integration_session.commit() # type: ignore[attr-defined]
|
||||
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||
row_id = row.id
|
||||
|
||||
provider = PPQAIUpstreamProvider.from_db_row(row) # captures sk-original
|
||||
assert provider is not None
|
||||
|
||||
row.api_key = "sk-rotated"
|
||||
integration_session.add(row) # type: ignore[attr-defined]
|
||||
await integration_session.commit() # type: ignore[attr-defined]
|
||||
|
||||
list_models_mock = AsyncMock(return_value=[])
|
||||
with (
|
||||
patch.object(provider, "fetch_models", new=AsyncMock(return_value=[])),
|
||||
patch("routstr.upstream.base.list_models", new=list_models_mock),
|
||||
):
|
||||
await provider.refresh_models_cache()
|
||||
|
||||
list_models_mock.assert_awaited_once()
|
||||
assert list_models_mock.await_args is not None
|
||||
assert list_models_mock.await_args.kwargs["upstream_id"] == row_id
|
||||
283
tests/integration/test_prune_dead_api_keys.py
Normal file
283
tests/integration/test_prune_dead_api_keys.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Tests for prune_dead_api_keys — the janitor that removes provably-dead 0/0/0
|
||||
API keys (funded keys fully refunded/expired without ever being used, plus bare
|
||||
orphans), while protecting keys that are still meaningful.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.sql.dml import Update
|
||||
from sqlmodel import col, update
|
||||
|
||||
from routstr.core.db import (
|
||||
ApiKey,
|
||||
CashuTransaction,
|
||||
LightningInvoice,
|
||||
create_session,
|
||||
prune_dead_api_keys,
|
||||
)
|
||||
|
||||
OLD = 100 # min_age_seconds used by the tests
|
||||
NOW = int(time.time())
|
||||
LONG_AGO = NOW - 10_000 # well past the grace period
|
||||
|
||||
|
||||
async def _exists(key_hash: str) -> bool:
|
||||
async with create_session() as session:
|
||||
return (await session.get(ApiKey, key_hash)) is not None
|
||||
|
||||
|
||||
def _dead_key(created_at: int | None) -> ApiKey:
|
||||
return ApiKey(
|
||||
hashed_key=f"dead_{uuid.uuid4().hex}",
|
||||
balance=0,
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prunes_old_refunded_zero_key(patched_db_engine: None) -> None:
|
||||
"""A funded-then-refunded key (0/0/0, NULL parent, old) is pruned."""
|
||||
key = _dead_key(LONG_AGO)
|
||||
async with create_session() as session:
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 1
|
||||
assert not await _exists(key.hashed_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grace_period_protects_fresh_key(patched_db_engine: None) -> None:
|
||||
"""A dead-looking but recently created key is protected by the grace period."""
|
||||
key = _dead_key(int(time.time()))
|
||||
async with create_session() as session:
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 0
|
||||
assert await _exists(key.hashed_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_used_key_never_pruned(patched_db_engine: None) -> None:
|
||||
"""Keys with any spend/requests or live balance are never pruned."""
|
||||
spent = _dead_key(LONG_AGO)
|
||||
spent.total_spent = 1
|
||||
requested = _dead_key(LONG_AGO)
|
||||
requested.total_requests = 1
|
||||
funded = _dead_key(LONG_AGO)
|
||||
funded.balance = 1000
|
||||
reserved = _dead_key(LONG_AGO)
|
||||
reserved.reserved_balance = 500
|
||||
|
||||
async with create_session() as session:
|
||||
for k in (spent, requested, funded, reserved):
|
||||
session.add(k)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 0
|
||||
for k in (spent, requested, funded, reserved):
|
||||
assert await _exists(k.hashed_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parent_and_child_keys_are_not_pruned(
|
||||
patched_db_engine: None,
|
||||
) -> None:
|
||||
"""Pruning must not orphan child keys or delete valid children."""
|
||||
parent = _dead_key(LONG_AGO)
|
||||
child = ApiKey(
|
||||
hashed_key=f"child_{uuid.uuid4().hex}",
|
||||
balance=0,
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
created_at=LONG_AGO,
|
||||
parent_key_hash=parent.hashed_key,
|
||||
)
|
||||
async with create_session() as session:
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 0
|
||||
assert await _exists(parent.hashed_key)
|
||||
assert await _exists(child.hashed_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_invoice_protects_key(patched_db_engine: None) -> None:
|
||||
"""A key referenced by a pending topup invoice is never pruned mid-topup."""
|
||||
key = _dead_key(LONG_AGO)
|
||||
invoice = LightningInvoice(
|
||||
id=f"inv_{uuid.uuid4().hex}",
|
||||
bolt11=f"lnbc_{uuid.uuid4().hex}",
|
||||
amount_sats=10,
|
||||
description="topup",
|
||||
payment_hash=uuid.uuid4().hex,
|
||||
status="pending",
|
||||
api_key_hash=key.hashed_key,
|
||||
purpose="topup",
|
||||
expires_at=NOW + 10_000,
|
||||
)
|
||||
async with create_session() as session:
|
||||
session.add(key)
|
||||
session.add(invoice)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 0
|
||||
assert await _exists(key.hashed_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paid_invoice_does_not_protect_key(patched_db_engine: None) -> None:
|
||||
"""A settled (non-pending) invoice does not keep a dead key alive."""
|
||||
key = _dead_key(LONG_AGO)
|
||||
invoice = LightningInvoice(
|
||||
id=f"inv_{uuid.uuid4().hex}",
|
||||
bolt11=f"lnbc_{uuid.uuid4().hex}",
|
||||
amount_sats=10,
|
||||
description="topup",
|
||||
payment_hash=uuid.uuid4().hex,
|
||||
status="paid",
|
||||
api_key_hash=key.hashed_key,
|
||||
purpose="topup",
|
||||
expires_at=NOW - 1,
|
||||
)
|
||||
async with create_session() as session:
|
||||
session.add(key)
|
||||
session.add(invoice)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 1
|
||||
assert not await _exists(key.hashed_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_that_becomes_meaningful_during_prune_survives(
|
||||
patched_db_engine: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Revalidate before unlink/delete so a late top-up cannot be pruned."""
|
||||
key = _dead_key(LONG_AGO)
|
||||
txn = CashuTransaction(
|
||||
id=uuid.uuid4().hex,
|
||||
token="cashuABC",
|
||||
amount=21,
|
||||
unit="sat",
|
||||
type="in",
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
async with create_session() as session:
|
||||
session.add(key)
|
||||
session.add(txn)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
original_exec = cast(Callable[..., Awaitable[Any]], session.exec)
|
||||
topped_up = False
|
||||
|
||||
async def exec_with_late_topup(
|
||||
statement: Any, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal topped_up
|
||||
if (
|
||||
not topped_up
|
||||
and isinstance(statement, Update)
|
||||
and getattr(statement.table, "name", None) == "cashu_transactions"
|
||||
):
|
||||
topped_up = True
|
||||
async with create_session() as topup_session:
|
||||
await topup_session.exec( # type: ignore[call-overload]
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=42)
|
||||
)
|
||||
await topup_session.commit()
|
||||
return await original_exec(statement, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(session, "exec", exec_with_late_topup)
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 0
|
||||
assert await _exists(key.hashed_key)
|
||||
async with create_session() as session:
|
||||
surviving = await session.get(CashuTransaction, txn.id)
|
||||
assert surviving is not None
|
||||
assert surviving.api_key_hashed_key == key.hashed_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_audit_trail_preserved(patched_db_engine: None) -> None:
|
||||
"""Pruning a refunded key keeps its cashu_transactions, unlinked from the key."""
|
||||
key = _dead_key(LONG_AGO)
|
||||
txn = CashuTransaction(
|
||||
id=uuid.uuid4().hex,
|
||||
token="cashuABC",
|
||||
amount=21,
|
||||
unit="sat",
|
||||
type="in",
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
async with create_session() as session:
|
||||
session.add(key)
|
||||
session.add(txn)
|
||||
await session.commit()
|
||||
|
||||
async with create_session() as session:
|
||||
pruned = await prune_dead_api_keys(session, OLD)
|
||||
|
||||
assert pruned == 1
|
||||
assert not await _exists(key.hashed_key)
|
||||
|
||||
async with create_session() as session:
|
||||
surviving = await session.get(CashuTransaction, txn.id)
|
||||
assert surviving is not None, "Financial audit row must survive key deletion"
|
||||
assert surviving.api_key_hashed_key is None, "Link must be nulled, not dangling"
|
||||
assert surviving.amount == 21
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_prune_disabled_returns_immediately(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Non-positive intervals disable the janitor."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from routstr import auth
|
||||
from routstr.core.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "dead_key_prune_interval_seconds", 0)
|
||||
sleep_mock = AsyncMock()
|
||||
monkeypatch.setattr(auth.asyncio, "sleep", sleep_mock)
|
||||
|
||||
await auth.periodic_dead_key_prune()
|
||||
|
||||
sleep_mock.assert_not_called()
|
||||
194
tests/integration/test_swap_fee_retry.py
Normal file
194
tests/integration/test_swap_fee_retry.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Integration tests for reactive swap fee retries via the wallet topup endpoint.
|
||||
|
||||
Foreign-mint tokens are swapped to the primary mint using the foreign mint's
|
||||
melt quote, whose fee_reserve is a non-binding estimate (NUT-05): the mint may
|
||||
demand more when re-quoting or at melt execution. These tests cover the
|
||||
endpoint behaviour in those cases:
|
||||
|
||||
1. The mint demands one sat more at melt time than every quote reported
|
||||
(the mint.cubabitcoin.org incident): the swap retries with a smaller
|
||||
invoice and the topup succeeds, crediting the recomputed amount.
|
||||
2. The real melt quote reports a higher fee_reserve than the estimate: the
|
||||
swap re-quotes from the observed fee and the topup succeeds.
|
||||
3. The mint escalates its fee demands on every attempt: the retry budget is
|
||||
exhausted and the endpoint returns 400 with a clear error (never 500),
|
||||
without ever executing a melt.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, Response
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
# Captured at collection time, before the integration_app fixture replaces it
|
||||
# with the testmint stub that bypasses swapping (see conftest.py).
|
||||
from routstr.wallet import recieve_token as _real_recieve_token
|
||||
|
||||
PRIMARY_MINT = "http://primary:3338"
|
||||
|
||||
|
||||
def _make_swap_mocks(
|
||||
token_amount: int,
|
||||
fee_reserves: list[int],
|
||||
input_fees: int = 0,
|
||||
mint_url: str = "http://foreign-mint:3338",
|
||||
) -> tuple[Mock, Mock, Mock]:
|
||||
"""Return (token, token_wallet, primary_wallet) mocks that act like a mint.
|
||||
|
||||
Mint quotes pass the requested amount through their ``request`` field and
|
||||
melt quotes echo that amount back, so the mocks stay consistent for
|
||||
whatever amounts the implementation requests. ``fee_reserves`` supplies the
|
||||
fee_reserve of each successive melt quote (the first serves the estimation
|
||||
pass); requesting more quotes than provided fails the test.
|
||||
"""
|
||||
mock_token = Mock()
|
||||
mock_token.mint = mint_url
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = token_amount
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [Mock(amount=token_amount)]
|
||||
|
||||
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=input_fees)
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
mock_primary_wallet.available_balance = Mock(amount=0)
|
||||
mock_primary_wallet.mint = AsyncMock(return_value=Mock())
|
||||
|
||||
fees = iter(fee_reserves)
|
||||
|
||||
def _next_fee() -> int:
|
||||
try:
|
||||
return next(fees)
|
||||
except StopIteration:
|
||||
raise AssertionError(
|
||||
"more melt quotes requested than fee_reserves provided"
|
||||
) from None
|
||||
|
||||
mock_primary_wallet.request_mint = AsyncMock(
|
||||
side_effect=lambda amount: Mock(quote=f"mint_quote_{amount}", request=amount)
|
||||
)
|
||||
mock_token_wallet.melt_quote = AsyncMock(
|
||||
side_effect=lambda invoice: Mock(
|
||||
quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee()
|
||||
)
|
||||
)
|
||||
mock_token_wallet.melt = AsyncMock(return_value=Mock())
|
||||
|
||||
return mock_token, mock_token_wallet, mock_primary_wallet
|
||||
|
||||
|
||||
def _wallet_router(primary_wallet: Mock, token_wallet: Mock) -> Callable[..., Mock]:
|
||||
"""Route get_wallet calls to the primary or foreign wallet mock by URL."""
|
||||
|
||||
def fake_get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Mock:
|
||||
return primary_wallet if mint_url == PRIMARY_MINT else token_wallet
|
||||
|
||||
return fake_get_wallet
|
||||
|
||||
|
||||
async def _post_topup(
|
||||
client: AsyncClient,
|
||||
mock_token: Mock,
|
||||
token_wallet: Mock,
|
||||
primary_wallet: Mock,
|
||||
) -> Response:
|
||||
"""POST /v1/wallet/topup with the swap layer mocked at the mint boundary.
|
||||
|
||||
The conftest's testmint stub for recieve_token is swapped back for the
|
||||
real implementation so the request exercises the actual swap path.
|
||||
"""
|
||||
with patch("routstr.wallet.recieve_token", _real_recieve_token):
|
||||
with patch(
|
||||
"routstr.wallet.deserialize_token_from_string", return_value=mock_token
|
||||
):
|
||||
with patch(
|
||||
"routstr.wallet.get_wallet",
|
||||
side_effect=_wallet_router(primary_wallet, token_wallet),
|
||||
):
|
||||
with patch.object(settings, "primary_mint", PRIMARY_MINT):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch.object(settings, "cashu_mints", [PRIMARY_MINT]):
|
||||
return await client.post(
|
||||
"/v1/wallet/topup",
|
||||
params={"cashu_token": "cashuAtest_foreign_token"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_retries_when_melt_demands_more_than_quoted(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""A 179-sat token where every quote reports fee_reserve=1 but the mint
|
||||
rejects the first melt demanding 180. The retry shrinks the invoice to 177
|
||||
and the topup credits 177 sats (177_000 msats)."""
|
||||
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
|
||||
179, fee_reserves=[1, 1, 1], mint_url="http://mint.cubabitcoin.org"
|
||||
)
|
||||
token_wallet.melt.side_effect = [
|
||||
Exception(
|
||||
"Mint Error: not enough inputs provided for melt. "
|
||||
"Provided: 179, needed: 180 (Code: 11000)"
|
||||
),
|
||||
Mock(),
|
||||
]
|
||||
|
||||
response = await _post_topup(
|
||||
authenticated_client, mock_token, token_wallet, primary_wallet
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["msats"] == 177_000
|
||||
assert token_wallet.melt.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_retries_when_quote_fee_exceeds_estimate(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""A 1000-sat token estimated at fee 20, but the real quote demands 23.
|
||||
The retry recomputes 1000 - 23 = 977, which fits, and the topup credits
|
||||
977 sats (977_000 msats) with a single melt."""
|
||||
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
|
||||
1000, fee_reserves=[20, 23, 23]
|
||||
)
|
||||
|
||||
response = await _post_topup(
|
||||
authenticated_client, mock_token, token_wallet, primary_wallet
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["msats"] == 977_000
|
||||
assert token_wallet.melt.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_returns_422_when_retries_exhausted(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50)
|
||||
exhausts the retry budget: clean 422 mint_error/too-small taxonomy, melt
|
||||
never executed."""
|
||||
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
|
||||
1000, fee_reserves=[1, 10, 25, 50]
|
||||
)
|
||||
|
||||
response = await _post_topup(
|
||||
authenticated_client, mock_token, token_wallet, primary_wallet
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "too small to cover swap fees" in response.json()["detail"]
|
||||
assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
|
||||
token_wallet.melt.assert_not_called()
|
||||
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)
|
||||
@@ -12,10 +12,7 @@ from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ResponseValidator,
|
||||
)
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -79,29 +76,31 @@ async def test_api_key_generation_invalid_token(
|
||||
# Capture initial state
|
||||
await db_snapshot.capture()
|
||||
|
||||
# Test various invalid tokens
|
||||
invalid_tokens = [
|
||||
CashuTokenGenerator.generate_invalid_token(), # Malformed token
|
||||
"not-a-cashu-token", # Wrong format
|
||||
"cashuA", # Empty token
|
||||
"cashuA" + "x" * 1000, # Invalid base64
|
||||
# Non-Cashu bearer values are invalid API keys (401). Malformed values that
|
||||
# look like Cashu tokens use the shared Cashu taxonomy (400 invalid_token).
|
||||
invalid_tokens: list[tuple[str, int, str | None]] = [
|
||||
("not-a-cashu-token", 401, None),
|
||||
("sk-not-a-real-api-key", 401, None),
|
||||
("cashuA", 400, "invalid_cashu_token"),
|
||||
("cashuA" + "x" * 1000, 400, "invalid_cashu_token"),
|
||||
]
|
||||
|
||||
for invalid_token in invalid_tokens:
|
||||
for invalid_token, expected_status, expected_code in invalid_tokens:
|
||||
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
||||
response = await integration_client.get("/v1/wallet/info")
|
||||
|
||||
# Should fail with 401
|
||||
assert response.status_code == 401, (
|
||||
assert response.status_code == expected_status, (
|
||||
f"Token {invalid_token[:20]}... should be invalid"
|
||||
)
|
||||
|
||||
# Validate error response
|
||||
validator = ResponseValidator()
|
||||
error_validation = validator.validate_error_response(
|
||||
response, expected_status=401, expected_error_key="detail"
|
||||
response, expected_status=expected_status, expected_error_key="detail"
|
||||
)
|
||||
assert error_validation["valid"]
|
||||
if expected_code is not None:
|
||||
assert response.json()["detail"]["error"]["code"] == expected_code
|
||||
|
||||
# Verify no database changes
|
||||
diff = await db_snapshot.diff()
|
||||
|
||||
@@ -14,6 +14,7 @@ from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
from routstr.wallet import MintConnectionError
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -503,26 +504,19 @@ async def test_mint_unavailability_handling(
|
||||
|
||||
# The global mock in conftest.py is already in place,
|
||||
# so we need to temporarily modify it
|
||||
from unittest.mock import patch
|
||||
raw_error = "Mint unavailable: Connection refused"
|
||||
|
||||
# Make the send_token method raise an exception
|
||||
# Make the send_token method raise a typed mint connection exception.
|
||||
with patch(
|
||||
"routstr.balance.send_token",
|
||||
side_effect=Exception("Mint unavailable: Connection refused"),
|
||||
side_effect=MintConnectionError(raw_error),
|
||||
):
|
||||
# The exception should propagate as a 503 error (Service Unavailable)
|
||||
# But we need to handle it properly
|
||||
try:
|
||||
response = await authenticated_client.post("/v1/wallet/refund")
|
||||
# If we get here, check the status code
|
||||
assert response.status_code == 503
|
||||
assert "Mint service unavailable" in response.json()["detail"]
|
||||
except Exception as e:
|
||||
# If the exception propagates, that's also a failure scenario
|
||||
assert "Mint unavailable" in str(e)
|
||||
response = await authenticated_client.post("/v1/wallet/refund")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Mint service unavailable"
|
||||
assert raw_error not in response.text
|
||||
|
||||
# Balance should remain unchanged (transaction should roll back)
|
||||
# Note: Current implementation might not handle this perfectly
|
||||
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||
assert wallet_response.status_code == 200
|
||||
assert wallet_response.json()["balance"] == 10_000_000
|
||||
|
||||
@@ -436,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
|
||||
|
||||
293
tests/unit/test_auth_cashu.py
Normal file
293
tests/unit/test_auth_cashu.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import AsyncGenerator, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
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
|
||||
from routstr.wallet import MintConnectionError
|
||||
|
||||
|
||||
def _value_error_wrapping_transport() -> ValueError:
|
||||
"""A ValueError re-raised ``from`` a real httpx transport error, mirroring
|
||||
``wallet.py`` wrapping a connection failure. The sanitized classifier must
|
||||
still see the mint-unreachable signal through the ``__cause__`` chain."""
|
||||
try:
|
||||
raise httpx.ConnectError("All connection attempts failed")
|
||||
except httpx.ConnectError as exc:
|
||||
err = ValueError("Failed to estimate fees: connection failed")
|
||||
err.__cause__ = exc
|
||||
return err
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_status", "expected_type", "expected_message", "expected_code"),
|
||||
[
|
||||
(
|
||||
ValueError("Mint Error: Token already spent. (Code: 11001)"),
|
||||
400,
|
||||
"token_already_spent",
|
||||
"Cashu token already spent",
|
||||
"cashu_token_already_spent",
|
||||
),
|
||||
(
|
||||
# Raw httpx transport error propagated unwrapped from cashu.
|
||||
httpx.ConnectError("All connection attempts failed"),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
# Typed error raised by wallet.py at a wrap site.
|
||||
MintConnectionError("connect to http://mint:3338 refused"),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
# ValueError wrapping the httpx error in its __cause__ chain.
|
||||
_value_error_wrapping_transport(),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
# asyncio.TimeoutError is builtin TimeoutError on 3.11+.
|
||||
TimeoutError("Timed out connecting to Cashu mint http://mint:3338"),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Token amount (5 sat) is insufficient to cover melt fees. "
|
||||
"Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Failed to melt token from foreign mint http://foreign:3338: boom"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Failed to swap token from foreign mint",
|
||||
"cashu_foreign_mint_swap_failed",
|
||||
),
|
||||
(
|
||||
ValueError("could not decode token"),
|
||||
400,
|
||||
"invalid_token",
|
||||
"Invalid Cashu token",
|
||||
"invalid_cashu_token",
|
||||
),
|
||||
(
|
||||
ValueError("some unexpected wallet condition"),
|
||||
400,
|
||||
"cashu_error",
|
||||
"Failed to redeem Cashu token",
|
||||
"cashu_token_redemption_failed",
|
||||
),
|
||||
(
|
||||
ValueError("Redeemed token amount must be positive, got 0 msats"),
|
||||
400,
|
||||
"cashu_error",
|
||||
"Failed to redeem Cashu token: token yielded no value",
|
||||
"cashu_token_zero_value",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_redemption_failure_returns_sanitized_error(
|
||||
session: AsyncSession,
|
||||
error: Exception,
|
||||
expected_status: int,
|
||||
expected_type: str,
|
||||
expected_message: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
"""Redemption failures reuse the shared X-Cashu taxonomy (carried in
|
||||
``type``), expose stable sanitized messages and granular machine-readable
|
||||
``code`` values, and leave no orphan ApiKey row."""
|
||||
token = "cashuAredemption_fails_with_specific_error"
|
||||
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=error),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == expected_status
|
||||
detail = cast(dict[str, dict[str, object]], exc_info.value.detail)
|
||||
error_detail = detail["error"]
|
||||
assert error_detail["type"] == expected_type
|
||||
assert error_detail["code"] == expected_code
|
||||
assert error_detail["message"] == expected_message
|
||||
assert str(error) not in cast(str, error_detail["message"])
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_redemption_error_returns_internal_error(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Unexpected (non-wallet) failures surface as generic 500s without
|
||||
leaking internal details, instead of masquerading as token errors."""
|
||||
token = "cashuAredemption_fails_with_internal_error"
|
||||
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=RuntimeError("db exploded at /var/lib/secret")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||
error_detail = detail["error"]
|
||||
assert error_detail["code"] == "internal_error"
|
||||
assert "/var/lib/secret" not in error_detail["message"]
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_error_with_invalid_keyword_does_not_masquerade(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""A non-wallet fault whose text merely contains "invalid" (but not
|
||||
"token") must fall through to a generic 500, not a 401 token error.
|
||||
|
||||
Guards the anchored `"invalid"/"decode"` + `"token"` gate against stdlib/
|
||||
driver strings like "Invalid isoformat string" leaking as token errors."""
|
||||
token = "cashuAinternal_fault_mentions_invalid"
|
||||
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=RuntimeError("Invalid isoformat string: '2020-13-99'")
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||
assert detail["error"]["code"] == "internal_error"
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_cashu_token_returns_400_invalid_token(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""A malformed 'cashu...' token that fails to decode maps to 400
|
||||
invalid_cashu_token (shared taxonomy), not the generic 401 invalid_api_key."""
|
||||
token = "cashuAthis_is_not_a_valid_token"
|
||||
|
||||
with patch(
|
||||
"routstr.auth.deserialize_token_from_string",
|
||||
side_effect=ValueError("unable to decode token: bad base64"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||
assert detail["error"]["type"] == "invalid_token"
|
||||
assert detail["error"]["code"] == "invalid_cashu_token"
|
||||
# Raw decoder text must not leak to the client.
|
||||
assert "base64" not in detail["error"]["message"]
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routstr.balance import refund_wallet_endpoint
|
||||
from routstr.balance import refund_wallet_endpoint, topup_wallet_endpoint
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
from routstr.wallet import credit_balance
|
||||
from routstr.wallet import MintConnectionError, credit_balance
|
||||
|
||||
|
||||
def _make_cashu_tx(
|
||||
@@ -103,6 +104,64 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_pending_raises_425() -> None:
|
||||
"""in row exists with a request_id but out row not yet created → 425.
|
||||
|
||||
This is the race condition where /v1/wallet/refund is polled while the
|
||||
upstream request is still in flight. The endpoint must signal "retry"
|
||||
rather than a permanent 404.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
x_cashu_token = "cashuApending_token"
|
||||
in_tx = _make_cashu_tx(
|
||||
token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-pending"
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(None)])
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu=x_cashu_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 425
|
||||
assert exc_info.value.headers == {"Retry-After": "2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_in_tx_without_request_id_raises_404() -> None:
|
||||
"""in row exists but has no request_id (cannot link to a refund) → 404.
|
||||
|
||||
This is a genuine "no refund will ever exist" case, distinct from the
|
||||
pending 425 path.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
x_cashu_token = "cashuAnoreqid_token"
|
||||
in_tx = _make_cashu_tx(
|
||||
token=x_cashu_token, amount=0, unit="msat", type="in", request_id=None
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx)])
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu=x_cashu_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_swept_raises_410() -> None:
|
||||
from fastapi import HTTPException
|
||||
@@ -339,7 +398,10 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
||||
|
||||
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.send_token",
|
||||
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
|
||||
),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||
@@ -353,10 +415,46 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Mint service unavailable"
|
||||
assert "raw mint outage detail" not in exc_info.value.detail
|
||||
# Verify two exec calls: debit + restore
|
||||
assert session.exec.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
|
||||
"""Unexpected send-side failures restore balance without leaking exception text."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
raw_error = "database secret token raw-mint-response"
|
||||
|
||||
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=RuntimeError(raw_error))),
|
||||
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 == 500
|
||||
assert exc_info.value.detail == "Refund failed"
|
||||
assert raw_error not in exc_info.value.detail
|
||||
assert session.exec.await_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -401,3 +499,190 @@ async def test_refund_unknown_sk_bearer_returns_401() -> None:
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
session.get.assert_awaited_once()
|
||||
|
||||
|
||||
# --- Topup redemption error taxonomy (POST /v1/wallet/topup) ------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
httpx.ConnectError("All connection attempts failed"),
|
||||
MintConnectionError("connect to mint refused"),
|
||||
TimeoutError("timed out connecting to mint"),
|
||||
],
|
||||
)
|
||||
async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
|
||||
"""A down mint must surface 503 (retryable), not 400 or 500 — the token is
|
||||
fine, so the client should retry once the mint recovers."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Cashu mint is unreachable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_already_spent_still_returns_400() -> None:
|
||||
"""Regression: the mint-unreachable short-circuit must not swallow the
|
||||
existing ValueError substring buckets."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(side_effect=ValueError("Token already spent")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "Cashu token already spent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_zero_value_returns_400_zero_value_message() -> None:
|
||||
"""A dust/zero redemption maps to the documented zero-value message, not the
|
||||
generic redemption-failed one."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(
|
||||
side_effect=ValueError("Redeemed token amount must be positive, got 0 msats")
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "Failed to redeem Cashu token: token yielded no value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_token_consumed_returns_500() -> None:
|
||||
"""A post-redemption crediting failure (token spent) is a non-retryable 500,
|
||||
not a 4xx that invites a retry."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routstr.wallet import TokenConsumedError
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(side_effect=TokenConsumedError("credit failed")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == (
|
||||
"Token was redeemed but could not be credited; do not retry"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_status", "expected_detail"),
|
||||
[
|
||||
(
|
||||
ValueError(
|
||||
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||
),
|
||||
422,
|
||||
"Token value is too small to cover swap fees",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Token amount (5 sat) is insufficient to cover melt fees."
|
||||
),
|
||||
422,
|
||||
"Token value is too small to cover swap fees",
|
||||
),
|
||||
(
|
||||
ValueError("Failed to melt token from foreign mint http://m: boom"),
|
||||
422,
|
||||
"Failed to swap token from foreign mint",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_topup_fee_and_swap_failures_return_422(
|
||||
error: Exception, expected_status: int, expected_detail: str
|
||||
) -> None:
|
||||
"""Fee/swap failures map to 422 (shared taxonomy), matching the bearer and
|
||||
X-Cashu paths — previously top-up flattened these to 400."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == expected_status
|
||||
assert exc_info.value.detail == expected_detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_unexpected_non_valueerror_returns_500() -> None:
|
||||
"""A non-ValueError, non-transport fault is an internal error (500), not a
|
||||
sanitized 400 — the merged except must preserve this."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(side_effect=RuntimeError("db exploded")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == "Internal server error"
|
||||
|
||||
189
tests/unit/test_cache_breakpoints.py
Normal file
189
tests/unit/test_cache_breakpoints.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Tests for Anthropic cache-breakpoint injection on forwarded requests.
|
||||
|
||||
Anthropic prompt caching is explicit; a client that doesn't recognise a routstr
|
||||
URL as Anthropic-backed never sends ``cache_control`` markers, so caching never
|
||||
engages over routstr. ``prepare_request_body`` must stamp the standard
|
||||
breakpoints for Anthropic-family models while always deferring to client-set
|
||||
markers and never touching automatic-cache providers.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
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.upstream import GenericUpstreamProvider
|
||||
from routstr.upstream.cache_breakpoints import (
|
||||
body_has_cache_control,
|
||||
inject_anthropic_cache_breakpoints,
|
||||
is_explicit_cache_model,
|
||||
)
|
||||
|
||||
|
||||
def _chat_body() -> dict:
|
||||
return {
|
||||
"model": "anthropic/claude-sonnet-4.5",
|
||||
"stream": True,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are concise."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
"tools": [
|
||||
{"type": "function", "function": {"name": "a"}},
|
||||
{"type": "function", "function": {"name": "b"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id,expected",
|
||||
[
|
||||
("anthropic/claude-sonnet-4.5", True),
|
||||
("claude-haiku-4-5-20251001", True),
|
||||
# Alibaba explicit-cache models share Anthropic's wire format
|
||||
("qwen/qwen3-max", True),
|
||||
("qwen/qwen3-coder-plus", True),
|
||||
("deepseek/deepseek-v3.2", True),
|
||||
# Automatic-cache providers need no markers
|
||||
("openai/gpt-4o", False),
|
||||
("google/gemini-2.5-flash", False),
|
||||
("deepseek/deepseek-chat", False),
|
||||
("qwen/qwen3.5-plus-02-15", False), # snapshot, no explicit caching
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_is_explicit_cache_model(model_id: str | None, expected: bool) -> None:
|
||||
assert is_explicit_cache_model(model_id) is expected
|
||||
|
||||
|
||||
def test_is_explicit_cache_model_uses_fallbacks() -> None:
|
||||
# routstr id is opaque but a forwarded/canonical alias reveals the family.
|
||||
assert is_explicit_cache_model("model-xyz", None, "anthropic/claude-opus-4.1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Breakpoint placement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_injects_three_breakpoints() -> None:
|
||||
data = _chat_body()
|
||||
assert inject_anthropic_cache_breakpoints(data) is True
|
||||
|
||||
# system prompt promoted to array form with a marker
|
||||
system = data["messages"][0]["content"]
|
||||
assert system == [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are concise.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
# last tool marked
|
||||
assert data["tools"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in data["tools"][0]
|
||||
# last user message marked
|
||||
user = data["messages"][1]["content"]
|
||||
assert user[-1]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
def test_defers_to_client_supplied_cache_control() -> None:
|
||||
data = _chat_body()
|
||||
data["messages"][1]["content"] = [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
assert body_has_cache_control(data) is True
|
||||
# No additional stamping when the client already controls caching.
|
||||
assert inject_anthropic_cache_breakpoints(data) is False
|
||||
assert "cache_control" not in data["tools"][-1]
|
||||
|
||||
|
||||
def test_marks_last_text_part_of_array_content() -> None:
|
||||
data = _chat_body()
|
||||
data["messages"][1]["content"] = [
|
||||
{"type": "text", "text": "first"},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
{"type": "text", "text": "last"},
|
||||
]
|
||||
inject_anthropic_cache_breakpoints(data)
|
||||
parts = data["messages"][1]["content"]
|
||||
assert parts[2]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in parts[0]
|
||||
|
||||
|
||||
def test_noop_without_messages() -> None:
|
||||
assert inject_anthropic_cache_breakpoints({"prompt": "x"}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_request_body integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _model(model_id: str): # type: ignore[no-untyped-def]
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="",
|
||||
context_length=200000,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Claude",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(prompt=0.0, completion=0.0),
|
||||
)
|
||||
|
||||
|
||||
def _openrouter_provider() -> "GenericUpstreamProvider":
|
||||
# OpenRouter endpoint via the generic provider — recognised by base URL.
|
||||
return GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id", ["anthropic/claude-sonnet-4.5", "qwen/qwen3-max", "deepseek/deepseek-v3.2"]
|
||||
)
|
||||
def test_prepare_request_body_injects_for_explicit_models(model_id: str) -> None:
|
||||
provider = _openrouter_provider()
|
||||
body = json.dumps(_chat_body()).encode()
|
||||
out = provider.prepare_request_body(body, _model(model_id))
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert body_has_cache_control(data) is True
|
||||
assert data["tools"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
def test_prepare_request_body_skips_for_automatic_provider_model() -> None:
|
||||
provider = _openrouter_provider()
|
||||
body = json.dumps(_chat_body()).encode()
|
||||
out = provider.prepare_request_body(body, _model("openai/gpt-4o"))
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert body_has_cache_control(data) is False
|
||||
|
||||
|
||||
def test_prepare_request_body_skips_when_upstream_rejects_markers() -> None:
|
||||
# Claude id but a non-OpenRouter/Anthropic upstream → must NOT inject,
|
||||
# since the markers could be rejected by an upstream that doesn't accept them.
|
||||
from routstr.upstream import GenericUpstreamProvider
|
||||
|
||||
provider = GenericUpstreamProvider(base_url="https://some-gateway.example/v1")
|
||||
body = json.dumps(_chat_body()).encode()
|
||||
out = provider.prepare_request_body(body, _model("anthropic/claude-sonnet-4.5"))
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert body_has_cache_control(data) is False
|
||||
347
tests/unit/test_cache_pricing.py
Normal file
347
tests/unit/test_cache_pricing.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""Tests for cache-aware pricing of cached input tokens.
|
||||
|
||||
Specifies two things:
|
||||
|
||||
1. ``backfill_cache_pricing`` — when the OpenRouter model feed omits cache
|
||||
rates (it does for most DeepSeek models and e.g. openai/gpt-4o), they are
|
||||
filled from litellm's bundled cost map instead of silently billing cache
|
||||
reads at the full input rate. Existing OpenRouter values are never
|
||||
overwritten, and provider fees apply to backfilled rates like any other.
|
||||
2. ``calculate_cost`` — cached tokens are billed at the cache rates from the
|
||||
model's sats_pricing; the full input rate remains only as the documented
|
||||
last resort when no cache rate could be resolved anywhere.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import litellm
|
||||
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, calculate_cost
|
||||
from routstr.payment.models import (
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
backfill_cache_pricing,
|
||||
)
|
||||
from routstr.upstream import GenericUpstreamProvider
|
||||
|
||||
|
||||
def _make_model(model_id: str, pricing: Pricing) -> Model:
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="",
|
||||
context_length=64000,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Other",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=pricing,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# backfill_cache_pricing — litellm as fallback source for missing cache rates
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_backfill_deepseek_cache_read_from_litellm() -> None:
|
||||
"""deepseek/deepseek-chat has no input_cache_read on OpenRouter; litellm
|
||||
knows the real rate (10x cheaper than input)."""
|
||||
pricing = Pricing(prompt=2.8e-07, completion=4.2e-07)
|
||||
|
||||
result = backfill_cache_pricing("deepseek/deepseek-chat", pricing)
|
||||
|
||||
expected = litellm.model_cost["deepseek/deepseek-chat"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert result.input_cache_read == expected
|
||||
assert result.input_cache_read < pricing.prompt # sanity: it's a discount
|
||||
|
||||
|
||||
def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None:
|
||||
"""OpenRouter ids are vendor-prefixed (openai/gpt-4o); litellm keys most
|
||||
non-DeepSeek models without the prefix (gpt-4o)."""
|
||||
pricing = Pricing(prompt=2.5e-06, completion=1e-05)
|
||||
|
||||
result = backfill_cache_pricing("openai/gpt-4o", pricing)
|
||||
|
||||
expected = litellm.model_cost["gpt-4o"]["cache_read_input_token_cost"]
|
||||
assert result.input_cache_read == expected
|
||||
|
||||
|
||||
def test_backfill_case_insensitive_lookup() -> None:
|
||||
"""A generic upstream may report a mixed-case id
|
||||
(deepseek-ai/DeepSeek-V4-Flash); litellm keys are lowercase. The
|
||||
case-insensitive fallback still resolves the cache rate."""
|
||||
pricing = Pricing(prompt=1.4e-07, completion=2.8e-07)
|
||||
|
||||
result = backfill_cache_pricing("deepseek-ai/DeepSeek-V4-Flash", pricing)
|
||||
|
||||
expected = litellm.model_cost["deepseek-v4-flash"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert result.input_cache_read == expected
|
||||
assert result.input_cache_read < pricing.prompt # sanity: it's a discount
|
||||
|
||||
|
||||
def test_backfill_fills_cache_write_rate() -> None:
|
||||
"""Anthropic cache writes cost more than input (1.25x); billing them at
|
||||
the input rate undercharges. litellm carries the write rate."""
|
||||
pricing = Pricing(prompt=3e-06, completion=1.5e-05)
|
||||
|
||||
result = backfill_cache_pricing("anthropic/claude-sonnet-4-5", pricing)
|
||||
|
||||
expected = litellm.model_cost["claude-sonnet-4-5"][
|
||||
"cache_creation_input_token_cost"
|
||||
]
|
||||
assert result.input_cache_write == expected
|
||||
assert result.input_cache_write > pricing.prompt # sanity: write premium
|
||||
|
||||
|
||||
def test_backfill_never_overwrites_openrouter_rates() -> None:
|
||||
"""When OpenRouter provides a cache rate, it is authoritative."""
|
||||
pricing = Pricing(
|
||||
prompt=2.1e-07, completion=7.9e-07, input_cache_read=1.3e-07
|
||||
)
|
||||
|
||||
result = backfill_cache_pricing("deepseek/deepseek-chat", pricing)
|
||||
|
||||
assert result.input_cache_read == 1.3e-07
|
||||
|
||||
|
||||
def test_backfill_unknown_model_unchanged() -> None:
|
||||
"""Models litellm doesn't know stay untouched (last-resort fallback to
|
||||
the input rate happens later, at billing time)."""
|
||||
pricing = Pricing(prompt=1e-06, completion=2e-06)
|
||||
|
||||
result = backfill_cache_pricing("artificial-dumbness/dumb-1", pricing)
|
||||
|
||||
assert result.input_cache_read == 0.0
|
||||
assert result.input_cache_write == 0.0
|
||||
|
||||
|
||||
def test_provider_fee_applies_to_backfilled_cache_rates() -> None:
|
||||
"""Backfill happens before the provider fee, so cache rates carry the
|
||||
same markup as every other price component."""
|
||||
provider = GenericUpstreamProvider(
|
||||
base_url="http://upstream.example", provider_fee=2.0
|
||||
)
|
||||
model = _make_model(
|
||||
"deepseek/deepseek-chat", Pricing(prompt=2.8e-07, completion=4.2e-07)
|
||||
)
|
||||
|
||||
adjusted = provider._apply_provider_fee_to_model(model)
|
||||
|
||||
litellm_read = litellm.model_cost["deepseek/deepseek-chat"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert adjusted.pricing.input_cache_read == pytest.approx(litellm_read * 2.0)
|
||||
assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0)
|
||||
|
||||
|
||||
def test_row_to_model_backfills_cache_rate() -> None:
|
||||
"""The DB-override path (admin-configured providers, e.g. a generic
|
||||
upstream) stores pricing without cache rates. ``_row_to_model`` must
|
||||
backfill them from litellm just like ``_apply_provider_fee_to_model``,
|
||||
otherwise cache reads bill at the full input rate."""
|
||||
import json
|
||||
|
||||
from routstr.core.db import ModelRow
|
||||
from routstr.payment.models import _row_to_model
|
||||
|
||||
row = ModelRow(
|
||||
id="deepseek-v4-flash",
|
||||
name="deepseek-v4-flash",
|
||||
created=0,
|
||||
description="",
|
||||
context_length=1000000,
|
||||
architecture=json.dumps(
|
||||
{
|
||||
"modality": "text",
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "unknown",
|
||||
"instruct_type": None,
|
||||
}
|
||||
),
|
||||
# Stored pricing omits input_cache_read (generic provider never sets it).
|
||||
pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}),
|
||||
enabled=True,
|
||||
upstream_provider_id=1,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"routstr.payment.models.sats_usd_price", return_value=5.0e-5
|
||||
):
|
||||
model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0)
|
||||
|
||||
litellm_read = litellm.model_cost["deepseek-v4-flash"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert model.pricing.input_cache_read == pytest.approx(litellm_read)
|
||||
assert model.pricing.input_cache_read < model.pricing.prompt # a discount
|
||||
assert model.sats_pricing is not None
|
||||
assert model.sats_pricing.input_cache_read > 0
|
||||
|
||||
|
||||
def test_row_to_model_backfills_via_forwarded_model_id() -> None:
|
||||
"""An alias row (id != forwarded_model_id) must backfill cache rates from
|
||||
the *forwarded* model name — the real upstream model litellm prices —
|
||||
not the alias id, which litellm doesn't know."""
|
||||
import json
|
||||
|
||||
from routstr.core.db import ModelRow
|
||||
from routstr.payment.models import _row_to_model
|
||||
|
||||
row = ModelRow(
|
||||
id="local-alias", # litellm has no such key
|
||||
name="local-alias",
|
||||
created=0,
|
||||
description="",
|
||||
context_length=1000000,
|
||||
architecture=json.dumps(
|
||||
{
|
||||
"modality": "text",
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "unknown",
|
||||
"instruct_type": None,
|
||||
}
|
||||
),
|
||||
pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}),
|
||||
enabled=True,
|
||||
upstream_provider_id=1,
|
||||
forwarded_model_id="deepseek-v4-flash",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"routstr.payment.models.sats_usd_price", return_value=5.0e-5
|
||||
):
|
||||
model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0)
|
||||
|
||||
litellm_read = litellm.model_cost["deepseek-v4-flash"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert model.pricing.input_cache_read == pytest.approx(litellm_read)
|
||||
assert model.pricing.input_cache_read < model.pricing.prompt
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# calculate_cost — cached tokens billed at cache rates
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sats_usd_price() -> None: # type: ignore[misc]
|
||||
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_pricing(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
"""Model-based pricing: 1 msat per input token, 2 per output token,
|
||||
0.1 per cache-read token, 1.25 per cache-write token."""
|
||||
monkeypatch.setattr(settings, "fixed_pricing", False)
|
||||
model = Mock()
|
||||
model.sats_pricing = Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.002,
|
||||
input_cache_read=0.0001,
|
||||
input_cache_write=0.00125,
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_cache_hits_billed_at_cache_rate(model_pricing: Mock) -> None:
|
||||
"""The reported overcharge scenario: a 10k-token prompt with 90% cache
|
||||
hits costs 2900 msats at honest rates, not the 11000 msats that billing
|
||||
every prompt token at the full input rate would charge."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("routstr.proxy.get_model_instance", return_value=model_pricing):
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 1000 input @ 1 msat + 9000 cache reads @ 0.1 msat + 500 output @ 2 msat.
|
||||
# input_msats folds the cache-read cost in (1000 + 900) so a dashboard
|
||||
# rendering I/O/T sees input + output == total; the cache portion stays
|
||||
# visible in cache_read_msats.
|
||||
assert result.cache_read_msats == 900
|
||||
assert result.output_msats == 1000
|
||||
assert result.input_msats == 1900
|
||||
assert result.input_msats + result.output_msats == result.total_msats
|
||||
assert result.total_msats == 2900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_write_billed_at_write_rate(model_pricing: Mock) -> None:
|
||||
"""Cache writes carry their premium rate (1.25x input here), instead of
|
||||
being silently billed at the plain input rate."""
|
||||
response = {
|
||||
"model": "claude-sonnet-4-5",
|
||||
"usage": {
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 100,
|
||||
"cache_read_input_tokens": 500,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("routstr.proxy.get_model_instance", return_value=model_pricing):
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 300 @ 1 + 500 @ 0.1 + 2000 @ 1.25 + 100 @ 2
|
||||
assert result.cache_read_msats == 50
|
||||
assert result.cache_creation_msats == 2500
|
||||
assert result.total_msats == 3050
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_cache_rate_falls_back_to_input_rate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Documented last resort: when no cache rate could be resolved anywhere
|
||||
(OpenRouter and litellm both silent), cache reads bill at the input rate —
|
||||
never cheaper, never free."""
|
||||
monkeypatch.setattr(settings, "fixed_pricing", False)
|
||||
model = Mock()
|
||||
model.sats_pricing = Pricing(prompt=0.001, completion=0.002)
|
||||
|
||||
response = {
|
||||
"model": "dumb-1",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("routstr.proxy.get_model_instance", return_value=model):
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 1000 @ 1 + 9000 @ 1 (fallback) + 500 @ 2
|
||||
assert result.total_msats == 11000
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Tests for cache token handling in cost calculation.
|
||||
|
||||
Covers OpenAI vs Anthropic caching formats, edge cases, and billing accuracy.
|
||||
Covers OpenAI, Anthropic and DeepSeek caching formats, dialect precedence,
|
||||
edge cases, and billing accuracy.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -16,12 +17,6 @@ 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."""
|
||||
@@ -41,7 +36,7 @@ def patch_sats_usd_price() -> None: # type: ignore[misc]
|
||||
# Test 1: OpenAI Cache Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
|
||||
async def test_openai_cache_subtraction() -> None:
|
||||
"""OpenAI includes cached_tokens in prompt_tokens, subtract them."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -53,7 +48,7 @@ async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # 2000 - 1000
|
||||
@@ -65,7 +60,7 @@ async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
|
||||
# Test 2: Anthropic Cache Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_anthropic_cache_additive(mock_fixed_pricing: None) -> None:
|
||||
"""Anthropic cache tokens are separate (additive) from input_tokens."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -76,7 +71,7 @@ async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pric
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500
|
||||
@@ -89,7 +84,7 @@ async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pric
|
||||
# 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:
|
||||
async def test_cache_read_exceeds_prompt_tokens(mock_fixed_pricing: None) -> None:
|
||||
"""Handle buggy upstream reporting cached > prompt_tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -101,7 +96,7 @@ async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fi
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
# Should not go negative
|
||||
assert isinstance(result, CostData)
|
||||
@@ -114,7 +109,7 @@ async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fi
|
||||
# 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:
|
||||
async def test_malformed_cache_tokens_coerce_to_zero(mock_fixed_pricing: None) -> None:
|
||||
"""Handle non-numeric cache token values."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -127,7 +122,7 @@ async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mo
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
# Both should coerce to 0
|
||||
assert isinstance(result, CostData)
|
||||
@@ -139,7 +134,7 @@ async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mo
|
||||
# Test 5: Anthropic Cache Not Subtracted
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_anthropic_cache_not_subtracted(mock_fixed_pricing: None) -> None:
|
||||
"""Anthropic cache fields should NOT be subtracted from input_tokens."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -149,7 +144,7 @@ async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixe
|
||||
"cache_read_input_tokens": 200, # ← Additive, don't subtract
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
# Anthropic: input_tokens stays as-is
|
||||
assert isinstance(result, CostData)
|
||||
@@ -161,7 +156,7 @@ async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixe
|
||||
# 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:
|
||||
async def test_only_cache_read_tokens(mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with only cache read tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -173,7 +168,7 @@ async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricin
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 0 # max(0, 0 - 1000)
|
||||
@@ -185,7 +180,7 @@ async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricin
|
||||
# Test 7: Only Cache Creation
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_only_cache_creation_tokens(mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with only cache creation tokens (Anthropic)."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -196,7 +191,7 @@ async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pr
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500
|
||||
@@ -209,7 +204,7 @@ async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pr
|
||||
# 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:
|
||||
async def test_both_cache_read_and_creation(mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with both cache read and creation."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -220,7 +215,7 @@ async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_
|
||||
"cache_read_input_tokens": 500,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 300
|
||||
@@ -233,7 +228,7 @@ async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_
|
||||
# Test 9: Token Field Fallback
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_token_field_fallback_order(mock_fixed_pricing: None) -> None:
|
||||
"""Verify fallback order for token extraction."""
|
||||
# When prompt_tokens is not present, fall back to input_tokens
|
||||
response = {
|
||||
@@ -243,7 +238,7 @@ async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pr
|
||||
"completion_tokens": 50,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 250
|
||||
@@ -254,20 +249,21 @@ async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pr
|
||||
# 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:
|
||||
async def test_float_token_values_coerced_to_int(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
|
||||
"prompt_tokens_details": {"cached_tokens": 25.9}, # Float
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 100 # Floored
|
||||
# cached_tokens are part of prompt_tokens (OpenAI dialect) → subtracted: 100 - 25
|
||||
assert result.input_tokens == 75 # Floored
|
||||
assert result.output_tokens == 50 # Floored
|
||||
assert result.cache_read_input_tokens == 25 # Floored
|
||||
|
||||
@@ -276,7 +272,7 @@ async def test_float_token_values_coerced_to_int(mock_session: AsyncMock, mock_f
|
||||
# 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:
|
||||
async def test_boolean_cache_tokens_coerced_to_zero(mock_fixed_pricing: None) -> None:
|
||||
"""Handle boolean cache token values by coercing to zero."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -286,7 +282,7 @@ async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, moc
|
||||
"cache_read_input_tokens": True, # Boolean
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0 # Boolean coerced to 0
|
||||
@@ -297,7 +293,7 @@ async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, moc
|
||||
# Test 12: Zero Cache Tokens
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_zero_cache_tokens(mock_fixed_pricing: None) -> None:
|
||||
"""Handle explicit zero cache tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -309,21 +305,174 @@ async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: No
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.input_tokens == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DeepSeek Cache Format
|
||||
# DeepSeek emits neither OpenAI's prompt_tokens_details nor Anthropic's
|
||||
# cache_read_input_tokens — only prompt_cache_hit_tokens and
|
||||
# prompt_cache_miss_tokens, with the documented guarantee
|
||||
# prompt_tokens = hit + miss. Hits are ~10x cheaper upstream, so billing
|
||||
# them as regular input is a large overcharge.
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_cache_hit_tokens_extracted() -> None:
|
||||
"""DeepSeek cache hits are extracted and removed from regular input.
|
||||
|
||||
Payload shape verbatim from the DeepSeek API reference (usage object).
|
||||
"""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000, # = hit + miss
|
||||
"completion_tokens": 500,
|
||||
"total_tokens": 10500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # only the cache misses
|
||||
assert result.cache_read_input_tokens == 9000
|
||||
assert result.output_tokens == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_all_tokens_cached() -> None:
|
||||
"""A fully cached DeepSeek prompt bills zero regular input tokens."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 5000,
|
||||
"completion_tokens": 100,
|
||||
"prompt_cache_hit_tokens": 5000,
|
||||
"prompt_cache_miss_tokens": 0,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 0
|
||||
assert result.cache_read_input_tokens == 5000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialect_precedence_never_double_subtracts() -> None:
|
||||
"""If a vendor emits both OpenAI-style and DeepSeek-style cache fields for
|
||||
the same cached tokens, they are counted once, not subtracted twice."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_tokens_details": {"cached_tokens": 9000},
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # 10000 - 9000, applied exactly once
|
||||
assert result.cache_read_input_tokens == 9000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
|
||||
"""Malformed DeepSeek cache fields degrade to billing all input at full
|
||||
rate instead of crashing or going negative."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 50,
|
||||
"prompt_cache_hit_tokens": "garbage",
|
||||
"prompt_cache_miss_tokens": -5,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000
|
||||
assert result.cache_read_input_tokens == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Truly-empty response with a non-zero USD cost → full refund
|
||||
#
|
||||
# When an upstream reports a USD cost but the response carries NO tokens at all
|
||||
# (input, output, cache-read and cache-creation all zero), billing the
|
||||
# USD-derived cost charges the user for nothing. Refund in full. The gate is
|
||||
# tightened relative to PR #489: a cache-read/-creation-only turn legitimately
|
||||
# reports zero prompt/completion tokens with a real cost and must still bill.
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_truly_empty_usd_cost_response_is_refunded(
|
||||
mock_fixed_pricing: None,
|
||||
) -> None:
|
||||
"""0 input + 0 output + 0 cache tokens with a non-zero USD cost → refund."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_cost": 0.01, # non-zero USD cost despite no tokens
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.total_msats == 0 # full refund
|
||||
assert result.input_msats == 0
|
||||
assert result.output_msats == 0
|
||||
assert result.total_usd == 0.0
|
||||
assert result.input_tokens == 0
|
||||
assert result.output_tokens == 0
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.cache_creation_input_tokens == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_read_only_usd_cost_response_is_billed(
|
||||
mock_fixed_pricing: None,
|
||||
) -> None:
|
||||
"""Cache-read-only turn (0 prompt/completion, non-zero cost) still bills."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 1000, # real cached usage
|
||||
"cache_creation_input_tokens": 0,
|
||||
"total_cost": 0.01, # non-zero USD cost
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# NOT refunded — the USD cost is billed in full. Pinning the exact value
|
||||
# guards against any future regression that would over-refund a cache-only
|
||||
# turn (the bug in PR #489, which refunded whenever prompt+completion == 0).
|
||||
assert result.total_msats == 200000
|
||||
assert result.total_usd == 0.01
|
||||
assert result.cache_read_input_tokens == 1000
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 13: Missing Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_missing_usage_block(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)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, MaxCostData)
|
||||
assert result.input_tokens == 0
|
||||
@@ -335,10 +484,10 @@ async def test_missing_usage_block(mock_session: AsyncMock, mock_fixed_pricing:
|
||||
# Test 14: Null Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
async def test_null_usage_block(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)
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, MaxCostData)
|
||||
assert result.input_tokens == 0
|
||||
|
||||
73
tests/unit/test_fetch_all_balances.py
Normal file
73
tests/unit/test_fetch_all_balances.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.wallet import fetch_all_balances
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session(): # type: ignore[no-untyped-def]
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
|
||||
proof = MagicMock(amount=proof_amount)
|
||||
return [
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
|
||||
patch(
|
||||
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||
MagicMock(return_value=[proof]),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.slow_filter_spend_proofs",
|
||||
AsyncMock(side_effect=lambda proofs, wallet: proofs),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.db.balances_for_mint_and_unit",
|
||||
AsyncMock(return_value=0),
|
||||
),
|
||||
patch("routstr.wallet.db.create_session", _fake_session),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
|
||||
"""With empty cashu_mints, balances are still fetched for primary_mint."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", []), patch.object(
|
||||
settings, "primary_mint", "http://primary:3338"
|
||||
):
|
||||
for p in _patches(proof_amount=1000):
|
||||
p.start()
|
||||
try:
|
||||
details, total_wallet, total_user, owner = await fetch_all_balances(
|
||||
units=["sat"]
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
|
||||
assert total_wallet == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
|
||||
"""primary_mint already in cashu_mints is not inspected twice."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(
|
||||
settings, "cashu_mints", ["http://primary:3338"]
|
||||
), patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
for p in _patches(proof_amount=1000):
|
||||
p.start()
|
||||
try:
|
||||
details, total_wallet, _total_user, _owner = await fetch_all_balances(
|
||||
units=["sat"]
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
|
||||
assert total_wallet == 1000
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
from typing import Any, AsyncIterator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
@@ -21,6 +22,7 @@ from routstr.core.db import ApiKey # noqa: E402
|
||||
from routstr.payment.cost_calculation import CostData # noqa: E402
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||
from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
@@ -500,7 +502,7 @@ async def test_streaming_emits_sse_and_reconciles_cost_at_end() -> None:
|
||||
captured_cost_call: dict[str, Any] = {}
|
||||
|
||||
async def fake_adjust(
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int, usage: Any = None
|
||||
) -> dict:
|
||||
captured_cost_call["combined_data"] = combined_data
|
||||
captured_cost_call["max_cost"] = max_cost
|
||||
@@ -589,7 +591,7 @@ async def test_streaming_handles_iterator_yielding_raw_sse_bytes() -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_adjust(
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int, usage: Any = None
|
||||
) -> dict:
|
||||
captured["combined_data"] = combined_data
|
||||
return fake_cost
|
||||
@@ -693,7 +695,6 @@ async def test_x_cashu_non_streaming_dispatches_and_refunds_overpaid_amount() ->
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
mint="https://mint.example",
|
||||
payment_token_hash="hash123",
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
@@ -972,7 +973,6 @@ async def test_forward_x_cashu_request_routes_messages_via_litellm() -> None:
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
mint="https://mint",
|
||||
payment_token_hash="h",
|
||||
)
|
||||
|
||||
mock_helper.assert_awaited_once()
|
||||
@@ -1041,7 +1041,6 @@ async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
mint="https://mint",
|
||||
payment_token_hash="h",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -1311,3 +1310,320 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N
|
||||
"fireworks_ai/accounts/fireworks/models/glm-5"
|
||||
)
|
||||
assert captured_kwargs["api_base"] == "https://api.fireworks.ai/inference/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# X-Cashu redemption error taxonomy (unreachable mint + string codes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
httpx.ConnectError("All connection attempts failed"),
|
||||
MintConnectionError("Cashu mint is unreachable"),
|
||||
TimeoutError("timed out connecting to mint"),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_mint_unreachable_returns_503(
|
||||
handler_name: str, error: Exception
|
||||
) -> None:
|
||||
"""Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable,
|
||||
not a generic 400 cashu_error."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "mint_unreachable"
|
||||
assert body["error"]["message"] == "Cashu mint is unreachable"
|
||||
assert body["error"]["code"] == "cashu_mint_unreachable"
|
||||
if str(error) != body["error"]["message"]:
|
||||
assert str(error) not in body["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"error",
|
||||
"expected_status",
|
||||
"expected_type",
|
||||
"expected_message",
|
||||
"expected_code",
|
||||
),
|
||||
[
|
||||
(
|
||||
ValueError("Mint Error: Token already spent"),
|
||||
400,
|
||||
"token_already_spent",
|
||||
"Cashu token already spent",
|
||||
"cashu_token_already_spent",
|
||||
),
|
||||
(
|
||||
ValueError("invalid token: could not decode"),
|
||||
400,
|
||||
"invalid_token",
|
||||
"Invalid Cashu token",
|
||||
"invalid_cashu_token",
|
||||
),
|
||||
(
|
||||
# Fee/swap failures now map to a granular 422 on the X-Cashu path,
|
||||
# matching the bearer path (previously flattened to 400).
|
||||
ValueError(
|
||||
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
),
|
||||
(
|
||||
ValueError("Failed to melt token from foreign mint http://m: boom"),
|
||||
422,
|
||||
"mint_error",
|
||||
"Failed to swap token from foreign mint",
|
||||
"cashu_foreign_mint_swap_failed",
|
||||
),
|
||||
(
|
||||
ValueError("some unexpected wallet condition"),
|
||||
400,
|
||||
"cashu_error",
|
||||
"Failed to redeem Cashu token",
|
||||
"cashu_token_redemption_failed",
|
||||
),
|
||||
(
|
||||
# Non-ValueError faults are internal errors (500), not token errors.
|
||||
RuntimeError("db exploded"),
|
||||
500,
|
||||
"api_error",
|
||||
"Internal error during token redemption",
|
||||
"internal_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_error_code_is_stable_string(
|
||||
handler_name: str,
|
||||
error: Exception,
|
||||
expected_status: int,
|
||||
expected_type: str,
|
||||
expected_message: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
"""X-Cashu emits a stable string ``code`` on every branch, matching the
|
||||
bearer path's taxonomy instead of an int HTTP status."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == expected_type
|
||||
assert body["error"]["message"] == expected_message
|
||||
assert body["error"]["code"] == expected_code
|
||||
assert isinstance(body["error"]["code"], str)
|
||||
if str(error) != expected_message:
|
||||
assert str(error) not in body["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "forward_attr"),
|
||||
[
|
||||
("handle_x_cashu", "forward_x_cashu_request"),
|
||||
("handle_x_cashu_responses", "forward_x_cashu_responses_request"),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_transport_error_after_redemption_is_not_retryable(
|
||||
handler_name: str, forward_attr: str
|
||||
) -> None:
|
||||
"""A transport failure while forwarding (after the token is spent) maps to
|
||||
502 upstream_error, never a retryable cashu_mint_unreachable."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.base.recieve_token",
|
||||
new=AsyncMock(return_value=(5_000, "sat", "https://mint")),
|
||||
),
|
||||
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
|
||||
patch.object(
|
||||
provider,
|
||||
forward_attr,
|
||||
new=AsyncMock(side_effect=httpx.ConnectError("upstream down")),
|
||||
),
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "upstream_error"
|
||||
assert body["error"]["code"] != "cashu_mint_unreachable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||
)
|
||||
async def test_x_cashu_token_consumed_returns_500_and_no_echo(
|
||||
handler_name: str,
|
||||
) -> None:
|
||||
"""A post-redemption failure (token spent, crediting/minting failed) is a
|
||||
non-retryable 500 token_consumed and must NOT echo the spent token back."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token",
|
||||
new=AsyncMock(side_effect=TokenConsumedError("credit failed")),
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "token_consumed"
|
||||
assert body["error"]["code"] == "cashu_token_consumed"
|
||||
assert "X-Cashu" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "error", "echoed"),
|
||||
[
|
||||
# Spent token: must NOT be echoed.
|
||||
("handle_x_cashu", ValueError("Token already spent"), False),
|
||||
("handle_x_cashu_responses", ValueError("Token already spent"), False),
|
||||
# Unspent but unreachable mint: echo so the client can retry the token.
|
||||
("handle_x_cashu", MintConnectionError("mint down"), True),
|
||||
("handle_x_cashu_responses", MintConnectionError("mint down"), True),
|
||||
# Consumed token (post-redemption): must NOT be echoed.
|
||||
("handle_x_cashu", TokenConsumedError("credit failed"), False),
|
||||
("handle_x_cashu_responses", TokenConsumedError("credit failed"), False),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_echoes_token_only_when_recoverable(
|
||||
handler_name: str, error: Exception, echoed: bool
|
||||
) -> None:
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
if echoed:
|
||||
assert response.headers.get("X-Cashu") == "cashuAtoken"
|
||||
else:
|
||||
assert "X-Cashu" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "forward_attr"),
|
||||
[
|
||||
("handle_x_cashu", "forward_x_cashu_request"),
|
||||
("handle_x_cashu_responses", "forward_x_cashu_responses_request"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("amount", [0, -5])
|
||||
async def test_x_cashu_zero_value_rejected_not_forwarded(
|
||||
handler_name: str, forward_attr: str, amount: int
|
||||
) -> None:
|
||||
"""A token that redeems to <= 0 must be rejected as cashu_token_zero_value
|
||||
(400) and NEVER forwarded as a free request — the X-Cashu path lacked the
|
||||
guard that credit_balance has."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.base.recieve_token",
|
||||
new=AsyncMock(return_value=(amount, "sat", "https://mint")),
|
||||
),
|
||||
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
|
||||
patch.object(
|
||||
provider,
|
||||
forward_attr,
|
||||
new=AsyncMock(side_effect=AssertionError("must not forward a zero-value token")),
|
||||
),
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "cashu_error"
|
||||
assert (
|
||||
body["error"]["message"]
|
||||
== "Failed to redeem Cashu token: token yielded no value"
|
||||
)
|
||||
assert body["error"]["code"] == "cashu_token_zero_value"
|
||||
# Spent-to-zero token must not be echoed back for retry.
|
||||
assert "X-Cashu" not in response.headers
|
||||
|
||||
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"
|
||||
55
tests/unit/test_provider_slug_migration.py
Normal file
55
tests/unit/test_provider_slug_migration.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "migrations"
|
||||
/ "versions"
|
||||
/ "c6d7e8f9a0b1_add_slug_to_upstream_providers.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("provider_slug_migration", _MIGRATION_PATH)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
migration = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(migration)
|
||||
|
||||
|
||||
def test_slug_migration_backfill_uses_api_safe_deterministic_slugs() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE TABLE upstream_providers ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"provider_type VARCHAR NOT NULL, "
|
||||
"slug VARCHAR NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO upstream_providers (id, provider_type, slug) VALUES "
|
||||
"(1, 'OpenAI Compatible', NULL), "
|
||||
"(2, 'OpenAI Compatible', ''), "
|
||||
"(3, '123', NULL), "
|
||||
"(4, 'x', NULL), "
|
||||
"(5, 'anthropic', 'anthropic')"
|
||||
)
|
||||
)
|
||||
|
||||
migration._backfill_provider_slugs(conn)
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, slug FROM upstream_providers ORDER BY id")
|
||||
).all()
|
||||
|
||||
assert rows == [
|
||||
(1, "openai-compatible"),
|
||||
(2, "openai-compatible-2"),
|
||||
(3, "provider-123"),
|
||||
(4, "x-provider"),
|
||||
(5, "anthropic"),
|
||||
]
|
||||
144
tests/unit/test_provider_slugs.py
Normal file
144
tests/unit/test_provider_slugs.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import _get_upstream_provider_by_ref
|
||||
from routstr.core.db import UpstreamProviderRow
|
||||
from routstr.core.provider_slugs import (
|
||||
allocate_unique_provider_slug,
|
||||
provider_slug_base,
|
||||
)
|
||||
from routstr.upstream.helpers import _seed_providers_from_settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allocate_unique_provider_slug_is_deterministic_with_suffixes() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(
|
||||
UpstreamProviderRow(
|
||||
slug="openai",
|
||||
provider_type="openai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="key-1",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await allocate_unique_provider_slug(session, "openai") == "openai-2"
|
||||
assert (
|
||||
await allocate_unique_provider_slug(session, "openai", {"openai-2"})
|
||||
== "openai-3"
|
||||
)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_provider_slug_base_sanitizes_provider_type() -> None:
|
||||
assert provider_slug_base("OpenAI Compatible") == "openai-compatible"
|
||||
assert provider_slug_base("!!!") == "provider"
|
||||
assert provider_slug_base("AI") == "ai-provider"
|
||||
assert provider_slug_base("123") == "provider-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_ref_lookup_accepts_existing_numeric_ids_and_slugs() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
provider = UpstreamProviderRow(
|
||||
slug="openai",
|
||||
provider_type="openai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="key-1",
|
||||
)
|
||||
session.add(provider)
|
||||
await session.commit()
|
||||
await session.refresh(provider)
|
||||
|
||||
by_id = await _get_upstream_provider_by_ref(session, str(provider.id))
|
||||
by_slug = await _get_upstream_provider_by_ref(session, "openai")
|
||||
|
||||
assert by_id.id == provider.id
|
||||
assert by_slug.id == provider.id
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_providers_from_settings_sets_deterministic_slug(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "seeded-openai-key")
|
||||
|
||||
class SettingsStub:
|
||||
chat_completions_api_version: str | None = None
|
||||
upstream_base_url: str | None = None
|
||||
upstream_api_key: str = ""
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type]
|
||||
await session.commit()
|
||||
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
providers: list[UpstreamProviderRow] = list(result.all())
|
||||
|
||||
assert [(p.provider_type, p.slug) for p in providers] == [("openai", "openai")]
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_providers_from_settings_keeps_slug_stable_on_reseed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "seeded-openai-key")
|
||||
|
||||
class SettingsStub:
|
||||
chat_completions_api_version: str | None = None
|
||||
upstream_base_url: str | None = None
|
||||
upstream_api_key: str = ""
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(
|
||||
UpstreamProviderRow(
|
||||
slug="openai",
|
||||
provider_type="openai",
|
||||
base_url="https://example.invalid/v1",
|
||||
api_key="other-key",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type]
|
||||
await session.commit()
|
||||
await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type]
|
||||
await session.commit()
|
||||
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).order_by(UpstreamProviderRow.slug)
|
||||
)
|
||||
providers: list[UpstreamProviderRow] = list(result.all())
|
||||
|
||||
assert [(p.provider_type, p.slug) for p in providers] == [
|
||||
("openai", "openai"),
|
||||
("openai", "openai-2"),
|
||||
]
|
||||
|
||||
await engine.dispose()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the built-in 404 handler in routstr.proxy."""
|
||||
"""Tests for the app-level 404 handler in routstr.core.main."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,18 +6,22 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routstr import proxy
|
||||
from routstr.proxy import proxy_router
|
||||
from routstr.core import main as core_main
|
||||
|
||||
|
||||
def _make_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(proxy_router)
|
||||
app.add_api_route(
|
||||
"/{path:path}",
|
||||
core_main.not_found_catch_all,
|
||||
methods=["GET", "POST"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
proxy._NOT_FOUND_HTML is None,
|
||||
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:
|
||||
@@ -41,24 +45,26 @@ def test_unknown_path_returns_json_404_for_api_client() -> None:
|
||||
assert "/some/random/page" in payload["error"]["message"]
|
||||
|
||||
|
||||
def test_root_path_returns_404_for_proxy_router() -> None:
|
||||
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_v1_path_is_not_intercepted_by_404_handler() -> None:
|
||||
"""Paths starting with v1/ must reach the proxy logic, not the 404 handler."""
|
||||
client = TestClient(_make_app(), raise_server_exceptions=False)
|
||||
response = client.get("/v1/anything")
|
||||
if response.status_code == 404:
|
||||
# Any 404 here must come from inner proxy logic, not our HTML page.
|
||||
assert "<!DOCTYPE html>" not in response.text
|
||||
|
||||
|
||||
def test_json_returned_when_ui_html_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(proxy, "_NOT_FOUND_HTML", 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"}
|
||||
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)
|
||||
411
tests/unit/test_streaming_sse_providers.py
Normal file
411
tests/unit/test_streaming_sse_providers.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crlf_delimiter_split_across_chunk_boundary() -> None:
|
||||
"""CRLF event delimiter straddling two TCP reads must not merge events.
|
||||
|
||||
Regression: a per-chunk ``replace(b"\\r\\n", b"\\n")`` left a stray ``\\r``
|
||||
when a ``\\r\\n`` of the ``\\r\\n\\r\\n`` delimiter landed at the very end of
|
||||
one ``aiter_bytes`` chunk and the matching ``\\n`` opened the next. The
|
||||
``\\n\\n`` split then missed the boundary, glued two events into one frame
|
||||
with two ``data:`` lines, and the client's ``JSON.parse`` threw on the
|
||||
concatenated payload (the "unexpected token"/"Extra data" crash).
|
||||
"""
|
||||
e1 = b'data: {"id":"x","choices":[{"delta":{"content":"a"}}]}'
|
||||
e2 = b'data: {"id":"x","choices":[{"delta":{"content":"b"}}]}'
|
||||
chunks = [
|
||||
e1 + b"\r\n\r", # delimiter cut mid-CRLF
|
||||
b"\n" + e2 + b"\r\n\r\n",
|
||||
b"data: [DONE]\r\n\r\n",
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
|
||||
# Client-accurate check: a real SSE client concatenates all ``data:`` lines
|
||||
# *within one event* (events are ``\n\n``-delimited) before parsing. A
|
||||
# merged frame would surface here as two objects glued into one payload,
|
||||
# which ``_assert_clean`` (per-line) would miss.
|
||||
blob = b"".join(out)
|
||||
contents: list[str] = []
|
||||
for event in blob.split(b"\n\n"):
|
||||
datas = [
|
||||
ln[len(b"data: ") :]
|
||||
for ln in event.split(b"\n")
|
||||
if ln.startswith(b"data: ")
|
||||
]
|
||||
if not datas:
|
||||
continue
|
||||
payload = b"".join(datas)
|
||||
if payload.strip() == b"[DONE]":
|
||||
continue
|
||||
obj = json.loads(payload) # raises if two events were merged into one
|
||||
for c in obj.get("choices", []):
|
||||
if "delta" in c:
|
||||
contents.append(c["delta"]["content"])
|
||||
assert contents == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncated_json_tail_on_connection_close() -> None:
|
||||
"""A stream that drops mid-event must not emit the partial JSON downstream.
|
||||
|
||||
Regression: the end-of-stream flush ran ``_process_event`` on the leftover
|
||||
buffer unconditionally. When the upstream connection closed mid-event the
|
||||
leftover was incomplete JSON, which fell through to the raw-forward path and
|
||||
handed the client a ``data: {partial`` frame -> ``Unterminated string`` parse
|
||||
error. The truncated tail must be dropped instead.
|
||||
"""
|
||||
chunks = [
|
||||
b'data: {"id":"x","choices":[{"delta":{"content":"ok"}}]}\n\n',
|
||||
b'data: {"id":"x","choices":[{"delta":{"con', # connection dies here
|
||||
]
|
||||
out = await _drive(chunks)
|
||||
objs = _assert_clean(out) # raises if the partial tail leaked as a data frame
|
||||
contents = [
|
||||
c["delta"]["content"]
|
||||
for o in objs
|
||||
for c in o.get("choices", [])
|
||||
if "delta" in c
|
||||
]
|
||||
# The one complete chunk is delivered; the truncated fragment is dropped
|
||||
# entirely (no second delta), and _assert_clean above guarantees nothing
|
||||
# non-JSON ever reached the client.
|
||||
assert contents == ["ok"]
|
||||
524
tests/unit/test_upstream_generic.py
Normal file
524
tests/unit/test_upstream_generic.py
Normal file
@@ -0,0 +1,524 @@
|
||||
"""Unit tests for ``GenericUpstreamProvider.fetch_models`` price/metadata resolution.
|
||||
|
||||
A generic upstream is any OpenAI-compatible API. Most (DeepSeek, OpenAI,
|
||||
Groq, ...) answer ``/models`` with bare ``{id, object, owned_by}`` entries that
|
||||
carry *no* pricing. The provider must not fabricate a price for those: it
|
||||
resolves through native ``model_spec`` (Venice's bespoke schema) → litellm's
|
||||
bundled cost map → the OpenRouter feed, and only when every source misses does
|
||||
it import the model **disabled** with a warning rather than invent a number.
|
||||
|
||||
These tests drive that behaviour through the public ``fetch_models`` API. The
|
||||
``/models`` HTTP call is faked at ``httpx.AsyncClient``; the OpenRouter feed is
|
||||
patched at its source (``routstr.payment.models.async_fetch_openrouter_models``)
|
||||
so the resolver's lazy import picks up the stub. litellm's real bundled cost map
|
||||
is used unmocked — the DeepSeek rates it ships are the assertion's ground truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.generic import GenericUpstreamProvider
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Stand-in for ``httpx.AsyncClient`` returning a canned ``/models`` body."""
|
||||
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
async def get(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
|
||||
return _FakeResponse(self._payload)
|
||||
|
||||
|
||||
def _patch_models_endpoint(payload: dict[str, Any]) -> Any:
|
||||
"""Patch the provider's ``httpx.AsyncClient`` to serve ``payload``."""
|
||||
return patch(
|
||||
"routstr.upstream.generic.httpx.AsyncClient",
|
||||
lambda *args, **kwargs: _FakeAsyncClient(payload),
|
||||
)
|
||||
|
||||
|
||||
def _model_by_id(models: list[Any], model_id: str) -> Any:
|
||||
return next(m for m in models if m.id == model_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# native model_spec (Venice) — must keep resolving, and capture its metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_model_spec_resolves_and_captures_metadata() -> None:
|
||||
"""A Venice-shaped ``model_spec`` is authoritative: prices/context come
|
||||
straight from it and vision capability becomes an image input modality."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "venice-llama",
|
||||
"owned_by": "venice",
|
||||
"model_spec": {
|
||||
"name": "Venice Llama",
|
||||
"availableContextTokens": 65536,
|
||||
"pricing": {
|
||||
"input": {"usd": 0.5},
|
||||
"output": {"usd": 1.5},
|
||||
},
|
||||
"capabilities": {"supportsVision": True},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "venice-llama")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(0.5 / 1_000_000)
|
||||
assert model.pricing.completion == pytest.approx(1.5 / 1_000_000)
|
||||
assert model.context_length == 65536
|
||||
assert "image" in model.architecture.input_modalities
|
||||
# Vision capability must be reflected in the combined modality string, not
|
||||
# flattened to "text->text".
|
||||
assert model.architecture.modality == "text+image->text"
|
||||
# A native price never needs the OpenRouter feed.
|
||||
or_feed.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# native model_spec validation — a bogus native price is not authoritative
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_both_zero_price_falls_through_to_litellm() -> None:
|
||||
"""A native ``model_spec`` that prices both tokens at 0 is not a real price
|
||||
(the same free-tier trap the litellm/OpenRouter rungs already reject). It
|
||||
must not be treated as authoritative and served free; the resolver falls
|
||||
through, so a litellm-known model lands on litellm's real rate instead."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "deepseek-chat",
|
||||
"owned_by": "deepseek",
|
||||
"model_spec": {
|
||||
"pricing": {"input": {"usd": 0}, "output": {"usd": 0}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "deepseek-chat")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(2.8e-07)
|
||||
assert model.pricing.completion == pytest.approx(4.2e-07)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_negative_price_falls_through_to_litellm() -> None:
|
||||
"""A negative native price would credit the caller's balance on every
|
||||
request (a fund drain, not a discount). Reject it like any other unusable
|
||||
price and fall through to the chain."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "deepseek-chat",
|
||||
"owned_by": "deepseek",
|
||||
"model_spec": {
|
||||
"pricing": {"input": {"usd": -0.5}, "output": {"usd": -1.5}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "deepseek-chat")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(2.8e-07)
|
||||
assert model.pricing.completion == pytest.approx(4.2e-07)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_non_numeric_price_does_not_break_catalog() -> None:
|
||||
"""A non-numeric native price (``"free"``) must not raise while parsing —
|
||||
an unguarded ``"free" / 1_000_000`` throws and the outer catch drops the
|
||||
*entire* provider catalog. It has to fail closed for that one model while
|
||||
every other model in the same response still resolves."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "broken-price",
|
||||
"owned_by": "mystery",
|
||||
"model_spec": {
|
||||
"pricing": {"input": {"usd": "free"}, "output": {"usd": "free"}},
|
||||
},
|
||||
},
|
||||
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
# One malformed entry must not empty the catalog.
|
||||
assert {m.id for m in models} == {"broken-price", "deepseek-chat"}
|
||||
broken = _model_by_id(models, "broken-price")
|
||||
assert broken.enabled is False
|
||||
healthy = _model_by_id(models, "deepseek-chat")
|
||||
assert healthy.enabled is True
|
||||
assert healthy.pricing.prompt == pytest.approx(2.8e-07)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# litellm rescue — the money-critical case (DeepSeek bare /models)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_deepseek_resolves_via_litellm() -> None:
|
||||
"""DeepSeek's ``/models`` carries no pricing. The old code fabricated
|
||||
``$0.001`` + ctx 4096; the resolver must instead pull DeepSeek's real
|
||||
rates from litellm's bundled cost map (``$0.28``/``$0.42`` per 1M, ctx
|
||||
131072) and keep the model enabled."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "deepseek-chat")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(2.8e-07)
|
||||
assert model.pricing.completion == pytest.approx(4.2e-07)
|
||||
assert model.context_length == 131072
|
||||
# Richer metadata than the two base prices is captured too.
|
||||
assert model.pricing.input_cache_read == pytest.approx(2.8e-08)
|
||||
assert model.top_provider is not None
|
||||
assert model.top_provider.max_completion_tokens == 8192
|
||||
# litellm answered, so the OpenRouter feed is never consulted.
|
||||
or_feed.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_zero_price_entry_fails_closed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A litellm entry that lists a model but prices it at 0/0 (free-tier
|
||||
moderation/rerank models do this) is not a real price — treating it as one
|
||||
would silently serve the model for free. The resolver must reject a both-zero
|
||||
litellm hit and fall through, so the model imports disabled, not at $0."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "omni-moderation-latest", "object": "model", "owned_by": "openai"},
|
||||
]
|
||||
}
|
||||
|
||||
gen_logger = logging.getLogger("routstr.upstream.generic")
|
||||
gen_logger.addHandler(caplog.handler)
|
||||
try:
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(
|
||||
base_url="http://x"
|
||||
).fetch_models()
|
||||
finally:
|
||||
gen_logger.removeHandler(caplog.handler)
|
||||
|
||||
model = _model_by_id(models, "omni-moderation-latest")
|
||||
assert model.enabled is False
|
||||
assert model.pricing.prompt == 0.0
|
||||
assert model.pricing.completion == 0.0
|
||||
assert any(
|
||||
"omni-moderation-latest" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
if rec.levelno >= logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_output_cap_not_used_as_context() -> None:
|
||||
"""litellm's ``max_tokens`` is the completion cap, not the context window
|
||||
(it tracks ``max_output_tokens`` for ~94% of models). When a model reports
|
||||
no ``max_input_tokens``, the resolver must not smuggle the output cap in as
|
||||
the context window; it falls back to the id-based estimate instead, while
|
||||
``max_tokens`` still feeds the completion limit."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "gemini/gemini-gemma-2-9b-it",
|
||||
"object": "model",
|
||||
"owned_by": "google",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "gemini/gemini-gemma-2-9b-it")
|
||||
assert model.enabled is True
|
||||
# litellm gives this model max_input_tokens=None, max_tokens=8192 (an output
|
||||
# cap). Context must come from the estimate (4096), never the 8192 cap.
|
||||
assert model.context_length == 4096
|
||||
# The 8192 output cap still lands where it belongs: the completion limit.
|
||||
assert model.top_provider is not None
|
||||
assert model.top_provider.max_completion_tokens == 8192
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenRouter fallback — litellm misses, OR carries a full payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_to_litellm_resolves_via_openrouter() -> None:
|
||||
"""A model litellm has never heard of still resolves if the OpenRouter
|
||||
feed lists it, pulling price + context from that entry."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "exotic/model-9000", "object": "model", "owned_by": "exotic"},
|
||||
]
|
||||
}
|
||||
or_entry = {
|
||||
"id": "exotic/model-9000",
|
||||
"name": "Exotic 9000",
|
||||
"context_length": 65536,
|
||||
"architecture": {
|
||||
"modality": "text+image->text",
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "Other",
|
||||
"instruct_type": None,
|
||||
},
|
||||
"pricing": {"prompt": "0.000005", "completion": "0.000010"},
|
||||
"top_provider": {
|
||||
"context_length": 65536,
|
||||
"max_completion_tokens": 4096,
|
||||
"is_moderated": False,
|
||||
},
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[or_entry])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "exotic/model-9000")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(5e-06)
|
||||
assert model.pricing.completion == pytest.approx(1e-05)
|
||||
assert model.context_length == 65536
|
||||
# The feed's own modality string is carried through verbatim, not recomputed.
|
||||
assert model.architecture.modality == "text+image->text"
|
||||
or_feed.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_bare_tail_collision_picks_highest_price() -> None:
|
||||
"""When a bare model id matches several OpenRouter entries by tail
|
||||
(``model`` ↔ ``a/model``, ``b/model``), the match must be deterministic and
|
||||
money-safe: pick the highest-priced candidate regardless of feed order, so
|
||||
ordering can never leave the node charging below true cost. (The live feed
|
||||
has zero such collisions today; this guards the latent case.)"""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "zzz-phantom-model", "object": "model", "owned_by": "mystery"},
|
||||
]
|
||||
}
|
||||
# Same bare tail, different resellers; the pricier one is listed *second*
|
||||
# so a first-wins match would pick the cheaper (undercharging) entry.
|
||||
or_feed = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "cheapco/zzz-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
|
||||
},
|
||||
{
|
||||
"id": "premiumco/zzz-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000009", "completion": "0.000010"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "zzz-phantom-model")
|
||||
assert model.pricing.prompt == pytest.approx(9e-06)
|
||||
assert model.pricing.completion == pytest.approx(1e-05)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_bare_tail_tie_breaks_on_combined_cost() -> None:
|
||||
"""The bare-tail tie-break must weigh *both* rates, not prompt alone.
|
||||
Given two colliding entries where one is cheaper on prompt but far dearer
|
||||
on completion, ranking by prompt would pick the entry that undercharges
|
||||
output-heavy traffic. Pick the highest *combined* per-token cost so the
|
||||
money-safe choice holds whichever way the traffic leans."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "yyy-phantom-model", "object": "model", "owned_by": "mystery"},
|
||||
]
|
||||
}
|
||||
# dear-overall is listed first with the *lower* prompt, so a prompt-only max
|
||||
# would wrongly pick the second (cheaper-overall) entry.
|
||||
or_feed = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "dearco/yyy-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000100"},
|
||||
},
|
||||
{
|
||||
"id": "cheapco/yyy-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000009", "completion": "0.000002"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "yyy-phantom-model")
|
||||
assert model.pricing.prompt == pytest.approx(1e-06)
|
||||
assert model.pricing.completion == pytest.approx(1e-04)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_feed_fetched_once_per_discovery() -> None:
|
||||
"""Two models both missing litellm must share a single OpenRouter fetch —
|
||||
the feed is not re-downloaded per model."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "exotic/model-a", "object": "model", "owned_by": "exotic"},
|
||||
{"id": "exotic/model-b", "object": "model", "owned_by": "exotic"},
|
||||
]
|
||||
}
|
||||
or_feed = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "exotic/model-a",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
|
||||
},
|
||||
{
|
||||
"id": "exotic/model-b",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000003", "completion": "0.000004"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
assert {m.id for m in models} == {"exotic/model-a", "exotic/model-b"}
|
||||
assert or_feed.await_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fail closed — no source resolves → disabled + warned, never fabricated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolvable_model_fails_closed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""When native, litellm and OpenRouter all miss, the model is imported
|
||||
disabled with a warning naming it — and no price is invented (the old
|
||||
``$0.001`` placeholder is gone)."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "nobody-has-priced-this-xyz",
|
||||
"object": "model",
|
||||
"owned_by": "mystery",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# routstr loggers set propagate=False, so caplog's root handler misses
|
||||
# them; attach its handler to the provider logger directly.
|
||||
gen_logger = logging.getLogger("routstr.upstream.generic")
|
||||
gen_logger.addHandler(caplog.handler)
|
||||
try:
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(
|
||||
base_url="http://x"
|
||||
).fetch_models()
|
||||
finally:
|
||||
gen_logger.removeHandler(caplog.handler)
|
||||
|
||||
model = _model_by_id(models, "nobody-has-priced-this-xyz")
|
||||
assert model.enabled is False
|
||||
assert model.pricing.prompt == 0.0
|
||||
assert model.pricing.completion == 0.0
|
||||
assert any(
|
||||
"nobody-has-priced-this-xyz" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
if rec.levelno >= logging.WARNING
|
||||
)
|
||||
396
tests/unit/test_upstream_rate_limit.py
Normal file
396
tests/unit/test_upstream_rate_limit.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Tests for upstream rate-limit detection, classification, and org-ID redaction.
|
||||
|
||||
Covers issue #555: upstream OpenAI-compatible providers return rate-limit
|
||||
errors that embed a sensitive organization ID. The proxy must classify these
|
||||
distinctly (``UPSTREAM_RATE_LIMIT``), preserve useful debugging fields, and
|
||||
never emit a raw ``org-*`` identifier in logs, errors, or returned bodies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.core.redaction import redact_org_ids
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
from routstr.upstream.rate_limit import (
|
||||
UPSTREAM_RATE_LIMIT,
|
||||
RateLimitInfo,
|
||||
classify_rate_limit,
|
||||
)
|
||||
|
||||
# The exact scenario from the issue, with a realistic (fake) org identifier.
|
||||
RAW_ORG_ID = "org-abc123XYZ456def"
|
||||
RATE_LIMIT_MESSAGE = (
|
||||
f"Rate limit reached for gpt-5.5-2026-04-23 (for limit gpt-5.5) in "
|
||||
f"organization {RAW_ORG_ID} on tokens per min (TPM): Limit 180000000, "
|
||||
f"Used 180000000, Requested 8929. Please try again in 2ms. Visit "
|
||||
f"https://platform.openai.com/account/rate-limits to learn more."
|
||||
)
|
||||
|
||||
|
||||
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 = 429,
|
||||
content_type: str | None = "application/json",
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Redaction
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_redact_org_ids_replaces_identifier() -> None:
|
||||
assert RAW_ORG_ID not in redact_org_ids(RATE_LIMIT_MESSAGE)
|
||||
assert "org-[REDACTED]" in redact_org_ids(RATE_LIMIT_MESSAGE)
|
||||
|
||||
|
||||
def test_redact_org_ids_is_idempotent() -> None:
|
||||
once = redact_org_ids(RATE_LIMIT_MESSAGE)
|
||||
assert redact_org_ids(once) == once
|
||||
|
||||
|
||||
def test_redact_org_ids_leaves_unrelated_text() -> None:
|
||||
assert redact_org_ids("organize the org-chart") == "organize the org-chart"
|
||||
assert redact_org_ids("") == ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Classification
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_classify_exact_scenario() -> None:
|
||||
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE)
|
||||
assert isinstance(info, RateLimitInfo)
|
||||
assert info.code == UPSTREAM_RATE_LIMIT
|
||||
assert info.model == "gpt-5.5-2026-04-23"
|
||||
assert info.limit_name == "gpt-5.5"
|
||||
assert info.metric == "tokens per min (TPM)"
|
||||
assert info.limit == 180000000
|
||||
assert info.used == 180000000
|
||||
assert info.requested == 8929
|
||||
assert info.retry_after_seconds == pytest.approx(0.002)
|
||||
# Redaction-safe: no raw org id survives into the structured view.
|
||||
assert RAW_ORG_ID not in info.message
|
||||
assert RAW_ORG_ID not in json.dumps(info.as_details())
|
||||
|
||||
|
||||
def test_classify_by_status_code_without_marker() -> None:
|
||||
info = classify_rate_limit(429, "slow down")
|
||||
assert info is not None
|
||||
assert info.code == UPSTREAM_RATE_LIMIT
|
||||
|
||||
|
||||
def test_classify_by_message_marker_without_429() -> None:
|
||||
info = classify_rate_limit(400, "rate_limit_exceeded for this key")
|
||||
assert info is not None
|
||||
|
||||
|
||||
def test_retry_after_header_takes_precedence() -> None:
|
||||
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE, {"Retry-After": "12"})
|
||||
assert info is not None
|
||||
assert info.retry_after_seconds == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_non_rate_limit_error_is_not_classified() -> None:
|
||||
assert classify_rate_limit(400, "invalid request: missing field 'model'") is None
|
||||
assert classify_rate_limit(500, "internal server error") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# forward_upstream_error_response integration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_rate_limit_body_is_redacted_and_forwarded(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
body = json.dumps(
|
||||
{"error": {"message": RATE_LIMIT_MESSAGE, "type": "rate_limit_exceeded"}}
|
||||
).encode()
|
||||
upstream = _make_upstream_response(body=body, status_code=429)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/chat/completions", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
raw = bytes(response.body).decode()
|
||||
# No raw organization id may survive in the forwarded body.
|
||||
assert RAW_ORG_ID not in raw
|
||||
assert "org-[REDACTED]" in raw
|
||||
# Body remains valid JSON; the original type is preserved while a stable
|
||||
# rate-limit code is injected so callers can switch on it.
|
||||
payload: dict[str, Any] = json.loads(raw)
|
||||
assert payload["error"]["type"] == "rate_limit_exceeded"
|
||||
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
|
||||
assert payload["error"]["details"]["model"] == "gpt-5.5-2026-04-23"
|
||||
# A retry hint extracted from the message is surfaced as a header.
|
||||
assert "retry-after" in {k.lower() for k in response.headers}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_json_rate_limit_envelope_uses_stable_code(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
upstream = _make_upstream_response(
|
||||
body=RATE_LIMIT_MESSAGE.encode(),
|
||||
status_code=429,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/chat/completions", upstream
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
|
||||
assert payload["error"]["details"]["model"] == "gpt-5.5-2026-04-23"
|
||||
serialized = json.dumps(payload)
|
||||
assert RAW_ORG_ID not in serialized
|
||||
assert "org-[REDACTED]" in serialized
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_rate_limit_json_error_unchanged(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
body = json.dumps(
|
||||
{"error": {"message": "missing field 'model'", "type": "invalid_request"}}
|
||||
).encode()
|
||||
upstream = _make_upstream_response(body=body, status_code=400)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/chat/completions", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["type"] == "invalid_request"
|
||||
assert "retry-after" not in {k.lower() for k in response.headers}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UpstreamError -> proxy response (preserves code/details/status)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_create_upstream_error_response_preserves_structure() -> None:
|
||||
from routstr.core.exceptions import UpstreamError
|
||||
from routstr.payment.helpers import create_upstream_error_response
|
||||
|
||||
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE)
|
||||
assert info is not None
|
||||
err = UpstreamError(
|
||||
f"Upstream error via litellm: {RATE_LIMIT_MESSAGE}",
|
||||
status_code=429,
|
||||
code=info.code,
|
||||
details=info.as_details(),
|
||||
)
|
||||
|
||||
response = create_upstream_error_response(err, _make_request())
|
||||
|
||||
# Original upstream status is preserved (not flattened to 502).
|
||||
assert response.status_code == 429
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["type"] == "upstream_error"
|
||||
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
|
||||
assert payload["error"]["details"]["requested"] == 8929
|
||||
serialized = json.dumps(payload)
|
||||
assert RAW_ORG_ID not in serialized
|
||||
assert "org-[REDACTED]" in serialized
|
||||
|
||||
|
||||
def test_generic_upstream_error_still_defaults_to_502() -> None:
|
||||
from routstr.core.exceptions import UpstreamError
|
||||
from routstr.payment.helpers import create_upstream_error_response
|
||||
|
||||
err = UpstreamError("connection refused") # status_code defaults to 502
|
||||
|
||||
response = create_upstream_error_response(err, _make_request())
|
||||
|
||||
assert response.status_code == 502
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["type"] == "upstream_error"
|
||||
assert payload["error"]["code"] == 502
|
||||
assert "details" not in payload["error"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Structured log-extra redaction
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_security_filter_redacts_org_id_in_extra() -> None:
|
||||
import logging
|
||||
|
||||
from routstr.core.logging import SecurityFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.ERROR,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg="upstream failed",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
# Simulate an ``extra={"body_preview": ...}`` field carrying an org id.
|
||||
setattr(record, "body_preview", RATE_LIMIT_MESSAGE)
|
||||
|
||||
assert SecurityFilter().filter(record) is True
|
||||
redacted: str = getattr(record, "body_preview")
|
||||
assert RAW_ORG_ID not in redacted
|
||||
assert "org-[REDACTED]" in redacted
|
||||
|
||||
|
||||
def test_security_filter_redacts_org_id_in_nested_extra() -> None:
|
||||
import logging
|
||||
|
||||
from routstr.core.logging import SecurityFilter
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.ERROR,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg="upstream failed",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
# Nested structures: dict containing a list containing the org id.
|
||||
setattr(record, "body", {"error": {"messages": [RATE_LIMIT_MESSAGE]}})
|
||||
|
||||
assert SecurityFilter().filter(record) is True
|
||||
serialized = json.dumps(getattr(record, "body"))
|
||||
assert RAW_ORG_ID not in serialized
|
||||
assert "org-[REDACTED]" in serialized
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5xx-wrapped rate limit through forward_upstream_error_response
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_5xx_wrapped_rate_limit_is_classified(
|
||||
provider: BaseUpstreamProvider,
|
||||
) -> None:
|
||||
# Some providers wrap a rate-limit in a 5xx envelope; classification must
|
||||
# key off the message marker, not only the 429 status.
|
||||
body = json.dumps({"error": {"message": RATE_LIMIT_MESSAGE}}).encode()
|
||||
upstream = _make_upstream_response(body=body, status_code=500)
|
||||
|
||||
response = await provider.forward_upstream_error_response(
|
||||
_make_request(), "v1/chat/completions", upstream
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
|
||||
serialized = json.dumps(payload)
|
||||
assert RAW_ORG_ID not in serialized
|
||||
assert "org-[REDACTED]" in serialized
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Real proxy loop: structured error surfaced + reservation reverted once
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_loop_surfaces_rate_limit_and_reverts_once() -> None:
|
||||
from routstr import proxy as proxy_module
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.exceptions import UpstreamError
|
||||
|
||||
info = classify_rate_limit(429, RATE_LIMIT_MESSAGE)
|
||||
assert info is not None
|
||||
|
||||
key = ApiKey(hashed_key="rlkey", balance=10_000)
|
||||
|
||||
request = MagicMock()
|
||||
request.method = "POST"
|
||||
request.headers = {"authorization": "Bearer sk-rlkey"}
|
||||
request.body = AsyncMock(return_value=b'{"model": "test-model"}')
|
||||
request.state = MagicMock()
|
||||
request.state.request_id = "req-rl"
|
||||
|
||||
upstream = MagicMock()
|
||||
upstream.provider_type = "test"
|
||||
upstream.prepare_headers = MagicMock(side_effect=lambda h: h)
|
||||
upstream.forward_request = AsyncMock(
|
||||
side_effect=UpstreamError(
|
||||
f"Upstream error via litellm: {RATE_LIMIT_MESSAGE}",
|
||||
status_code=429,
|
||||
code=info.code,
|
||||
details=info.as_details(),
|
||||
)
|
||||
)
|
||||
|
||||
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),
|
||||
):
|
||||
response = await proxy_module.proxy(
|
||||
request, "v1/chat/completions", session=session
|
||||
)
|
||||
|
||||
# Original 429 status and the stable code/details survive to the client.
|
||||
assert response.status_code == 429
|
||||
payload: dict[str, Any] = json.loads(bytes(response.body))
|
||||
assert payload["error"]["type"] == "upstream_error"
|
||||
assert payload["error"]["code"] == UPSTREAM_RATE_LIMIT
|
||||
assert payload["error"]["details"]["requested"] == 8929
|
||||
serialized = json.dumps(payload)
|
||||
assert RAW_ORG_ID not in serialized
|
||||
assert "org-[REDACTED]" in serialized
|
||||
# Single upstream failed -> reservation reverted exactly once (no double-charge).
|
||||
revert_mock.assert_awaited_once_with(key, session, 1_000)
|
||||
140
tests/unit/test_usage_normalization.py
Normal file
140
tests/unit/test_usage_normalization.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for vendor-agnostic usage normalization.
|
||||
|
||||
Specifies the seam that keeps vendor usage dialects out of generic billing
|
||||
code: a canonical ``NormalizedUsage`` shape produced by
|
||||
``routstr.payment.usage.normalize_usage`` (union parser for the known,
|
||||
non-colliding dialects). ``calculate_cost`` normalizes the response's usage
|
||||
object with this parser and needs no vendor knowledge of its own.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.payment.usage import NormalizedUsage, normalize_usage
|
||||
|
||||
# ============================================================================
|
||||
# The union parser: one canonical shape for all known dialects
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"usage,expected",
|
||||
[
|
||||
# OpenAI: cached_tokens included in prompt_tokens → subtracted
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 100,
|
||||
"prompt_tokens_details": {"cached_tokens": 800},
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=1200,
|
||||
output_tokens=100,
|
||||
cache_read_tokens=800,
|
||||
cache_write_tokens=0,
|
||||
),
|
||||
),
|
||||
# DeepSeek: hit/miss fields, prompt_tokens = hit + miss → hit subtracted
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
cache_read_tokens=9000,
|
||||
cache_write_tokens=0,
|
||||
),
|
||||
),
|
||||
# Anthropic: cache fields additive, input_tokens NOT reduced
|
||||
(
|
||||
{
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 100,
|
||||
"cache_read_input_tokens": 500,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=300,
|
||||
output_tokens=100,
|
||||
cache_read_tokens=500,
|
||||
cache_write_tokens=2000,
|
||||
),
|
||||
),
|
||||
# Plain OpenAI without caching
|
||||
(
|
||||
{"prompt_tokens": 100, "completion_tokens": 50},
|
||||
NormalizedUsage(input_tokens=100, output_tokens=50),
|
||||
),
|
||||
# OpenRouter: cache writes nested as prompt_tokens_details.cache_write_tokens,
|
||||
# both reads and writes included in prompt_tokens → both subtracted
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 60,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 5000,
|
||||
"cache_write_tokens": 2000,
|
||||
},
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=3000,
|
||||
output_tokens=60,
|
||||
cache_read_tokens=5000,
|
||||
cache_write_tokens=2000,
|
||||
),
|
||||
),
|
||||
# litellm-normalized Anthropic: prompt_tokens is the grand total and the
|
||||
# write field is named cache_creation_tokens; top-level fields mirror it.
|
||||
# prompt_tokens present → both subtracted (NOT additive like native).
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 100,
|
||||
"cache_read_input_tokens": 5000,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 5000,
|
||||
"cache_creation_tokens": 2000,
|
||||
},
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=3000,
|
||||
output_tokens=100,
|
||||
cache_read_tokens=5000,
|
||||
cache_write_tokens=2000,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_usage_dialects(usage: dict, expected: NormalizedUsage) -> None:
|
||||
"""Each known vendor dialect maps onto the same canonical shape."""
|
||||
assert normalize_usage(usage) == expected
|
||||
|
||||
|
||||
def test_normalize_usage_absent_usage() -> None:
|
||||
"""Missing/invalid usage yields None so callers can bill at max cost."""
|
||||
assert normalize_usage(None) is None
|
||||
assert normalize_usage("not a dict") is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_normalize_usage_never_negative() -> None:
|
||||
"""Buggy upstreams reporting more cached than prompt tokens clamp to 0."""
|
||||
result = normalize_usage(
|
||||
{
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_cache_hit_tokens": 150,
|
||||
}
|
||||
)
|
||||
assert result is not None
|
||||
assert result.input_tokens == 0
|
||||
assert result.cache_read_tokens == 150
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
56
ui/README.md
56
ui/README.md
@@ -1,36 +1,44 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Routstr node admin UI
|
||||
|
||||
## Getting Started
|
||||
A [Next.js](https://nextjs.org) app (App Router, **static export**) that provides the
|
||||
admin dashboard for a `routstr-core` node: login, settings, providers, balances,
|
||||
transactions, usage, and logs.
|
||||
|
||||
First, run the development server:
|
||||
There is no separate web server in production. `next build` produces a fully static
|
||||
export (`next.config.ts` sets `output: 'export'`), and the FastAPI backend serves it
|
||||
directly from `../ui_out/` (see `routstr/core/main.py`). So the UI and the API are
|
||||
served from the **same origin** in production.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
## Developing the UI (hot reload)
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
The everyday loop runs two processes side by side — you do **not** rebuild the static
|
||||
export while developing:
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
1. Start the backend on `:8000` — from the repo root: `make docker-up` (or
|
||||
`uvicorn routstr.core.main:app --reload`).
|
||||
2. Start the Next.js dev server on `:3000` — from the repo root: `make ui-dev`
|
||||
(or `cd ui && pnpm dev`). Edits hot-reload instantly.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Open http://localhost:3000. With no `NEXT_PUBLIC_API_URL` set, the UI falls back to
|
||||
`http://127.0.0.1:8000` in development (see `lib/api/services/configuration.ts`), so it
|
||||
talks to the local backend out of the box.
|
||||
|
||||
## Learn More
|
||||
Because dev is cross-origin (`:3000` → `:8000`), it relies on the backend's CORS
|
||||
allowing the UI origin. The default `cors_origins` is `["*"]`; if you tighten CORS,
|
||||
keep `http://localhost:3000` allowed for development.
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## Building the integrated/static UI (what production serves)
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
To produce the bundle that FastAPI serves from `../ui_out/`:
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
- `make ui-build` — builds with local Node/pnpm (`scripts/build-ui.sh`), then moves
|
||||
`ui/out/*` to `../ui_out/`.
|
||||
- `make ui-build-docker` — same, but inside Docker (no local Node needed).
|
||||
|
||||
## Deploy on Vercel
|
||||
`NEXT_PUBLIC_*` variables are read from the repo-root `.env` at build time and baked in.
|
||||
For a same-origin deployment leave `NEXT_PUBLIC_API_URL` empty (relative paths); the UI
|
||||
uses `window.location.origin` at runtime. After building, start the backend and open
|
||||
http://localhost:8000 — the dashboard is served at `/` and `/admin`.
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
If `../ui_out/` does not exist, the backend logs a warning at startup and serves the API
|
||||
only (hitting a UI route returns a small JSON fallback instead of the dashboard).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -297,16 +297,13 @@ export default function ProvidersPage() {
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
newExpanded.delete(providerId);
|
||||
} else {
|
||||
newExpanded.add(providerId);
|
||||
}
|
||||
setExpandedProviders(newExpanded);
|
||||
if (!newExpanded.has(providerId)) {
|
||||
if (expandedProviders.has(providerId)) {
|
||||
setExpandedProviders(new Set());
|
||||
setViewingModels(null);
|
||||
} else {
|
||||
// Accordion: only one provider open at a time so switching to another
|
||||
// provider's models auto-collapses the previously expanded one.
|
||||
setExpandedProviders(new Set([providerId]));
|
||||
setViewingModels(providerId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,7 +53,11 @@ import {
|
||||
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';
|
||||
|
||||
@@ -200,6 +204,172 @@ function TransactionTable({
|
||||
);
|
||||
}
|
||||
|
||||
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-[140px] truncate font-mono'>
|
||||
{inv.payment_hash.slice(0, 14)}...
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4'
|
||||
onClick={() => onCopy(inv.payment_hash, inv.id + '-hash')}
|
||||
>
|
||||
{copiedId === inv.id + '-hash' ? (
|
||||
<Check className='h-3 w-3' />
|
||||
) : (
|
||||
<Copy className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = useState<string>('all');
|
||||
@@ -231,6 +401,7 @@ export default function TransactionsPage() {
|
||||
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;
|
||||
@@ -278,12 +449,37 @@ export default function TransactionsPage() {
|
||||
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) => {
|
||||
@@ -337,9 +533,13 @@ export default function TransactionsPage() {
|
||||
useEffect(() => {
|
||||
setXcashuPage(0);
|
||||
setApikeyPage(0);
|
||||
setLightningPage(0);
|
||||
}, [type, status, search]);
|
||||
|
||||
const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching;
|
||||
const isRefetching =
|
||||
xcashuQuery.isRefetching ||
|
||||
apikeyQuery.isRefetching ||
|
||||
lightningQuery.isRefetching;
|
||||
|
||||
const renderCardContent = (
|
||||
query: typeof xcashuQuery,
|
||||
@@ -417,6 +617,7 @@ export default function TransactionsPage() {
|
||||
onClick={() => {
|
||||
xcashuQuery.refetch();
|
||||
apikeyQuery.refetch();
|
||||
lightningQuery.refetch();
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -476,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>
|
||||
@@ -516,6 +722,15 @@ export default function TransactionsPage() {
|
||||
</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>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='x-cashu'>
|
||||
@@ -553,6 +768,81 @@ export default function TransactionsPage() {
|
||||
</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>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
|
||||
@@ -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';
|
||||
@@ -68,6 +68,8 @@ const FormSchema = z.object({
|
||||
upstream_provider_id: z.string().default(''),
|
||||
input_cost: z.coerce.number().min(0).default(0),
|
||||
output_cost: z.coerce.number().min(0).default(0),
|
||||
cache_read_cost: z.coerce.number().min(0).default(0),
|
||||
cache_write_cost: z.coerce.number().min(0).default(0),
|
||||
request_cost: z.coerce.number().min(0).default(0),
|
||||
image_cost: z.coerce.number().min(0).default(0),
|
||||
web_search_cost: z.coerce.number().min(0).default(0),
|
||||
@@ -125,6 +127,8 @@ export function AddProviderModelDialog({
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
cache_read_cost: 0,
|
||||
cache_write_cost: 0,
|
||||
request_cost: 0,
|
||||
image_cost: 0,
|
||||
web_search_cost: 0,
|
||||
@@ -190,6 +194,8 @@ export function AddProviderModelDialog({
|
||||
: initialData.upstream_provider_id?.toString() || '',
|
||||
input_cost: pricing?.prompt ?? 0,
|
||||
output_cost: pricing?.completion ?? 0,
|
||||
cache_read_cost: pricing?.input_cache_read ?? 0,
|
||||
cache_write_cost: pricing?.input_cache_write ?? 0,
|
||||
request_cost: pricing?.request ?? 0,
|
||||
image_cost: pricing?.image ?? 0,
|
||||
web_search_cost: pricing?.web_search ?? 0,
|
||||
@@ -231,6 +237,8 @@ export function AddProviderModelDialog({
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
cache_read_cost: 0,
|
||||
cache_write_cost: 0,
|
||||
request_cost: 0,
|
||||
image_cost: 0,
|
||||
web_search_cost: 0,
|
||||
@@ -294,6 +302,8 @@ export function AddProviderModelDialog({
|
||||
);
|
||||
form.setValue('input_cost', pricing?.prompt ?? 0);
|
||||
form.setValue('output_cost', pricing?.completion ?? 0);
|
||||
form.setValue('cache_read_cost', pricing?.input_cache_read ?? 0);
|
||||
form.setValue('cache_write_cost', pricing?.input_cache_write ?? 0);
|
||||
form.setValue('request_cost', pricing?.request ?? 0);
|
||||
form.setValue('image_cost', pricing?.image ?? 0);
|
||||
form.setValue('web_search_cost', pricing?.web_search ?? 0);
|
||||
@@ -365,6 +375,8 @@ export function AddProviderModelDialog({
|
||||
pricing: {
|
||||
prompt: data.input_cost,
|
||||
completion: data.output_cost,
|
||||
input_cache_read: data.cache_read_cost,
|
||||
input_cache_write: data.cache_write_cost,
|
||||
request: data.request_cost,
|
||||
image: data.image_cost,
|
||||
web_search: data.web_search_cost,
|
||||
@@ -810,6 +822,38 @@ export function AddProviderModelDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cache_read_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cache Read Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.000001' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Discounted cached-input read price per 1M tokens.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='cache_write_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cache Write Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.000001' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Cached-input creation price per 1M tokens.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='request_cost'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,26 @@ export function ProviderFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={`${idPrefix}slug`}>
|
||||
Slug {mode === 'create' ? '(optional, auto-generated)' : ''}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}slug`}
|
||||
value={formData.slug || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
slug: e.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder='e.g. openai-prod'
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Stable external key used to update this provider via the admin API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
|
||||
<Input
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const ProviderTypeSchema = z.object({
|
||||
|
||||
export const UpstreamProviderSchema = z.object({
|
||||
id: z.number(),
|
||||
slug: z.string().nullable().optional(),
|
||||
provider_type: z.string(),
|
||||
base_url: z.string(),
|
||||
api_key: z.string().optional(),
|
||||
@@ -31,6 +32,7 @@ export const CreateUpstreamProviderSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateUpstreamProviderSchema = z.object({
|
||||
@@ -41,6 +43,7 @@ export const UpdateUpstreamProviderSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
provider_fee: z.number().optional(),
|
||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
export const AdminModelPricingSchema = z.object({
|
||||
@@ -50,6 +53,8 @@ export const AdminModelPricingSchema = z.object({
|
||||
image: z.number().optional(),
|
||||
web_search: z.number().optional(),
|
||||
internal_reasoning: z.number().optional(),
|
||||
input_cache_read: z.number().optional(),
|
||||
input_cache_write: z.number().optional(),
|
||||
});
|
||||
|
||||
export const AdminModelArchitectureSchema = z.object({
|
||||
@@ -146,7 +151,7 @@ export class AdminService {
|
||||
if (!pricing) return pricing;
|
||||
const result = { ...pricing };
|
||||
|
||||
// Only prompt and completion are per-token and need scaling to per-1M
|
||||
// Token-priced fields are stored per-token by the API and shown per-1M in the UI.
|
||||
const convertField = (field: string) => {
|
||||
const val = result[field];
|
||||
if (val !== undefined && val !== null) {
|
||||
@@ -161,6 +166,8 @@ export class AdminService {
|
||||
|
||||
convertField('prompt');
|
||||
convertField('completion');
|
||||
convertField('input_cache_read');
|
||||
convertField('input_cache_write');
|
||||
|
||||
// Other fields (request, image, etc.) are already flat fees (per item)
|
||||
// so we do NOT scale them.
|
||||
@@ -174,7 +181,7 @@ export class AdminService {
|
||||
if (!pricing) return pricing;
|
||||
const result = { ...pricing };
|
||||
|
||||
// Only prompt and completion are per-1M in UI and need scaling down to per-token
|
||||
// Token-priced fields are per-1M in the UI and need scaling down to per-token.
|
||||
const convertField = (field: string) => {
|
||||
const val = result[field];
|
||||
if (val !== undefined && val !== null) {
|
||||
@@ -187,6 +194,8 @@ export class AdminService {
|
||||
|
||||
convertField('prompt');
|
||||
convertField('completion');
|
||||
convertField('input_cache_read');
|
||||
convertField('input_cache_write');
|
||||
|
||||
// Other fields stay as flat fees
|
||||
|
||||
@@ -831,9 +840,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()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -908,6 +926,25 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -1015,10 +1052,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;
|
||||
@@ -1186,3 +1234,22 @@ 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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user