mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
118 Commits
do-not-cre
...
sentrystr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ffebe6d91 | ||
|
|
38d7075652 | ||
|
|
ed27ad0dea | ||
|
|
6b1b4285fa | ||
|
|
7b56dcb902 | ||
|
|
2131cfc7f9 | ||
|
|
d6d9af91e4 | ||
|
|
cbd97e8e3a | ||
|
|
e4b26dd27b | ||
|
|
1e50afbd82 | ||
|
|
4d6e2cdf59 | ||
|
|
d079f45ba3 | ||
|
|
d08f07d6fb | ||
|
|
af0796689f | ||
|
|
f408785409 | ||
|
|
90f1ba89e5 | ||
|
|
e29c9241ce | ||
|
|
5a3774a414 | ||
|
|
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 |
@@ -34,6 +34,14 @@ UPSTREAM_API_KEY=your-upstream-api-key
|
||||
# LOG_LEVEL=INFO
|
||||
# ENABLE_CONSOLE_LOGGING=true
|
||||
|
||||
# SentryStr: mirror logs to Nostr relays (requires `pip install sentrystr`).
|
||||
# Announcements run on a background worker thread so they never block requests.
|
||||
# ENABLE_SENTRYSTR=false
|
||||
# SENTRYSTR_LOG_LEVEL=ERROR # defaults to ERROR; keep >= LOG_LEVEL
|
||||
# SENTRYSTR_RELAYS="wss://relay.damus.io,wss://nos.lol" # defaults to RELAYS
|
||||
# SENTRYSTR_NSEC=nsec1... # defaults to NSEC; ephemeral key if unset
|
||||
# SENTRYSTR_RECIPIENT_NPUB=npub1... # if set, log events are NIP-44 encrypted
|
||||
|
||||
# Custom Model Management
|
||||
# BASE_URL=https://openrouter.ai/api/v1
|
||||
# MODELS_PATH=models.json
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,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")
|
||||
@@ -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",
|
||||
@@ -24,6 +23,11 @@ dependencies = [
|
||||
"litellm>=1.55.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Decentralized log mirroring to Nostr relays. Enable at runtime with
|
||||
# ENABLE_SENTRYSTR=true (see routstr/core/logging.py).
|
||||
sentrystr = ["sentrystr>=0.2.0"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=1.15.0",
|
||||
@@ -87,4 +91,3 @@ disallow_untyped_decorators = true
|
||||
|
||||
[tool.uv.sources]
|
||||
routstr = { workspace = true }
|
||||
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
|
||||
|
||||
225
routstr/auth.py
225
routstr/auth.py
@@ -341,6 +341,11 @@ async def validate_bearer_key(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
# Defense-in-depth: credit_balance now refuses to commit on a
|
||||
# zero/negative redemption, but if a row was nonetheless
|
||||
# persisted, drop it so we never leave an orphan zero-balance key.
|
||||
await session.delete(new_key)
|
||||
await session.commit()
|
||||
raise Exception("Token redemption failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
@@ -359,6 +364,7 @@ async def validate_bearer_key(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
@@ -529,31 +535,19 @@ async def pay_for_request(
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
reserved_at_now = int(time.time())
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
reserved_at=reserved_at_now,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also increment total_requests and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
@@ -565,7 +559,6 @@ async def pay_for_request(
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
@@ -577,6 +570,44 @@ async def pay_for_request(
|
||||
},
|
||||
)
|
||||
|
||||
# Also increment total_requests and reserved_balance on the child key if it's different.
|
||||
# The balance_limit guard is enforced atomically here — the Python pre-check above
|
||||
# is a fast-path rejection only and provides no concurrency guarantee.
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(
|
||||
(col(ApiKey.balance_limit).is_(None))
|
||||
| (
|
||||
col(ApiKey.total_spent)
|
||||
+ col(ApiKey.reserved_balance)
|
||||
+ cost_per_request
|
||||
<= col(ApiKey.balance_limit)
|
||||
)
|
||||
)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
reserved_at=reserved_at_now,
|
||||
)
|
||||
)
|
||||
child_result = await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
if child_result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "balance_limit_exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
@@ -615,12 +646,19 @@ async def revert_pay_for_request(
|
||||
False if the reservation was already released (prevents negative reserved_balance)."""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
# Keep reserved_at while other reservations remain
|
||||
cleared_reserved_at = case(
|
||||
(col(ApiKey.reserved_balance) - cost_per_request > 0, col(ApiKey.reserved_at)),
|
||||
else_=None,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
reserved_at=cleared_reserved_at,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
@@ -636,6 +674,7 @@ async def revert_pay_for_request(
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
reserved_at=cleared_reserved_at,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
@@ -670,12 +709,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 +803,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 +1021,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 +1059,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 +1295,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 (
|
||||
@@ -45,10 +45,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
info = {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": billing_key.balance,
|
||||
"balance": billing_key.total_balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
"is_child": key.parent_key_hash is not None,
|
||||
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||
"total_requests": key.total_requests,
|
||||
"total_spent": key.total_spent,
|
||||
"balance_limit": key.balance_limit,
|
||||
@@ -56,7 +55,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
"validity_date": key.validity_date,
|
||||
}
|
||||
|
||||
if not key.parent_key_hash:
|
||||
if key.parent_key_hash:
|
||||
info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
|
||||
else:
|
||||
# Fetch child keys if this is a parent key
|
||||
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
|
||||
results = await session.exec(statement)
|
||||
@@ -312,9 +313,36 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
|
||||
if key.reserved_balance > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
# Release the reservation if it is stale
|
||||
cutoff = int(time.time()) - settings.stale_reservation_timeout_seconds
|
||||
stale_release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) > 0)
|
||||
.where(
|
||||
or_(
|
||||
col(ApiKey.reserved_at).is_(None),
|
||||
col(ApiKey.reserved_at) < cutoff,
|
||||
)
|
||||
)
|
||||
.values(reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
stale_result = await session.exec(stale_release_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if stale_result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
logger.warning(
|
||||
"refund_wallet_endpoint: released stale reservation before refund",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"stale_timeout_seconds": settings.stale_reservation_timeout_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
@@ -341,7 +369,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 +382,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,15 +429,26 @@ 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)
|
||||
logger.error(
|
||||
"refund_wallet_endpoint: mint/send failed",
|
||||
extra={
|
||||
"error": error_msg,
|
||||
"error_type": type(e).__name__,
|
||||
"hashed_key": key.hashed_key,
|
||||
"remaining_balance": remaining_balance,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
"has_refund_address": bool(key.refund_address),
|
||||
},
|
||||
)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
or "connection" in error_msg.lower()
|
||||
or isinstance(e, Exception)
|
||||
and "ConnectError" in str(type(e))
|
||||
or "ConnectError" in str(type(e))
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
@@ -519,10 +563,24 @@ async def create_child_key(
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Deduct cost from parent
|
||||
key.balance -= total_cost
|
||||
key.total_spent += total_cost
|
||||
session.add(key)
|
||||
# Deduct cost from parent atomically — guards against concurrent requests
|
||||
# that both pass the balance check above on stale in-memory state.
|
||||
deduct_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - total_cost,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost,
|
||||
)
|
||||
)
|
||||
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Generate new keys
|
||||
import secrets
|
||||
@@ -547,6 +605,7 @@ async def create_child_key(
|
||||
new_keys.append("sk-" + new_key_hash)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
response_data = {
|
||||
"api_keys": new_keys,
|
||||
@@ -593,26 +652,6 @@ async def reset_child_key_spent(
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
@router.get("/cashu-refund/{payment_token_hash}")
|
||||
async def get_cashu_refund(
|
||||
payment_token_hash: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
||||
result = await session.get(CashuTransaction, payment_token_hash)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if result.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
result.collected = True
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
return {
|
||||
"refund_token": result.token,
|
||||
"amount": result.amount,
|
||||
"unit": result.unit,
|
||||
}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
@@ -626,7 +665,7 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router)
|
||||
balance_router.include_router(lightning_router, include_in_schema=False)
|
||||
balance_router.include_router(router)
|
||||
|
||||
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .db import (
|
||||
ApiKey,
|
||||
CashuTransaction,
|
||||
CliToken,
|
||||
LightningInvoice,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
@@ -67,26 +68,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):
|
||||
@@ -1477,6 +1541,52 @@ 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)],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -199,31 +232,57 @@ class SecurityFilter(logging.Filter):
|
||||
"refund_address",
|
||||
}
|
||||
|
||||
def _scrub(self, message: str) -> str:
|
||||
"""Redact secrets from a single string."""
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||
]
|
||||
for pattern in standalone_patterns:
|
||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||
|
||||
for key in self.SENSITIVE_KEYS:
|
||||
if key in message.lower():
|
||||
key_patterns = [
|
||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||
]
|
||||
for pattern in key_patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
return message
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||
]
|
||||
for pattern in standalone_patterns:
|
||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||
|
||||
for key in self.SENSITIVE_KEYS:
|
||||
if key in message.lower():
|
||||
key_patterns = [
|
||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||
]
|
||||
for pattern in key_patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
record.msg = message
|
||||
message = redact_org_ids(record.getMessage())
|
||||
record.msg = self._scrub(message)
|
||||
record.args = ()
|
||||
|
||||
# The exception traceback is appended by the *formatter* (e.g. when a
|
||||
# QueueHandler flattens the record before it is announced to the
|
||||
# public, permanent SentryStr relays), so the message scrub above
|
||||
# never sees it. Pre-render and redact it here and cache the result
|
||||
# in `exc_text` so every formatter reuses the scrubbed version.
|
||||
if record.exc_info and record.exc_info[0] is not None and not record.exc_text:
|
||||
record.exc_text = logging.Formatter().formatException(record.exc_info)
|
||||
if record.exc_text:
|
||||
record.exc_text = self._scrub(redact_org_ids(record.exc_text))
|
||||
if record.stack_info:
|
||||
record.stack_info = self._scrub(redact_org_ids(record.stack_info))
|
||||
|
||||
# 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
|
||||
|
||||
@@ -408,6 +467,120 @@ def setup_logging() -> None:
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
_install_sentrystr_logging(LOGGING_CONFIG)
|
||||
|
||||
|
||||
# Handle for the background SentryStr logging worker (None when disabled).
|
||||
_sentrystr_handle: Any = None
|
||||
|
||||
# Bound on the in-memory queue feeding the background SentryStr worker. Records
|
||||
# beyond this are dropped rather than blocking the caller or growing memory
|
||||
# without limit when a relay is slow or unreachable.
|
||||
_SENTRYSTR_QUEUE_SIZE = 10_000
|
||||
|
||||
# Max seconds to wait for the queued backlog to flush on shutdown before
|
||||
# abandoning it, so shutdown cannot hang on a slow or unreachable relay.
|
||||
_SENTRYSTR_SHUTDOWN_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def _install_sentrystr_logging(logging_config: dict[str, Any]) -> None:
|
||||
"""Mirror routstr application logs to Nostr relays via SentryStr.
|
||||
|
||||
Announcing to relays blocks, so SentryStr's installer runs the publish on a
|
||||
dedicated background worker thread fed by an in-memory queue; the request /
|
||||
event-loop threads only pay the cost of enqueueing a record. Disabled unless
|
||||
`ENABLE_SENTRYSTR` is set. Any failure here is swallowed so optional logging
|
||||
can never take down startup.
|
||||
"""
|
||||
global _sentrystr_handle
|
||||
|
||||
if _sentrystr_handle is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
from .settings import settings
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not getattr(settings, "enable_sentrystr", False):
|
||||
return
|
||||
|
||||
routstr_logger = logging.getLogger("routstr")
|
||||
|
||||
relays = list(settings.sentrystr_relays or settings.relays or [])
|
||||
if not relays:
|
||||
routstr_logger.warning(
|
||||
"SentryStr logging is enabled but no relays are configured; set "
|
||||
"SENTRYSTR_RELAYS or RELAYS. Skipping SentryStr logging."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from sentrystr import install_sentrystr_logging
|
||||
except Exception:
|
||||
routstr_logger.warning(
|
||||
"SentryStr logging is enabled but the `sentrystr` package is not "
|
||||
"installed. Install it with `pip install sentrystr`. Skipping "
|
||||
"SentryStr logging."
|
||||
)
|
||||
return
|
||||
|
||||
# Default to ERROR: these events are announced to public, permanent relays,
|
||||
# so only genuine problems should be mirrored unless the operator opts into
|
||||
# something more verbose via SENTRYSTR_LOG_LEVEL. (Keep this at or above
|
||||
# LOG_LEVEL; a lower value is gated out by the routstr loggers' own level.)
|
||||
level = (settings.sentrystr_log_level or "ERROR").upper()
|
||||
|
||||
# routstr application loggers set propagate=False, so the handler must be
|
||||
# attached to each of them rather than relying on propagation to the root.
|
||||
routstr_loggers = [
|
||||
name
|
||||
for name in logging_config.get("loggers", {})
|
||||
if name == "routstr" or name.startswith("routstr.")
|
||||
] or ["routstr"]
|
||||
|
||||
try:
|
||||
_sentrystr_handle = install_sentrystr_logging(
|
||||
relays=relays,
|
||||
private_key=(settings.sentrystr_nsec or settings.nsec or None),
|
||||
level=level,
|
||||
loggers=routstr_loggers,
|
||||
recipient_pubkey=(settings.sentrystr_recipient_npub or None),
|
||||
platform="routstr",
|
||||
formatter=logging.Formatter("%(message)s"),
|
||||
# Run the same scrubbing/enrichment filters used by the other
|
||||
# handlers, on the calling thread, before records are handed to the
|
||||
# background worker. RequestIdFilter must run here because it reads a
|
||||
# ContextVar that is only set on the request thread; SecurityFilter
|
||||
# must run here so secrets are redacted before anything is announced
|
||||
# to the (public, permanent) relays. Passing them to the installer
|
||||
# attaches them before the handler goes live, so no record can slip
|
||||
# through unfiltered during startup.
|
||||
filters=[RequestIdFilter(), SecurityFilter()],
|
||||
# Bound the queue so a slow/unreachable relay can't grow memory.
|
||||
queue_size=_SENTRYSTR_QUEUE_SIZE,
|
||||
)
|
||||
except Exception as exc:
|
||||
routstr_logger.warning("Failed to initialize SentryStr logging: %s", exc)
|
||||
return
|
||||
|
||||
routstr_logger.info(
|
||||
"SentryStr logging enabled",
|
||||
extra={"sentrystr_level": level, "sentrystr_relay_count": len(relays)},
|
||||
)
|
||||
|
||||
|
||||
def shutdown_sentrystr_logging() -> None:
|
||||
"""Flush and stop the background SentryStr logging worker, if running."""
|
||||
global _sentrystr_handle
|
||||
handle = _sentrystr_handle
|
||||
_sentrystr_handle = None
|
||||
if handle is not None:
|
||||
try:
|
||||
handle.close(timeout=_SENTRYSTR_SHUTDOWN_TIMEOUT)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance for the given module name."""
|
||||
|
||||
@@ -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)
|
||||
@@ -198,6 +230,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
# Flush and stop the background SentryStr logging worker (no-op when
|
||||
# SentryStr logging is disabled). Run off the event loop: the drain is
|
||||
# blocking (though bounded) and must not stall the loop during shutdown.
|
||||
from .logging import shutdown_sentrystr_logging
|
||||
|
||||
await asyncio.to_thread(shutdown_sentrystr_logging)
|
||||
|
||||
|
||||
class _ImmutableStaticFiles(StaticFiles):
|
||||
"""Static files with long Cache-Control for content-hashed Next.js assets.
|
||||
@@ -347,7 +386,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 +406,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)
|
||||
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
|
||||
@@ -16,7 +16,12 @@ class Settings(BaseSettings):
|
||||
|
||||
@classmethod
|
||||
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
|
||||
if field_name in {"cashu_mints", "cors_origins", "relays"}:
|
||||
if field_name in {
|
||||
"cashu_mints",
|
||||
"cors_origins",
|
||||
"relays",
|
||||
"sentrystr_relays",
|
||||
}:
|
||||
v = str(raw_value).strip()
|
||||
if v == "":
|
||||
return []
|
||||
@@ -67,6 +72,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")
|
||||
@@ -89,6 +108,21 @@ class Settings(BaseSettings):
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
|
||||
|
||||
# SentryStr (decentralized logging to Nostr relays). When enabled, routstr
|
||||
# application logs at `sentrystr_log_level` and above are mirrored to Nostr
|
||||
# relays on a background worker thread (see routstr/core/logging.py).
|
||||
enable_sentrystr: bool = Field(default=False, env="ENABLE_SENTRYSTR")
|
||||
# Minimum level forwarded to relays. Empty -> defaults to ERROR. Should be
|
||||
# at or above `log_level`, since the routstr loggers gate lower records.
|
||||
sentrystr_log_level: str = Field(default="", env="SENTRYSTR_LOG_LEVEL")
|
||||
# Relays to announce to. Empty -> fall back to the discovery `relays`.
|
||||
sentrystr_relays: list[str] = Field(default_factory=list, env="SENTRYSTR_RELAYS")
|
||||
# Secret key (nsec or hex) used to sign log events. Empty -> fall back to
|
||||
# `nsec`; if that is also empty an ephemeral key is generated per process.
|
||||
sentrystr_nsec: str = Field(default="", env="SENTRYSTR_NSEC")
|
||||
# Optional recipient npub. When set, log events are NIP-44 encrypted to it.
|
||||
sentrystr_recipient_npub: str = Field(default="", env="SENTRYSTR_RECIPIENT_NPUB")
|
||||
|
||||
# Other
|
||||
chat_completions_api_version: str = Field(
|
||||
default="", env="CHAT_COMPLETIONS_API_VERSION"
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -34,7 +42,8 @@ class CostDataError(BaseModel):
|
||||
|
||||
|
||||
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 +53,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 +66,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 "
|
||||
@@ -84,16 +97,14 @@ async def calculate_cost(
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -202,22 +213,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 +225,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 +300,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 +327,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 +418,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,48 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
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). litellm ships per-model USD rates keyed by the exact
|
||||
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
|
||||
(gpt-4o, claude-sonnet-4-5), so both spellings are tried.
|
||||
|
||||
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
|
||||
|
||||
import litellm
|
||||
|
||||
info: dict | None = None
|
||||
for key in (model_id, model_id.split("/", 1)[-1]):
|
||||
candidate = litellm.model_cost.get(key)
|
||||
if isinstance(candidate, dict):
|
||||
info = candidate
|
||||
break
|
||||
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", {})
|
||||
@@ -418,7 +478,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 +508,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
|
||||
|
||||
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
|
||||
66
routstr/upstream/deepseek_v4_pricing_shim.py
Normal file
66
routstr/upstream/deepseek_v4_pricing_shim.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""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 ~60% overcharge on cache hits (DeepSeek hits are ~0.2x input).
|
||||
|
||||
This module injects the missing entries into ``litellm.model_cost`` at startup
|
||||
so the existing backfill path resolves them. Rates mirror the open upstream PR
|
||||
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. Source: BerriAI/litellm PR #26380.
|
||||
_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-08,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"input_cost_per_token_cache_hit": 2.8e-08,
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
"input_cost_per_token": 1.74e-06,
|
||||
"output_cost_per_token": 3.48e-06,
|
||||
"cache_read_input_token_cost": 1.4e-07,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"input_cost_per_token_cache_hit": 1.4e-07,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
@@ -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__"):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
@@ -18,6 +19,65 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
supports_anthropic_messages = True
|
||||
litellm_provider_prefix = "openrouter/"
|
||||
|
||||
def prepare_request_body(
|
||||
self, body: bytes | None, model_obj: Model
|
||||
) -> bytes | None:
|
||||
"""Set provider.require_parameters on tool-use requests.
|
||||
|
||||
Without it OpenRouter can route a tool call to an endpoint that doesn't
|
||||
support function calling and 404 with "No endpoints found that support
|
||||
tool use". We leave a client-supplied value untouched.
|
||||
"""
|
||||
body = super().prepare_request_body(body, model_obj)
|
||||
if not body:
|
||||
return body
|
||||
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return body
|
||||
|
||||
if not isinstance(data, dict) or not data.get("tools"):
|
||||
return body
|
||||
|
||||
provider = data.get("provider")
|
||||
if not isinstance(provider, dict):
|
||||
provider = {}
|
||||
|
||||
if "require_parameters" in provider:
|
||||
return body
|
||||
|
||||
provider["require_parameters"] = True
|
||||
data["provider"] = provider
|
||||
return json.dumps(data).encode()
|
||||
|
||||
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.
|
||||
|
||||
|
||||
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
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from typing import TypedDict
|
||||
@@ -35,6 +36,24 @@ async def get_balance(unit: str) -> int:
|
||||
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 +67,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 +130,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 +233,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,
|
||||
},
|
||||
@@ -171,10 +285,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 +297,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 +307,185 @@ 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:
|
||||
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 ValueError(
|
||||
f"Swap recovery failed: mint signed outputs but proofs are "
|
||||
f"unrecoverable (mint reports them spent). "
|
||||
f"Expected {minted_amount}, recovered {balance_gained}. "
|
||||
f"Local wallet DB ('.wallet/') state is corrupted — "
|
||||
f"the counter for keyset is stuck at a bad index range."
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as recovery_err:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: recovery failed",
|
||||
extra={"error": str(recovery_err)},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Mint on primary failed and recovery unsuccessful: {e}"
|
||||
) from e
|
||||
else:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: mint on primary failed after successful melt",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"minted_amount": minted_amount,
|
||||
"mint_quote_id": mint_quote.quote,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: completed successfully",
|
||||
@@ -310,6 +524,20 @@ async def credit_balance(
|
||||
"credit_balance: Converted to msat", extra={"amount_msat": amount}
|
||||
)
|
||||
|
||||
# Guard against zero/negative redemptions (empty or dust tokens, or
|
||||
# swap-to-primary-mint amounts that net to <= 0 after fees). Raising here
|
||||
# — before the UPDATE/commit below — leaves any freshly-created, still
|
||||
# uncommitted ApiKey row to be rolled back when the request session
|
||||
# closes, instead of persisting an orphan key with balance 0.
|
||||
if amount <= 0:
|
||||
logger.error(
|
||||
"credit_balance: Redeemed amount is zero or negative; refusing to credit",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Redeemed token amount must be positive, got {amount} msats"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
@@ -321,7 +549,11 @@ async def credit_balance(
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
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 ValueError("API key disappeared before credit could be recorded")
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
@@ -445,7 +677,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 +692,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 +733,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",
|
||||
|
||||
@@ -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)
|
||||
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_400_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 400 with an actionable message, 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 == 400
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
59
tests/unit/test_auth_cashu.py
Normal file
59
tests/unit/test_auth_cashu.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import validate_bearer_key
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = _make_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
db_session = AsyncSession(engine, expire_on_commit=False)
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
await db_session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
token = "cashuAfirst_seen_but_redemption_fails"
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||
patch(
|
||||
"routstr.auth.credit_balance",
|
||||
new=AsyncMock(side_effect=ValueError("token already spent")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException):
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
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
|
||||
245
tests/unit/test_cache_pricing.py
Normal file
245
tests/unit/test_cache_pricing.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""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_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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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,113 @@ 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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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 +423,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
|
||||
@@ -500,7 +500,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 +589,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 +693,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 +971,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 +1039,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
|
||||
|
||||
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"
|
||||
@@ -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"}
|
||||
95
tests/unit/test_require_parameters.py
Normal file
95
tests/unit/test_require_parameters.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
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")
|
||||
|
||||
from routstr.upstream import GenericUpstreamProvider
|
||||
from routstr.upstream.openrouter import OpenRouterUpstreamProvider
|
||||
|
||||
|
||||
def _model(model_id: str = "openai/gpt-4o"): # 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=128000,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="GPT",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(prompt=0.0, completion=0.0),
|
||||
)
|
||||
|
||||
|
||||
def _tool_body() -> dict:
|
||||
return {
|
||||
"model": "openai/gpt-4o",
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {}},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _prepare(provider, body: dict) -> dict: # type: ignore[no-untyped-def]
|
||||
out = provider.prepare_request_body(json.dumps(body).encode(), _model())
|
||||
assert out is not None
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def test_injects_require_parameters_for_tool_request() -> None:
|
||||
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), _tool_body())
|
||||
assert data["provider"]["require_parameters"] is True
|
||||
|
||||
|
||||
def test_generic_provider_on_openrouter_url_is_left_alone() -> None:
|
||||
# Only OpenRouterUpstreamProvider injects; a generic provider pointed at the
|
||||
# same base URL doesn't.
|
||||
provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1")
|
||||
data = _prepare(provider, _tool_body())
|
||||
assert "provider" not in data
|
||||
|
||||
|
||||
def test_no_injection_without_tools() -> None:
|
||||
body = {"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "hi"}]}
|
||||
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
|
||||
assert "provider" not in data
|
||||
|
||||
|
||||
def test_empty_tools_list_does_not_inject() -> None:
|
||||
body = _tool_body()
|
||||
body["tools"] = []
|
||||
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
|
||||
assert "provider" not in data
|
||||
|
||||
|
||||
def test_direct_provider_does_not_inject() -> None:
|
||||
provider = GenericUpstreamProvider(base_url="https://api.openai.com/v1")
|
||||
data = _prepare(provider, _tool_body())
|
||||
assert "provider" not in data
|
||||
|
||||
|
||||
def test_keeps_client_set_require_parameters() -> None:
|
||||
body = _tool_body()
|
||||
body["provider"] = {"require_parameters": False}
|
||||
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
|
||||
assert data["provider"]["require_parameters"] is False
|
||||
|
||||
|
||||
def test_preserves_other_provider_fields() -> None:
|
||||
body = _tool_body()
|
||||
body["provider"] = {"order": ["openai", "azure"]}
|
||||
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
|
||||
assert data["provider"]["order"] == ["openai", "azure"]
|
||||
assert data["provider"]["require_parameters"] is True
|
||||
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"]
|
||||
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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -41,52 +43,12 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { formatFromMsat } from '@/lib/currency';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
function getTotals(balances: TemporaryBalance[]) {
|
||||
let totalBalance = 0;
|
||||
let totalSpent = 0;
|
||||
let totalRequests = 0;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
balances.forEach((balance) => {
|
||||
if (!balance.parent_key_hash) {
|
||||
totalBalance += balance.balance || 0;
|
||||
}
|
||||
totalSpent += balance.total_spent || 0;
|
||||
totalRequests += balance.total_requests || 0;
|
||||
});
|
||||
|
||||
return { totalBalance, totalSpent, totalRequests };
|
||||
}
|
||||
|
||||
function buildHierarchicalData(
|
||||
allBalances: TemporaryBalance[],
|
||||
filteredBalances: TemporaryBalance[]
|
||||
) {
|
||||
const parents = filteredBalances.filter((item) => !item.parent_key_hash);
|
||||
const result: Array<TemporaryBalance & { isChild?: boolean }> = [];
|
||||
|
||||
parents.forEach((parent) => {
|
||||
result.push(parent);
|
||||
|
||||
const children = allBalances.filter(
|
||||
(item) => item.parent_key_hash === parent.hashed_key
|
||||
);
|
||||
|
||||
children.forEach((child) => {
|
||||
result.push({ ...child, isChild: true });
|
||||
});
|
||||
});
|
||||
|
||||
const orphans = filteredBalances.filter(
|
||||
(item) =>
|
||||
item.parent_key_hash &&
|
||||
!result.some((r) => r.hashed_key === item.hashed_key)
|
||||
);
|
||||
|
||||
result.push(...orphans.map((item) => ({ ...item, isChild: true })));
|
||||
|
||||
return result;
|
||||
}
|
||||
const formatCreatedAt = (createdAt: number | null | undefined) =>
|
||||
createdAt ? format(createdAt * 1000, 'yyyy-MM-dd HH:mm:ss') : '—';
|
||||
|
||||
export function TemporaryBalances({
|
||||
refreshInterval = 10000,
|
||||
@@ -98,38 +60,55 @@ export function TemporaryBalances({
|
||||
usdPerSat: number | null;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Debounce the search input so we don't refetch on every keystroke.
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebouncedSearch(searchTerm), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Reset to the first page whenever the active search changes.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const searchParam = debouncedSearch || undefined;
|
||||
|
||||
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
|
||||
queryKey: ['temporary-balances'],
|
||||
queryFn: async () => AdminService.getTemporaryBalances(),
|
||||
queryKey: ['temporary-balances', searchParam, page],
|
||||
queryFn: async () =>
|
||||
AdminService.getTemporaryBalances(
|
||||
searchParam,
|
||||
PAGE_SIZE,
|
||||
page * PAGE_SIZE
|
||||
),
|
||||
refetchInterval: refreshInterval,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const formatBalance = (msat: number) =>
|
||||
formatFromMsat(msat, displayUnit, usdPerSat);
|
||||
|
||||
const filteredData = data
|
||||
? data.filter(
|
||||
(item) =>
|
||||
item.hashed_key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.refund_address?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
const totals = data
|
||||
? getTotals(data)
|
||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
||||
|
||||
const rows = data ? buildHierarchicalData(data, filteredData) : [];
|
||||
const rows = data?.balances ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totals = data?.totals ?? {
|
||||
total_balance: 0,
|
||||
total_spent: 0,
|
||||
total_requests: 0,
|
||||
};
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='space-y-1.5'>
|
||||
<CardTitle>Temporary Balances</CardTitle>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription className='max-w-2xl'>
|
||||
API keys with their current balances and usage statistics
|
||||
API keys with their current balances and usage statistics, newest
|
||||
first
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +135,7 @@ export function TemporaryBalances({
|
||||
(isFetching || isLoading) && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span className='sr-only'>Refresh temporary balances</span>
|
||||
<span className='sr-only'>Refresh API keys</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,7 +169,7 @@ export function TemporaryBalances({
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-5 w-5' />
|
||||
<AlertDescription>
|
||||
Error loading temporary balances: {(error as Error).message}
|
||||
Error loading API keys: {(error as Error).message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -207,7 +186,7 @@ export function TemporaryBalances({
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatBalance(totals.totalBalance)}
|
||||
{formatBalance(totals.total_balance)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -222,7 +201,7 @@ export function TemporaryBalances({
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{formatBalance(totals.totalSpent)}
|
||||
{formatBalance(totals.total_spent)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -237,12 +216,44 @@ export function TemporaryBalances({
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
|
||||
{totals.totalRequests.toLocaleString()}
|
||||
{totals.total_requests.toLocaleString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{page * PAGE_SIZE + 1}–
|
||||
{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>Previous</span>
|
||||
</Button>
|
||||
<span className='text-xs sm:text-sm'>
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<span className='hidden sm:inline'>Next</span>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<>
|
||||
<div className='hidden md:block'>
|
||||
@@ -257,6 +268,7 @@ export function TemporaryBalances({
|
||||
<TableHead className='text-right'>
|
||||
Total Requests
|
||||
</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Refund Address</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
Expiry Time
|
||||
@@ -264,178 +276,192 @@ export function TemporaryBalances({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((balance, index) => (
|
||||
<TableRow
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 &&
|
||||
!balance.isChild &&
|
||||
'opacity-60',
|
||||
balance.isChild && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{balance.isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
<span>{balance.hashed_key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{balance.isChild ? (
|
||||
<span className='text-muted-foreground italic'>
|
||||
(Parent)
|
||||
</span>
|
||||
) : (
|
||||
formatBalance(balance.balance)
|
||||
{rows.map((balance, index) => {
|
||||
const isChild = Boolean(balance.parent_key_hash);
|
||||
return (
|
||||
<TableRow
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 && !isChild && 'opacity-60',
|
||||
isChild && 'bg-muted/30'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
|
||||
{balance.refund_address || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<div className='inline-flex items-center justify-end gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
>
|
||||
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
<span>{balance.hashed_key}</span>
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{isChild ? (
|
||||
<span className='text-muted-foreground italic'>
|
||||
(Parent)
|
||||
</span>
|
||||
) : (
|
||||
formatBalance(balance.balance)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs whitespace-nowrap'>
|
||||
{formatCreatedAt(balance.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
|
||||
{balance.refund_address || '-'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<div className='inline-flex items-center justify-end gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 md:hidden'>
|
||||
{rows.map((balance, index) => (
|
||||
<Card
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 &&
|
||||
!balance.isChild &&
|
||||
'opacity-80',
|
||||
balance.isChild && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<CardDescription className='font-mono text-xs break-all'>
|
||||
{balance.hashed_key}
|
||||
</CardDescription>
|
||||
{balance.isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Balance
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{balance.isChild
|
||||
? '(Uses Parent)'
|
||||
: formatBalance(balance.balance)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Spent</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
{rows.map((balance, index) => {
|
||||
const isChild = Boolean(balance.parent_key_hash);
|
||||
return (
|
||||
<Card
|
||||
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
|
||||
className={cn(
|
||||
balance.balance === 0 && !isChild && 'opacity-80',
|
||||
isChild && 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<CardHeader className='p-4 pb-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<CardDescription className='font-mono text-xs break-all'>
|
||||
{balance.hashed_key}
|
||||
</CardDescription>
|
||||
{isChild && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 px-1.5 text-[10px] uppercase'
|
||||
>
|
||||
Child
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{balance.refund_address && (
|
||||
<div className='col-span-2'>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Refund Address
|
||||
Balance
|
||||
</p>
|
||||
<p className='font-mono text-xs break-all'>
|
||||
{balance.refund_address}
|
||||
<p className='font-mono text-sm'>
|
||||
{isChild
|
||||
? '(Uses Parent)'
|
||||
: formatBalance(balance.balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Spent
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Requests
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Created
|
||||
</p>
|
||||
<p className='font-mono text-xs'>
|
||||
{formatCreatedAt(balance.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Expires
|
||||
</p>
|
||||
<p className='font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{balance.refund_address && (
|
||||
<div className='col-span-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Refund Address
|
||||
</p>
|
||||
<p className='font-mono text-xs break-all'>
|
||||
{balance.refund_address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className='py-8'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{searchTerm ? (
|
||||
{debouncedSearch ? (
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
) : (
|
||||
<Key className='h-4 w-4' />
|
||||
)}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
{searchTerm
|
||||
? 'No temporary balances match your search'
|
||||
: 'No temporary balances found'}
|
||||
{debouncedSearch
|
||||
? 'No API keys match your search'
|
||||
: 'No API keys found'}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{searchTerm
|
||||
{debouncedSearch
|
||||
? 'Try a different key hash or refund address.'
|
||||
: 'Temporary balances will appear here once API keys are used.'}
|
||||
: 'API keys will appear here once they are created.'}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
{total > 0 && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Showing {filteredData.length} of {data.length} temporary
|
||||
balances
|
||||
Showing {rows.length} of {total} API keys
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
282
ui/components/version-status.tsx
Normal file
282
ui/components/version-status.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
ExternalLinkIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
deriveStatus,
|
||||
formatReleaseDate,
|
||||
formatVersionLabel,
|
||||
parseVersion,
|
||||
type StatusKind,
|
||||
} from '@/lib/utils/version';
|
||||
|
||||
interface NodeInfo {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
name?: string;
|
||||
html_url: string;
|
||||
published_at?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
const NODE_QUERY_KEY = ['node-version'] as const;
|
||||
const RELEASE_QUERY_KEY = ['routstr-latest-release'] as const;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
|
||||
const GITHUB_RELEASES_URL = `https://api.github.com/repos/Routstr/routstr-core/releases/latest`;
|
||||
const RELEASES_PAGE_URL = `https://github.com/Routstr/routstr-core/releases`;
|
||||
|
||||
async function fetchNodeInfo(): Promise<NodeInfo> {
|
||||
const baseUrl = ConfigurationService.getLocalBaseUrl().replace(/\/+$/, '');
|
||||
const response = await fetch(`${baseUrl}/v1/info`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load node info');
|
||||
}
|
||||
return (await response.json()) as NodeInfo;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<GithubRelease | null> {
|
||||
const response = await fetch(GITHUB_RELEASES_URL, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (response.status === 403 || response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub responded ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as GithubRelease;
|
||||
}
|
||||
|
||||
function pickColorClass(status: StatusKind): string {
|
||||
if (status === 'outdated') return 'text-amber-600 dark:text-amber-400';
|
||||
if (status === 'unknown') return 'text-muted-foreground';
|
||||
if (status === 'ahead' || status === 'commit-drift') {
|
||||
return 'text-sky-600 dark:text-sky-400';
|
||||
}
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
}
|
||||
|
||||
function renderStatusIcon(status: StatusKind, className: string) {
|
||||
if (status === 'outdated') {
|
||||
return <AlertTriangleIcon className={className} />;
|
||||
}
|
||||
if (status === 'commit-drift' || status === 'ahead' || status === 'unknown') {
|
||||
return <InfoIcon className={className} />;
|
||||
}
|
||||
return <CheckCircle2Icon className={className} />;
|
||||
}
|
||||
|
||||
function describeStatus(status: StatusKind): string {
|
||||
if (status === 'outdated') return 'A newer release is available.';
|
||||
if (status === 'commit-drift') {
|
||||
return 'Running release version on a non-release commit.';
|
||||
}
|
||||
if (status === 'ahead') {
|
||||
return 'Running ahead of the latest published release.';
|
||||
}
|
||||
if (status === 'current') return 'Up to date with the latest release.';
|
||||
return 'Version status unavailable.';
|
||||
}
|
||||
|
||||
interface VersionStatusProps {
|
||||
variant?: 'expanded' | 'compact';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VersionStatus({
|
||||
variant = 'expanded',
|
||||
className,
|
||||
}: VersionStatusProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const nodeQuery = useQuery({
|
||||
queryKey: NODE_QUERY_KEY,
|
||||
queryFn: fetchNodeInfo,
|
||||
staleTime: THIRTY_MINUTES,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
queryKey: RELEASE_QUERY_KEY,
|
||||
queryFn: fetchLatestRelease,
|
||||
staleTime: THIRTY_MINUTES,
|
||||
refetchInterval: THIRTY_MINUTES,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const currentVersion = parseVersion(nodeQuery.data?.version);
|
||||
const latestVersion = parseVersion(releaseQuery.data?.tag_name);
|
||||
const status = deriveStatus(currentVersion, latestVersion);
|
||||
|
||||
const isRefreshing = releaseQuery.isFetching || nodeQuery.isFetching;
|
||||
|
||||
const handleRefresh = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: NODE_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: RELEASE_QUERY_KEY }),
|
||||
]);
|
||||
};
|
||||
|
||||
const colorClass = pickColorClass(status);
|
||||
const versionLabel = currentVersion
|
||||
? formatVersionLabel(currentVersion)
|
||||
: nodeQuery.isLoading
|
||||
? '…'
|
||||
: 'unknown';
|
||||
|
||||
if (!nodeQuery.data && nodeQuery.isLoading && variant === 'expanded') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusDescription = describeStatus(status);
|
||||
const ariaLabel = `Node version ${versionLabel}. ${statusDescription} Click for details.`;
|
||||
|
||||
const releaseRateLimited = releaseQuery.data === null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'hover:bg-accent/40 inline-flex items-center gap-1 rounded-md px-1 py-0.5 font-mono text-[10px] leading-tight transition-colors',
|
||||
colorClass,
|
||||
className
|
||||
)}
|
||||
title='View version details'
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{renderStatusIcon(status, 'h-3 w-3 shrink-0')}
|
||||
<span className='truncate'>{versionLabel}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={6}
|
||||
className='w-72 p-3'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-2'>
|
||||
<div className='min-w-0 space-y-0.5'>
|
||||
<div className='flex items-center gap-1.5 text-sm font-medium'>
|
||||
{renderStatusIcon(status, cn('h-4 w-4 shrink-0', colorClass))}
|
||||
Node Version
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs leading-snug'>
|
||||
{statusDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-7 w-7 shrink-0'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
title='Check for latest release'
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Loader2Icon className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<RefreshCwIcon className='h-3.5 w-3.5' />
|
||||
)}
|
||||
<span className='sr-only'>Check for latest release</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-3 space-y-2'>
|
||||
<div className='border-border/60 bg-card/30 grid gap-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Current
|
||||
</span>
|
||||
<span className='font-mono text-xs'>{versionLabel}</span>
|
||||
</div>
|
||||
{currentVersion?.commit ? (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Commit
|
||||
</span>
|
||||
<code className='font-mono text-[11px]'>
|
||||
{currentVersion.commit}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='border-border/60 bg-card/30 grid gap-1.5 rounded-md border p-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Latest release
|
||||
</span>
|
||||
<span className='font-mono text-xs'>
|
||||
{releaseQuery.isLoading
|
||||
? 'loading…'
|
||||
: releaseQuery.isError
|
||||
? 'unavailable'
|
||||
: releaseRateLimited
|
||||
? 'rate-limited'
|
||||
: (releaseQuery.data?.tag_name ?? 'unknown')}
|
||||
</span>
|
||||
</div>
|
||||
{releaseQuery.data?.published_at ? (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-[10px] tracking-wide uppercase'>
|
||||
Published
|
||||
</span>
|
||||
<span className='text-[11px]'>
|
||||
{formatReleaseDate(releaseQuery.data.published_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{releaseQuery.isError ? (
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
Failed to fetch latest release from GitHub.
|
||||
</p>
|
||||
) : releaseRateLimited ? (
|
||||
<p className='text-muted-foreground text-[11px]'>
|
||||
GitHub rate limit reached. Try again later.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='border-border/60 mt-3 border-t pt-2'>
|
||||
<a
|
||||
href={releaseQuery.data?.html_url ?? RELEASES_PAGE_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
|
||||
>
|
||||
View release changelog
|
||||
<ExternalLinkIcon className='h-3 w-3' />
|
||||
</a>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -831,9 +831,18 @@ export class AdminService {
|
||||
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
|
||||
}
|
||||
|
||||
static async getTemporaryBalances(): Promise<TemporaryBalance[]> {
|
||||
return await apiClient.get<TemporaryBalance[]>(
|
||||
'/admin/api/temporary-balances'
|
||||
static async getTemporaryBalances(
|
||||
search?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<TemporaryBalancesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append('search', search);
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
return await apiClient.get<TemporaryBalancesResponse>(
|
||||
`/admin/api/temporary-balances?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -908,6 +917,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 +1043,21 @@ export const TemporaryBalanceSchema = z.object({
|
||||
refund_address: z.string().nullable(),
|
||||
key_expiry_time: z.number().nullable(),
|
||||
parent_key_hash: z.string().nullable().optional(),
|
||||
created_at: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||
|
||||
export interface TemporaryBalancesResponse {
|
||||
balances: TemporaryBalance[];
|
||||
total: number;
|
||||
totals: {
|
||||
total_balance: number;
|
||||
total_spent: number;
|
||||
total_requests: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageMetricData {
|
||||
timestamp: string;
|
||||
total_requests: number;
|
||||
@@ -1186,3 +1225,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;
|
||||
}
|
||||
|
||||
72
ui/lib/utils/version.ts
Normal file
72
ui/lib/utils/version.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface ParsedVersion {
|
||||
raw: string;
|
||||
base: string;
|
||||
parts: readonly number[];
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
export type StatusKind =
|
||||
| 'unknown'
|
||||
| 'outdated'
|
||||
| 'ahead'
|
||||
| 'commit-drift'
|
||||
| 'current';
|
||||
|
||||
export function parseVersion(
|
||||
raw: string | undefined | null
|
||||
): ParsedVersion | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim().replace(/^v/i, '');
|
||||
if (!trimmed) return null;
|
||||
const [base, commitPart] = trimmed.split('+', 2);
|
||||
const parts = (base ?? '')
|
||||
.split('.')
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (parts.length === 0) return null;
|
||||
return {
|
||||
raw,
|
||||
base: base ?? '',
|
||||
parts,
|
||||
commit: commitPart ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareVersionParts(
|
||||
a: readonly number[],
|
||||
b: readonly number[]
|
||||
): number {
|
||||
const length = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function deriveStatus(
|
||||
current: ParsedVersion | null,
|
||||
latest: ParsedVersion | null
|
||||
): StatusKind {
|
||||
if (!current || !latest) return 'unknown';
|
||||
const cmp = compareVersionParts(current.parts, latest.parts);
|
||||
if (cmp < 0) return 'outdated';
|
||||
if (cmp > 0) return 'ahead';
|
||||
return current.commit ? 'commit-drift' : 'current';
|
||||
}
|
||||
|
||||
export function formatReleaseDate(iso: string | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatVersionLabel(version: ParsedVersion | null): string {
|
||||
if (!version) return 'unknown';
|
||||
return version.raw.startsWith('v') ? version.raw : `v${version.raw}`;
|
||||
}
|
||||
16
uv.lock
generated
16
uv.lock
generated
@@ -2400,7 +2400,6 @@ dependencies = [
|
||||
{ name = "openai" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "secp256k1" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
@@ -2435,7 +2434,6 @@ requires-dist = [
|
||||
{ name = "openai", specifier = ">=1.98.0" },
|
||||
{ name = "pillow", specifier = ">=10" },
|
||||
{ name = "python-json-logger", specifier = ">=2.0.0" },
|
||||
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },
|
||||
{ name = "sqlmodel", specifier = ">=0.0.24" },
|
||||
{ name = "websockets", specifier = ">=12.0" },
|
||||
]
|
||||
@@ -2591,10 +2589,22 @@ wheels = [
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.14.0"
|
||||
source = { git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060#7d70a8ec7ca2db050d292c3759e49e75e21ac533" }
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/41/bb668a6e4192303542d2d90c3b38d564af3c17c61bd7d4039af4f29405fe/secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", size = 2420607, upload-time = "2021-11-06T01:36:10.707Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/12/4c9815a819816587df70aa38fe7d09b54724a0b1b9b8e8ea2af1c205f2a5/secp256k1-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539d1d9750299ec4e8df6211978ba78779f5095c7ef19985313f03d1d1b816bd", size = 1298105, upload-time = "2026-01-29T16:26:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/86/f01ee0f4c44e12933c460f2b868a3888b93a7c7f4e9fc9be173401b55e8d/secp256k1-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d597a59e3918b0e41181a1c872851ac2e6137882de7f0487b8c42b25333ada", size = 1498906, upload-time = "2026-01-29T16:26:30.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c8/79f2990b72556c3f416ecfde2116a08afb41e324f51b8bf61268d7b72715/secp256k1-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:393d189b4ada9ab3de0b053f484a3b7e86024f4b8cd36616c05f07dbae3ca180", size = 1494612, upload-time = "2026-01-29T16:26:32.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/e8/8dd140270b4e12a7f5876f1641f996854d700866352875f161f770b69ebb/secp256k1-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4ec14534c1e8b8991376915ef059b7a3e62366aeda60df50b3932ad6529d26a", size = 1298100, upload-time = "2026-01-29T16:26:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6c/e63892de8d7582ab30602ccc1cf0ecd88a30b1a09424eb847c863fd46d9f/secp256k1-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1041694e429eb465123cb742911d2aad5cbd9e0cf2891aaaf794a887938647d1", size = 1499269, upload-time = "2026-01-29T16:26:35.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/5c/2faa8c523c0204af249890eb51b697e9a19d59d101625149d7b4f482e894/secp256k1-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf03e6d45892172046d4e085d5cc91d13a73a465c0f4c8b5633d823b0ca667e2", size = 1494878, upload-time = "2026-01-29T16:26:37.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/27/702d5683d211644f4d286463d7b1c25aeed26275f7b0e2a5a8dc83e7a598/secp256k1-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d90725a63e8e1d6d1483a135649c30ba949185702d3e5acbc075cdab3a44a37f", size = 1298097, upload-time = "2026-01-29T16:26:39.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1e/928647ac138fddfb4c5ee8aa4140a5786e51c75e9062b7f8d1a0362565df/secp256k1-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cd60d76d95e2eb977edc6523d1178a496fa1634517b497d4cdc7c9aa5e93aa3", size = 1499198, upload-time = "2026-01-29T16:26:41.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/30/c4168076a3cd66ce8ddb28ea127a5f97b088452f1ccb2a3208219fc4f77b/secp256k1-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:245b91f4bfe3a151e3e361f7e7ed634744d35e87c9ac6cf3eb0e4269801d9f7e", size = 1494778, upload-time = "2026-01-29T16:26:43.167Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
|
||||
Reference in New Issue
Block a user