Compare commits

...

80 Commits

Author SHA1 Message Date
9qeklajc
9ef01acac9 Merge branch 'main' into pr-reactive-correction 2026-06-07 13:10:29 +02:00
9qeklajc
cbd38c15fe Merge pull request #543 from Routstr/pr-sse-parser
fix: buffer-based SSE parser for all supported providers
2026-06-07 13:06:16 +02:00
9qeklajc
06c8a071a5 feat: reactive request-correction retry for recoverable upstream 400s 2026-06-07 13:00:19 +02:00
9qeklajc
9e9c2bde57 fix: buffer-based SSE parser for all supported providers
Replace the fragile `re.split(b"data: ")` streaming parser with a
buffered, event-delimited parser in both the chat-completion and
Responses-API streamers. Events are accumulated until the SSE blank-line
delimiter, so parsing is independent of network chunk boundaries.

Fixes:
- OpenRouter `: OPENROUTER PROCESSING` keepalive comments no longer leak
  to clients as `data: : ...` (the `Unexpected token ':'` client crash).
- JSON payloads split across TCP reads are reassembled before parsing.
- CRLF framing (Gemini native alt=sse) handled.
- Combined content+usage chunks (Gemini thinking models over the
  OpenAI-compat endpoint) forward content once and still report usage in
  the cost trailer, instead of dropping the assistant message.
- Multi-line non-JSON `data` blocks are re-prefixed per line so they stay
  valid SSE framing for the client.

Adds tests/unit/test_streaming_sse_providers.py driving the real
generator against per-provider on-the-wire framing, and switches the
integration token-mint fallback to secrets.token_hex to kill a
PRNG/clock collision flake.
2026-06-07 12:58:54 +02:00
9qeklajc
222fd6ed45 Merge pull request #542 from Routstr/fix-model-provider-field-response
make sure model provider field naming is correct
2026-06-07 11:32:01 +02:00
9qeklajc
4abd751f5f make sure model provider field naming is correct 2026-06-07 11:01:53 +02:00
9qeklajc
8f89171db1 Merge pull request #541 from jeroenubbink/fix/lightning-invoice-key-constraints
fix: persist and propagate key constraints from Lightning invoices
2026-06-07 10:51:29 +02:00
9qeklajc
5dc9d60bec Merge pull request #540 from jeroenubbink/fix/child-key-atomic-balance-deduction
fix: make child key balance deduction atomic
2026-06-04 10:09:26 +02:00
Jeroen Ubbink
feb76bc89d fix: persist and propagate key constraints from Lightning invoices
LightningInvoice had no columns for balance_limit, balance_limit_reset,
or validity_date. SQLModel silently dropped these constructor kwargs, so
create_api_key_from_invoice always produced an unconstrained key.

Add the three columns to LightningInvoice with a migration, and wire them
through to the ApiKey in create_api_key_from_invoice, matching the pattern
already used in the child key creation path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:58:40 +02:00
Jeroen Ubbink
3b7f96560b fix: make child key balance deduction atomic
The previous in-memory deduction (key.balance -= cost) was a read-modify-
write on stale state, allowing two concurrent create_child_key() calls to
both pass the balance check and both succeed, effectively charging the
parent only once for two child keys.

Replace with an atomic UPDATE ... WHERE balance - reserved_balance >= cost
and check rowcount, matching the pattern already used in pay_for_request.
Also adds a concurrent integration test that reproduces the race and
confirms the fix holds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:02:12 +02:00
9qeklajc
23e206c93a Merge pull request #538 from Routstr/revert-sse-refactoring
revert sse refactoring
2026-06-02 14:00:48 +02:00
9qeklajc
3e0ca0daf9 revert sse refactoring 2026-06-02 13:58:43 +02:00
9qeklajc
d7bf1d6582 Merge pull request #536 from Routstr/add-created-date-to-api-key
add api key creation date
2026-06-02 00:20:57 +02:00
9qeklajc
955090bd91 Merge pull request #535 from Routstr/prevent-zero-balance-token
prevent zero balance token
2026-06-01 23:34:51 +02:00
9qeklajc
379c319e0d fix test 2026-06-01 23:30:20 +02:00
9qeklajc
aaa80d47bc add api key creation date 2026-06-01 23:27:26 +02:00
9qeklajc
9633483fa4 prevent zero balance token 2026-06-01 22:47:09 +02:00
9qeklajc
9aa3408905 Merge pull request #528 from bilthon/refactor/remove-refund-by-token-hash
Remove deprecated cashu-refund-by-hash endpoint
2026-05-31 16:39:22 +02:00
9qeklajc
31ff1fd90c Merge pull request #519 from Routstr/sse-buffer-refactor
refactor: rewrite SSE parsing with buffered double-newline delimiter
2026-05-31 11:13:10 +02:00
9qeklajc
b9622689ea Merge remote-tracking branch 'origin/main' into sse-buffer-refactor
# Conflicts:
#	routstr/upstream/base.py
2026-05-30 22:55:55 +02:00
9qeklajc
f9e8a3250d fix import 2026-05-30 21:28:54 +02:00
9qeklajc
ade2d19be6 Merge pull request #534 from Routstr/wrap-long-log-info
wrap long log to not overflow
2026-05-30 20:48:50 +02:00
9qeklajc
c4d0a1afba wrap long log to not overflow 2026-05-30 20:02:44 +02:00
9qeklajc
81817355cc Merge pull request #533 from Routstr/add-git-dep
add missing git dep. to display correct commit
2026-05-30 17:58:03 +02:00
root
d345d3b53f add missing git dep. to display correct commit 2026-05-30 17:56:03 +02:00
9qeklajc
9fe13e9733 Merge pull request #532 from Routstr/fix-compose
fix compose file
2026-05-30 17:31:23 +02:00
9qeklajc
ee79a305ba revert 2026-05-30 17:20:16 +02:00
9qeklajc
ed2c8c9fe2 fix compose file 2026-05-30 17:00:57 +02:00
9qeklajc
9161256d31 Merge pull request #529 from Routstr/rip-08-lightning-invoice
better handling invoice payment
2026-05-30 16:39:11 +02:00
9qeklajc
1a8f407142 Merge pull request #531 from Routstr/remove-secp256-dep
remove secp256k1 dependency
2026-05-29 13:34:31 +02:00
9qeklajc
eddc070628 remove secp256k1 dependency 2026-05-28 21:09:36 +02:00
9qeklajc
f7bd250c97 better handling invoice payment 2026-05-28 20:09:48 +02:00
Bilthon
f2ef63da62 refactor(balance): remove unreachable cashu-refund-by-hash endpoint 2026-05-28 10:25:25 -05:00
9qeklajc
29cfeaed9a Merge pull request #523 from Routstr/rip-08-lightning-invoice
add rop-08 lightning invoice support
2026-05-21 00:28:41 +02:00
9qeklajc
e2b46dab9f Merge pull request #522 from Routstr/fix-balance-info
use correct var to report balance info
2026-05-21 00:28:28 +02:00
9qeklajc
632244e54f add rop-08 lightning invoice support 2026-05-20 23:24:24 +02:00
9qeklajc
3251664513 use correct var to report balance info 2026-05-20 23:00:03 +02:00
9qeklajc
ee16cd0495 Merge pull request #518 from Routstr/tee-fixes
fix: tee GET passthrough and encoding fix
2026-05-19 22:31:19 +02:00
redshift
f80d59182f refactor: rewrite SSE parsing with buffered double-newline delimiter
- Replace regex split on 'data: ' with proper SSE buffering using \n\n
  as the event separator, handling partial chunks across boundaries
- Fix [DONE] detection to match 'data: [DONE]' instead of bare '[DONE]'
- Add debug logging for SSE buffer size, parsed events, and stream end
- Distinguish billing-only (usage) chunks from content-bearing chunks;
  hold back usage-only chunks for later cost metadata injection
- Extract JSON payload from 'data: ...' prefix instead of raw part
- Gracefully pass through non-JSON SSE events
2026-05-19 10:54:16 +08:00
redshift
70ef3c357c fixed the encoding bug. 2026-05-19 10:53:59 +08:00
redshift
e4165f1dab passing through tee get requests 2026-05-19 10:53:59 +08:00
9qeklajc
41edae52b0 Merge pull request #513 from Routstr/add-provider-field-to-response
add provider field to response
2026-05-18 22:06:10 +02:00
9qeklajc
cb16da5543 Merge pull request #517 from Routstr/selinux-podman-fix
fix: add SELinux :z labels and user root to podman-compose volumes
2026-05-18 20:28:10 +02:00
redshift
d52b727bce fix: add SELinux :z labels and user root to podman-compose volumes
- Added :z (shared SELinux label) to all host bind-mount volumes
  so containers can write when SELinux is enforcing
- Set user: root on the ui service for compatibility with
  rootless podman's UID mapping
2026-05-19 01:44:51 +08:00
9qeklajc
6a2abd3439 Merge pull request #515 from Routstr/fix-value-not-set-issue
fix missing field initialization
2026-05-17 14:46:33 +02:00
9qeklajc
1e4ed75179 fix missing field initialization 2026-05-17 14:43:50 +02:00
9qeklajc
efb5719679 add provider field to response 2026-05-17 14:39:16 +02:00
9qeklajc
3a1902b2b5 Merge pull request #512 from Routstr/sidebar-display-logo
better logo display
2026-05-16 23:56:20 +02:00
9qeklajc
ba1978f830 better logo display 2026-05-16 23:52:03 +02:00
9qeklajc
2f51d62a46 Merge pull request #511 from Routstr/enforce-provider-fee-calculation
enforce fee calculation
2026-05-16 23:17:13 +02:00
9qeklajc
1ca344ba7b enforce fee calculation 2026-05-16 23:13:47 +02:00
9qeklajc
dda9b848ba Merge pull request #510 from Routstr/display-correct-version
display correct commit
2026-05-16 21:14:26 +02:00
9qeklajc
033d8c5a38 display correct commit 2026-05-16 19:10:45 +02:00
9qeklajc
e73cdffdc5 Merge pull request #508 from Routstr/display-official-version
display version
2026-05-16 15:46:43 +02:00
9qeklajc
e32b332eb5 Merge pull request #509 from Routstr/reduce-verbose-logs
less verbose logs and more precise
2026-05-16 15:41:20 +02:00
9qeklajc
89b84392a4 lint 2026-05-16 15:40:58 +02:00
9qeklajc
f37ff30598 display version 2026-05-16 15:35:16 +02:00
9qeklajc
547f45c185 less verbose logs and more precise 2026-05-16 15:29:41 +02:00
9qeklajc
a6e81a83cb Merge pull request #507 from Routstr/fix-provider-forwarding-upstream
Fix provider forwarding upstream
2026-05-15 20:45:19 +02:00
9qeklajc
e2143aa173 Revert "fix home nav"
This reverts commit 2eb257c2a6.
2026-05-14 15:51:20 +02:00
9qeklajc
2eb257c2a6 fix home nav 2026-05-14 15:47:59 +02:00
9qeklajc
490687bb71 Merge branch 'main' into fix-provider-forwarding-upstream 2026-05-14 15:40:52 +02:00
9qeklajc
966613847e update not found proxy 2026-05-14 15:40:49 +02:00
9qeklajc
d030d86f9a Merge pull request #506 from Routstr/fix-swapping
fix swapping
2026-05-14 14:32:39 +02:00
9qeklajc
d9ab46b3bb make sure when topup from untrusted mint to refund from primary mint 2026-05-14 13:52:25 +02:00
9qeklajc
6e596e3860 Merge pull request #504 from Routstr/do-not-create-empty-token
no key creation when refund
2026-05-13 22:48:16 +02:00
9qeklajc
3c13be20cb no key creation when refund 2026-05-13 22:34:24 +02:00
9qeklajc
9d5e14903b fix swapping 2026-05-13 21:49:03 +02:00
9qeklajc
9e33d3b100 Merge pull request #503 from Routstr/fix-docker-build
add missing path
2026-05-10 11:21:04 +02:00
9qeklajc
c88665f6d1 Merge pull request #500 from Routstr/enforce-loading-keysets
enforce load mint keyset
2026-05-10 11:20:53 +02:00
9qeklajc
a8157b3e2d add missing path 2026-05-10 11:18:27 +02:00
9qeklajc
a07e6723d9 Merge pull request #502 from Routstr/fix-docker-build
pin pnpm version
2026-05-10 10:44:44 +02:00
9qeklajc
5bc7b741bc pin pnpm version 2026-05-10 10:42:17 +02:00
9qeklajc
1db37cf084 Merge pull request #501 from Routstr/fix-docker-build
build fix
2026-05-10 10:20:13 +02:00
9qeklajc
6c5b103149 build fix 2026-05-10 10:18:07 +02:00
9qeklajc
27ec052c17 Merge pull request #499 from Routstr/emulate-claude-token-count
support /message/count_tokens endpoint
2026-05-10 08:45:58 +02:00
9qeklajc
00b813f6a8 clean up 2026-05-10 08:43:47 +02:00
9qeklajc
91e5198a94 Merge pull request #498 from Routstr/fix-docker-build
fix-docker-build
2026-05-10 08:43:02 +02:00
9qeklajc
a4d048f2b5 fix-docker-build 2026-05-10 08:31:05 +02:00
9qeklajc
164ed775c8 support /message/count_tokens endpoint 2026-05-09 14:43:03 +02:00
52 changed files with 4099 additions and 762 deletions

View File

@@ -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

View File

@@ -4,7 +4,7 @@ FROM node:23-alpine AS ui-builder
WORKDIR /app/ui
# Install pnpm
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
@@ -16,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"]

View File

@@ -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:

View File

@@ -396,6 +396,8 @@ Authorization: Bearer sk-...
}
```
`balance` is the spendable balance used by request admission.
### Check Balance
Get current wallet balance.

View File

@@ -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

View File

@@ -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-...`.

View File

@@ -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")

View File

@@ -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")

View File

@@ -14,7 +14,6 @@ dependencies = [
"alembic>=1.13",
"python-json-logger>=2.0.0",
"cashu>=0.20",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
@@ -87,4 +86,3 @@ disallow_untyped_decorators = true
[tool.uv.sources]
routstr = { workspace = true }
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }

View File

@@ -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)

View File

@@ -45,7 +45,7 @@ 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,
@@ -211,8 +211,20 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
_refund_cache[key] = (expiry, value)
async def _lookup_key_no_create(
bearer_value: str, session: AsyncSession
) -> ApiKey | None:
"""Look up an existing API key without creating one Used by the refund endpoint"""
if bearer_value.startswith("sk-"):
return await session.get(ApiKey, bearer_value[3:])
if bearer_value.startswith("cashu"):
hashed = hashlib.sha256(bearer_value.encode()).hexdigest()
return await session.get(ApiKey, hashed)
return None
async def _restore_balance(
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str
) -> None:
"""Restore balance after a failed refund mint attempt."""
restore_stmt = (
@@ -227,7 +239,7 @@ async def _restore_balance(
await session.commit()
logger.info(
"refund_wallet_endpoint: balance restored after mint failure",
extra={"hashed_key": hashed_key, "restored_balance": balance},
extra={"hashed_key": hashed_key, "restored_balance": balance, "mint_url": mint_url},
)
@@ -282,7 +294,12 @@ async def refund_wallet_endpoint(
)
bearer_value: str = authorization[7:]
key: ApiKey = await validate_bearer_key(bearer_value, session)
key: ApiKey | None = await _lookup_key_no_create(bearer_value, session)
if key is None:
raise HTTPException(
status_code=401,
detail="Key not found. Deposit first via /v1/wallet/create before requesting a refund.",
)
if key.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
@@ -337,21 +354,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}
@@ -373,21 +395,32 @@ async def refund_wallet_endpoint(
except HTTPException:
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
raise
except Exception as e:
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
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)
@@ -502,10 +535,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
@@ -530,6 +577,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,
@@ -576,26 +624,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}",
@@ -609,7 +637,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)

View File

@@ -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)],

View File

@@ -45,6 +45,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",
@@ -132,6 +140,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

View File

@@ -13,6 +13,7 @@ from starlette.types import Scope
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
from ..lightning import lightning_router, periodic_invoice_watcher
from ..nostr import (
announce_provider,
providers_cache_refresher,
@@ -30,6 +31,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__
@@ -55,6 +57,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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,
@@ -122,6 +125,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
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
@@ -161,6 +165,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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 = []
@@ -188,6 +194,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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)
@@ -364,6 +372,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
View 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -336,6 +336,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 +363,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)

View File

@@ -1,9 +1,8 @@
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,6 +17,7 @@ from .core.db import (
get_session,
)
from .core.exceptions import UpstreamError
from .core.not_found import build_not_found_response
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
@@ -28,6 +28,7 @@ from .payment.helpers import (
from .payment.models import Model
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
from .upstream.request_correction import correct_request, extract_error_message
logger = get_logger(__name__)
proxy_router = APIRouter()
@@ -151,50 +152,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 +184,40 @@ 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_error_response(
"upstream_error", str(e), 502, request=request
)
continue
return last_error_response or create_error_response(
"upstream_error", "All upstreams failed", 502, request=request
)
if is_responses_api:
model_id = extract_model_from_responses_request(request_body_dict)
else:
@@ -252,7 +268,15 @@ async def proxy(
)
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
"Upstream %s failed (x-cashu) for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
},
)
if i == len(upstreams) - 1:
last_error = e
@@ -329,50 +353,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 +457,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,16 +472,20 @@ 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
@@ -426,8 +494,16 @@ async def proxy(
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
"""Local handling of Anthropic ``/v1/messages/count_tokens`` for upstreams
that do not natively expose the endpoint.
Most non-Anthropic upstreams (OpenAI-compat, Gemini OpenAI-compat,
OpenRouter chat-completions, generic providers) return 400/404 when asked
to ``POST /messages/count_tokens``. Claude Code and other Anthropic SDK
clients call this endpoint before each turn to size context windows and
trigger compaction, so a failure breaks the whole chat.
We answer locally. ``litellm.token_counter`` understands the Anthropic
message shape and the per-model tokenizers, so we prefer it. If it raises
(unknown model, encoding lookup failure, ...), we fall back to the
project's own ``estimate_tokens`` heuristic, which is always defined and
never raises.
"""
from __future__ import annotations
import json
from typing import Any
import litellm
from fastapi.responses import Response
from ..core import get_logger
from ..payment.helpers import estimate_tokens
from ..payment.models import Model
logger = get_logger(__name__)
def _parse_request_body(request_body: bytes | None) -> dict[str, Any]:
if not request_body:
return {}
try:
parsed = json.loads(request_body)
except (ValueError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
messages = body.get("messages")
if not isinstance(messages, list):
messages = []
system = body.get("system")
if isinstance(system, str) and system:
messages = [{"role": "system", "content": system}, *messages]
elif isinstance(system, list):
text = "".join(
block.get("text", "")
for block in system
if isinstance(block, dict) and block.get("type") == "text"
)
if text:
messages = [{"role": "system", "content": text}, *messages]
tools = body.get("tools") if isinstance(body.get("tools"), list) else None
return int(
litellm.token_counter(
model=model,
messages=messages,
tools=tools,
)
)
def count_tokens_locally(
request_body: bytes | None,
model_obj: Model | None,
) -> Response:
"""Return an Anthropic-compatible count_tokens response without
touching the upstream. Always returns 200; never raises."""
body = _parse_request_body(request_body)
model_name = ""
if model_obj is not None:
model_name = model_obj.forwarded_model_id or model_obj.id or ""
if not model_name:
body_model = body.get("model")
if isinstance(body_model, str):
model_name = body_model
input_tokens: int
try:
input_tokens = _count_with_litellm(model_name, body)
except Exception as exc:
messages = body.get("messages")
fallback_messages = messages if isinstance(messages, list) else []
input_tokens = estimate_tokens(fallback_messages)
logger.debug(
"litellm token_counter failed; using local estimator",
extra={
"model": model_name,
"error": str(exc),
"error_type": type(exc).__name__,
"estimated_tokens": input_tokens,
},
)
payload = {"input_tokens": max(0, int(input_tokens))}
return Response(
content=json.dumps(payload).encode(),
status_code=200,
media_type="application/json",
)

View File

@@ -18,6 +18,33 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
supports_anthropic_messages = True
litellm_provider_prefix = "openrouter/"
def _apply_provider_field(self, response_json: object) -> None:
"""Stamp the ``provider`` field for OpenRouter responses.
OpenRouter is a router, not the real serving provider, so a bare
``"openrouter"`` value carries no useful information. Rules:
- Real upstream sub-provider (e.g. ``"GMICloud"``) -> ``"openrouter:GMICloud"``.
- Missing sub-provider, or one that merely echoes ``"openrouter"`` ->
``"unknown"``.
- Idempotent: re-stamping never produces ``"openrouter:openrouter:..."``;
the ``openrouter:`` prefix appears at most once.
"""
if not isinstance(response_json, dict):
return
provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider")
sub = existing.strip() if isinstance(existing, str) else ""
# Strip any already-applied "openrouter:" prefixes (idempotency).
prefix = f"{provider_type}:"
while sub.lower().startswith(prefix.lower()):
sub = sub[len(prefix) :].strip()
# No real sub-provider, or it just echoes our own router name.
if not sub or sub.lower() == provider_type.lower():
response_json["provider"] = "unknown"
return
response_json["provider"] = f"{provider_type}:{sub}"
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.

View 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

View File

@@ -58,17 +58,56 @@ async def recieve_token(
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
"""Internal send function - returns amount and serialized token"""
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url or settings.primary_mint, unit
effective_mint_url = mint_url or settings.primary_mint
wallet: Wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs_for_mint = sum(p.amount for p in proofs)
# Fallback: proofs from untrusted source mints are swapped to primary_mint
# during receive, so the user's preferred refund_mint_url may have no proofs
# even though the global wallet has the balance.
if proofs_for_mint < amount and effective_mint_url != settings.primary_mint:
logger.info(
f"send: insufficient proofs at {effective_mint_url} "
f"(have {proofs_for_mint}, need {amount}), falling back to primary_mint={settings.primary_mint}"
)
effective_mint_url = settings.primary_mint
wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs_for_mint = sum(p.amount for p in proofs)
all_mint_urls = list({k.mint_url for k in wallet.keysets.values()})
proof_summary = {
f"{k.mint_url}/{k.unit.name}": sum(p.amount for p in wallet.proofs if p.id == k.id)
for k in wallet.keysets.values()
}
# Show ALL proofs in DB by keyset_id, regardless of whether the loaded wallet
# knows about that keyset. This reveals proofs orphaned under stale keysets.
raw_proofs_by_keyset: dict[str, int] = {}
for p in wallet.proofs:
raw_proofs_by_keyset[p.id] = raw_proofs_by_keyset.get(p.id, 0) + p.amount
logger.info(
f"send: proof inventory | mint={effective_mint_url} unit={unit} amount={amount} "
f"primary_mint={settings.primary_mint} proofs_for_mint={proofs_for_mint} "
f"all_mints={all_mint_urls} by_keyset={proof_summary} "
f"raw_proofs_by_keyset_id={raw_proofs_by_keyset} "
f"total_wallet_proofs={sum(p.amount for p in wallet.proofs)}"
)
# Reserve proofs only after serialization succeeds — if serialize_proofs or
# swap_to_send fails mid-way, proofs stay unreserved so dashboard balance
# doesn't go negative.
send_proofs, _ = await wallet.select_to_send(
proofs, amount, set_reserved=True, include_fees=False
)
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
proofs, amount, set_reserved=False, include_fees=False
)
try:
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
)
except Exception:
await wallet.set_reserved_for_send(send_proofs, reserved=False)
raise
await wallet.set_reserved_for_send(send_proofs, reserved=True)
return amount, token
@@ -83,10 +122,11 @@ async def _calculate_swap_amount(
token_mint_url: str,
token_wallet: Wallet,
primary_wallet: Wallet,
proofs: list,
) -> int:
"""
Calculate the amount to mint on the primary mint after accounting for
potential swap fees (melt fees) on the foreign mint.
melt fees and NUT-02 input fees on the foreign mint.
"""
if settings.primary_mint_unit == "sat":
receive_amount = amount_msat // 1000
@@ -113,10 +153,11 @@ async def _calculate_swap_amount(
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
fee_reserve = dummy_melt_quote.fee_reserve
input_fees = token_wallet.get_fees_for_proofs(proofs)
if token_unit == "sat":
fee_msat = fee_reserve * 1000
fee_msat = (fee_reserve + input_fees) * 1000
else:
fee_msat = fee_reserve
fee_msat = fee_reserve + input_fees
amount_msat_after_fee = amount_msat - fee_msat
@@ -126,13 +167,14 @@ async def _calculate_swap_amount(
minted_amount = int(amount_msat_after_fee)
if minted_amount <= 0:
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
raise ValueError(f"Fees ({fee_reserve + input_fees} {token_unit}) exceed token amount")
logger.info(
"swap_to_primary_mint: fee estimation result",
extra={
"token_amount_sat": amount_msat // 1000,
"estimated_fee_sat": fee_msat // 1000,
"input_fees": input_fees,
"minted_amount": minted_amount,
"minted_unit": settings.primary_mint_unit,
},
@@ -193,6 +235,7 @@ async def swap_to_primary_mint(
token_obj.mint,
token_wallet,
primary_wallet,
token_obj.proofs,
)
mint_quote = await primary_wallet.request_mint(minted_amount)
@@ -202,13 +245,15 @@ async def swap_to_primary_mint(
)
melt_quote = await token_wallet.melt_quote(mint_quote.request)
total_needed = melt_quote.amount + melt_quote.fee_reserve
input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs)
total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
logger.info(
"swap_to_primary_mint: melt quote received",
extra={
"melt_quote_id": melt_quote.quote,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"input_fees": input_fees,
"total_needed": total_needed,
"token_amount": token_amount,
},
@@ -221,6 +266,7 @@ async def swap_to_primary_mint(
"token_amount": token_amount,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"input_fees": input_fees,
"total_needed": total_needed,
"shortfall": total_needed - token_amount,
},
@@ -228,7 +274,7 @@ async def swap_to_primary_mint(
raise ValueError(
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
f"melt fees. Needed: {total_needed} {token_obj.unit} "
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve} + input_fees: {input_fees})"
)
try:
@@ -259,19 +305,66 @@ async def swap_to_primary_mint(
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
)
await primary_wallet.load_proofs(reload=True)
pre_mint_balance = primary_wallet.available_balance.amount
try:
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
except Exception as e:
logger.error(
"swap_to_primary_mint: mint on primary failed after successful melt",
extra={
"error": str(e),
"error_type": type(e).__name__,
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
},
)
raise
if "11003" in str(e) or "outputs already signed" in str(e).lower():
# Previous mint call signed outputs at the mint but failed before
# bump_secret_derivation ran locally. Recover orphaned proofs and
# advance the counter so the next request derives fresh secrets.
logger.warning(
"swap_to_primary_mint: outputs already signed — recovering orphaned proofs",
extra={"mint_quote_id": mint_quote.quote, "minted_amount": minted_amount},
)
try:
for keyset_id in primary_wallet.keysets:
await primary_wallet.restore_tokens_for_keyset(keyset_id, to=1, batch=25)
await primary_wallet.load_proofs(reload=True)
post_recovery_balance = primary_wallet.available_balance.amount
balance_gained = post_recovery_balance - pre_mint_balance
logger.info(
"swap_to_primary_mint: recovery scan completed",
extra={
"pre_mint_balance": pre_mint_balance,
"post_recovery_balance": post_recovery_balance,
"balance_gained": balance_gained,
"expected": minted_amount,
},
)
if balance_gained < minted_amount:
# Recovery scan ran but did NOT restore the orphaned proofs
# (mint reports them as spent — they're stuck). Refuse to
# credit the API key balance for proofs we don't actually hold.
raise ValueError(
f"Swap recovery failed: mint signed outputs but proofs are "
f"unrecoverable (mint reports them spent). "
f"Expected {minted_amount}, recovered {balance_gained}. "
f"Local wallet DB ('.wallet/') state is corrupted — "
f"the counter for keyset is stuck at a bad index range."
)
except ValueError:
raise
except Exception as recovery_err:
logger.error(
"swap_to_primary_mint: recovery failed",
extra={"error": str(recovery_err)},
)
raise ValueError(
f"Mint on primary failed and recovery unsuccessful: {e}"
) from e
else:
logger.error(
"swap_to_primary_mint: mint on primary failed after successful melt",
extra={
"error": str(e),
"error_type": type(e).__name__,
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
},
)
raise
logger.info(
"swap_to_primary_mint: completed successfully",
@@ -310,6 +403,20 @@ async def credit_balance(
"credit_balance: Converted to msat", extra={"amount_msat": amount}
)
# Guard against zero/negative redemptions (empty or dust tokens, or
# swap-to-primary-mint amounts that net to <= 0 after fees). Raising here
# — before the UPDATE/commit below — leaves any freshly-created, still
# uncommitted ApiKey row to be rolled back when the request session
# closes, instead of persisting an orphan key with balance 0.
if amount <= 0:
logger.error(
"credit_balance: Redeemed amount is zero or negative; refusing to credit",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
raise ValueError(
f"Redeemed token amount must be positive, got {amount} msats"
)
logger.info(
"credit_balance: Updating balance",
extra={"old_balance": key.balance, "credit_amount": amount},
@@ -445,7 +552,7 @@ async def fetch_all_balances(
"unit": unit,
"wallet_balance": proofs_balance,
"user_balance": user_balance,
"owner_balance": proofs_balance - user_balance,
"owner_balance": proofs_balance - user_balance if proofs_balance != 0 else 0,
}
return result
except Exception as e:
@@ -493,7 +600,9 @@ async def fetch_all_balances(
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
owner_balance = 0
if total_wallet_balance_sats != 0:
owner_balance = total_wallet_balance_sats - total_user_balance_sats
return (
balance_details,

View File

@@ -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",

View File

@@ -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(

View File

@@ -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
# ---------------------------------------------------------------------------

View 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

View 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

View 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)

View File

@@ -372,17 +372,17 @@ async def test_refund_rejects_concurrent_topup_on_same_key(
topup_amount_sat = 500
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
validate_called = asyncio.Event()
key_looked_up = asyncio.Event()
allow_refund_to_continue = asyncio.Event()
original_validate_bearer_key = balance_module.validate_bearer_key
original_lookup = balance_module._lookup_key_no_create
delayed_once = False
async def delayed_validate_bearer_key(*args: Any, **kwargs: Any) -> ApiKey:
async def delayed_lookup_key_no_create(*args: Any, **kwargs: Any) -> ApiKey | None:
nonlocal delayed_once
key = await original_validate_bearer_key(*args, **kwargs)
if not delayed_once:
key = await original_lookup(*args, **kwargs)
if not delayed_once and key is not None:
delayed_once = True
validate_called.set()
key_looked_up.set()
await allow_refund_to_continue.wait()
return key
@@ -390,7 +390,7 @@ async def test_refund_rejects_concurrent_topup_on_same_key(
return await authenticated_client.post("/v1/wallet/refund")
async def issue_topup() -> Any:
await validate_called.wait()
await key_looked_up.wait()
try:
return await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
@@ -399,7 +399,7 @@ async def test_refund_rejects_concurrent_topup_on_same_key(
allow_refund_to_continue.set()
with patch(
"routstr.balance.validate_bearer_key", new=delayed_validate_bearer_key
"routstr.balance._lookup_key_no_create", new=delayed_lookup_key_no_create
):
refund_response, topup_response = await asyncio.gather(
issue_refund(), issue_topup()

View File

@@ -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

View File

@@ -168,12 +168,12 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
refund_token = "cashuArefund_apikey_token"
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
@@ -203,12 +203,12 @@ async def test_apikey_refund_logs_token() -> None:
refund_token = "cashuAlogged_token"
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -232,12 +232,12 @@ async def test_apikey_refund_log_includes_path() -> None:
refund_token = "cashuApath_token"
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -269,6 +269,7 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
session = MagicMock()
session.get = AsyncMock(return_value=key)
# Debit returns rowcount=0 → balance changed concurrently
session.exec = AsyncMock(return_value=_update_result(0))
session.commit = AsyncMock()
@@ -276,7 +277,6 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", mock_send_token),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -333,11 +333,11 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
# First exec call = debit (succeeds), second = restore
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -355,3 +355,49 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
assert exc_info.value.status_code == 503
# Verify two exec calls: debit + restore
assert session.exec.await_count == 2
# ---------------------------------------------------------------------------
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_refund_fresh_cashu_bearer_returns_401() -> None:
"""Fresh Cashu token not in DB must get 401, never create a new ApiKey."""
from fastapi import HTTPException
session = MagicMock()
session.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer cashuAfresh_never_deposited_token",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 401
session.get.assert_awaited_once()
# No add/commit → no key was persisted
session.add.assert_not_called()
session.commit.assert_not_called()
@pytest.mark.asyncio
async def test_refund_unknown_sk_bearer_returns_401() -> None:
"""Unknown sk- key not in DB must get 401."""
from fastapi import HTTPException
session = MagicMock()
session.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-unknownhash",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 401
session.get.assert_awaited_once()

View File

@@ -0,0 +1,177 @@
"""Unit tests for the local count_tokens shim.
The shim runs whenever an upstream that does not support Anthropic's
``/v1/messages`` endpoint is asked for a token count. It must always
return a 200 JSON ``{"input_tokens": N}`` response and must never raise.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import patch
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream import count_tokens as count_tokens_module
from routstr.upstream.count_tokens import count_tokens_locally
def _make_model(model_id: str = "anthropic/claude-3-5-sonnet") -> Model:
pricing = Pricing(prompt=0.000003, completion=0.000015)
architecture = Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="cl100k_base",
instruct_type=None,
)
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=200_000,
architecture=architecture,
pricing=pricing,
)
def _body(payload: dict[str, Any]) -> bytes:
return json.dumps(payload).encode()
def _read_payload(response: Any) -> dict[str, Any]:
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
return json.loads(body.decode())
def test_returns_input_tokens_for_simple_messages() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "hello world"}],
}
)
response = count_tokens_locally(request_body, model)
assert response.status_code == 200
assert response.media_type == "application/json"
payload = _read_payload(response)
assert "input_tokens" in payload
assert isinstance(payload["input_tokens"], int)
assert payload["input_tokens"] >= 0
def test_falls_back_to_estimator_when_litellm_raises() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "this is a longer message"}],
}
)
with patch.object(
count_tokens_module,
"_count_with_litellm",
side_effect=RuntimeError("boom"),
):
response = count_tokens_locally(request_body, model)
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 1
def test_handles_missing_model_object() -> None:
request_body = _body(
{
"model": "anthropic/claude-3-5-sonnet",
"messages": [{"role": "user", "content": "hi"}],
}
)
response = count_tokens_locally(request_body, None)
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 0
def test_handles_empty_request_body() -> None:
response = count_tokens_locally(b"", _make_model())
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 0
def test_handles_malformed_json() -> None:
response = count_tokens_locally(b"not-json", _make_model())
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 0
def test_includes_system_prompt_in_count() -> None:
model = _make_model()
short = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "hi"}],
}
)
with_system = _body(
{
"model": model.id,
"system": "You are a helpful assistant with a long preamble " * 10,
"messages": [{"role": "user", "content": "hi"}],
}
)
short_count = _read_payload(count_tokens_locally(short, model))["input_tokens"]
long_count = _read_payload(count_tokens_locally(with_system, model))["input_tokens"]
assert long_count > short_count
def test_supports_anthropic_system_block_list() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"system": [{"type": "text", "text": "be terse" * 50}],
"messages": [{"role": "user", "content": "ok"}],
}
)
response = count_tokens_locally(request_body, model)
payload = _read_payload(response)
assert payload["input_tokens"] > 0
def test_uses_forwarded_model_id_when_present() -> None:
model = _make_model("anthropic/claude-3-5-sonnet")
model.forwarded_model_id = "claude-3-5-sonnet-20241022"
request_body = _body(
{
"model": "ignored",
"messages": [{"role": "user", "content": "hi"}],
}
)
captured: dict[str, Any] = {}
def _capture(model_name: str, body: dict[str, Any]) -> int:
captured["model"] = model_name
return 7
with patch.object(count_tokens_module, "_count_with_litellm", side_effect=_capture):
response = count_tokens_locally(request_body, model)
assert captured["model"] == "claude-3-5-sonnet-20241022"
assert _read_payload(response)["input_tokens"] == 7

View File

@@ -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",
)
@@ -908,7 +907,7 @@ async def test_forward_request_skips_litellm_when_provider_supports_messages() -
@pytest.mark.asyncio
async def test_forward_request_skips_litellm_for_count_tokens() -> None:
async def test_forward_request_handles_count_tokens_locally() -> None:
provider = _make_provider()
key = _make_key()
model = _make_model()
@@ -923,19 +922,25 @@ async def test_forward_request_skips_litellm_for_count_tokens() -> None:
with patch.object(
provider,
"prepare_request_body",
side_effect=RuntimeError("stop here"),
side_effect=AssertionError("upstream should not be called"),
):
with pytest.raises(RuntimeError, match="stop here"):
await provider.forward_request(
request=request,
path="messages/count_tokens",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
response = await provider.forward_request(
request=request,
path="messages/count_tokens",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
assert response.status_code == 200
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
payload = json.loads(body.decode())
assert "input_tokens" in payload
assert isinstance(payload["input_tokens"], int)
assert payload["input_tokens"] >= 0
# ---------------------------------------------------------------------------
@@ -966,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()
@@ -1010,7 +1014,7 @@ async def test_forward_x_cashu_request_skips_litellm_when_native_messages() -> N
@pytest.mark.asyncio
async def test_forward_x_cashu_request_skips_litellm_for_count_tokens() -> None:
async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
provider = _make_provider()
model = _make_model()
request = _make_request()
@@ -1024,18 +1028,23 @@ async def test_forward_x_cashu_request_skips_litellm_for_count_tokens() -> None:
with patch.object(
provider,
"prepare_request_body",
side_effect=RuntimeError("stop here"),
side_effect=AssertionError("upstream should not be called"),
):
with pytest.raises(RuntimeError, match="stop here"):
await provider.forward_x_cashu_request(
request=request,
path="v1/messages/count_tokens",
headers={},
amount=5_000,
unit="sat",
max_cost_for_model=10_000,
model_obj=model,
)
response = await provider.forward_x_cashu_request(
request=request,
path="v1/messages/count_tokens",
headers={},
amount=5_000,
unit="sat",
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint",
)
assert response.status_code == 200
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
payload = json.loads(body.decode())
assert "input_tokens" in payload
# ---------------------------------------------------------------------------

View 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"

View File

@@ -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"

View 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"}

View File

@@ -0,0 +1,339 @@
"""Battle-test the streaming SSE parser against real per-provider framing.
Each test drives the *actual* ``handle_streaming_chat_completion`` generator
with a mock upstream response whose ``aiter_bytes`` emits byte sequences that
mirror what each supported provider sends on the wire (captured from the
providers' own streaming docs):
* OpenAI / Groq / Fireworks / xAI / Perplexity / Azure - plain
``data: {json}\\n\\n`` + ``data: [DONE]``.
* OpenRouter - same, but with ``: OPENROUTER PROCESSING`` keepalive comments
interleaved (the framing that produced the original
``Unexpected token ':'`` client crash).
* Gemini (native ``alt=sse``) - ``data:`` payloads framed with CRLF.
The invariant every provider must satisfy: every line the proxy emits that
starts with ``data: `` either equals ``[DONE]`` or is valid JSON, and no SSE
comment ever reaches the client. That invariant is exactly what the buggy
``re.split(b"data: ")`` parser violated for OpenRouter.
"""
import json
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock
import pytest
from routstr.core.db import ApiKey
from routstr.upstream import base
from routstr.upstream.base import BaseUpstreamProvider
def _make_response(chunks: list[bytes]) -> MagicMock:
async def aiter_bytes() -> AsyncGenerator[bytes, None]:
for chunk in chunks:
yield chunk
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/event-stream"}
mock_response.aiter_bytes = aiter_bytes
return mock_response
async def _drive(chunks: list[bytes], requested_model: str | None = None) -> list[bytes]:
"""Run the real streaming generator over ``chunks`` and collect output bytes."""
provider = BaseUpstreamProvider(
base_url="https://api.example.com", api_key="test_key"
)
key = MagicMock(spec=ApiKey)
key.hashed_key = "test_hash"
key.balance = 1000
base.adjust_payment_for_tokens = AsyncMock(
return_value={"total_usd": 0.1, "total_msats": 100}
)
mock_session = MagicMock()
mock_session.get = AsyncMock(return_value=key)
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
base.create_session = MagicMock(return_value=mock_ctx)
streaming_response = await provider.handle_streaming_chat_completion(
response=_make_response(chunks),
key=key,
max_cost_for_model=100,
background_tasks=MagicMock(),
requested_model=requested_model,
)
out: list[bytes] = []
async for chunk in streaming_response.body_iterator:
if isinstance(chunk, str):
out.append(chunk.encode())
else:
out.append(bytes(chunk))
return out
def _data_payloads(out: list[bytes]) -> list[bytes]:
"""Return the raw payload of every ``data: `` line across all emitted bytes."""
payloads: list[bytes] = []
for chunk in out:
for line in chunk.split(b"\n"):
if line.startswith(b"data: "):
payloads.append(line[len(b"data: ") :])
return payloads
def _assert_clean(out: list[bytes]) -> list[dict]:
"""Core invariant: every data line is [DONE] or valid JSON; no comments leak."""
blob = b"".join(out)
# No SSE comment line must ever reach the client.
for line in blob.split(b"\n"):
assert not line.startswith(b":"), f"comment leaked to client: {line!r}"
# The original bug signature: a data line whose value is itself a comment.
assert not line.startswith(b"data: :"), f"mangled comment frame: {line!r}"
objs: list[dict] = []
for payload in _data_payloads(out):
stripped = payload.strip()
if stripped == b"[DONE]":
continue
obj = json.loads(stripped) # raises if the proxy emitted non-JSON data
objs.append(obj)
return objs
@pytest.mark.asyncio
async def test_openai_style_plain_stream() -> None:
"""OpenAI / Groq / Fireworks / xAI / Perplexity: plain data + [DONE]."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"Hello"}}]}\n\n',
b'data: {"id":"x","choices":[{"delta":{"content":" world"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
assert any(o.get("choices") for o in objs)
assert b"data: [DONE]\n\n" in b"".join(out)
@pytest.mark.asyncio
async def test_openrouter_keepalive_comments() -> None:
"""OpenRouter ``: OPENROUTER PROCESSING`` keepalives must never crash clients.
This is the exact regression: the old parser emitted
``data: : OPENROUTER PROCESSING`` which made downstream
``JSON.parse`` throw ``Unexpected token ':'``.
"""
chunks = [
b": OPENROUTER PROCESSING\n\n",
b": OPENROUTER PROCESSING\n\n",
b'data: {"id":"x","choices":[{"delta":{"content":"Hi"}}]}\n\n',
b": OPENROUTER PROCESSING\n\n",
b'data: {"id":"x","choices":[{"delta":{"content":"!"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
# The keepalive must be gone entirely.
assert b"OPENROUTER PROCESSING" not in b"".join(out)
# Real content survived.
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert "Hi" in contents and "!" in contents
@pytest.mark.asyncio
async def test_openrouter_comment_glued_to_data_chunk() -> None:
"""Keepalive packed into the same TCP chunk as data (the harder case)."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"a"}}]}\n\n'
b": OPENROUTER PROCESSING\n\n"
b'data: {"id":"x","choices":[{"delta":{"content":"b"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["a", "b"]
@pytest.mark.asyncio
async def test_json_split_across_chunk_boundary() -> None:
"""A single event's JSON arriving in two TCP reads must reassemble."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"con',
b'tent":"split"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["split"]
@pytest.mark.asyncio
async def test_byte_by_byte_fragmentation() -> None:
"""Pathological framing: one byte per chunk. Must still parse cleanly."""
raw = (
b'data: {"id":"x","choices":[{"delta":{"content":"drip"}}]}\n\n'
b": OPENROUTER PROCESSING\n\n"
b"data: [DONE]\n\n"
)
chunks = [raw[i : i + 1] for i in range(len(raw))]
out = await _drive(chunks)
objs = _assert_clean(out)
assert objs and objs[0]["choices"][0]["delta"]["content"] == "drip"
@pytest.mark.asyncio
async def test_gemini_crlf_framing() -> None:
"""Gemini native (alt=sse) frames events with CRLF."""
chunks = [
b'data: {"id":"g","choices":[{"delta":{"content":"hej"}}]}\r\n\r\n',
b'data: {"id":"g","choices":[{"delta":{"content":"!"}}]}\r\n\r\n',
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["hej", "!"]
@pytest.mark.asyncio
async def test_azure_leading_role_chunk() -> None:
"""Azure OpenAI opens with a content-filter / role-only chunk."""
chunks = [
b'data: {"id":"az","choices":[],"prompt_filter_results":[]}\n\n',
b'data: {"id":"az","choices":[{"delta":{"role":"assistant"}}]}\n\n',
b'data: {"id":"az","choices":[{"delta":{"content":"ok"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
_assert_clean(out)
@pytest.mark.asyncio
async def test_openrouter_mid_stream_error_event() -> None:
"""OpenRouter mid-stream errors arrive as a normal data JSON event."""
err = {
"id": "x",
"object": "chat.completion.chunk",
"model": "openai/gpt-4o",
"error": {"code": "server_error", "message": "Provider disconnected"},
"choices": [{"index": 0, "delta": {"content": ""}, "finish_reason": "error"}],
}
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"partial"}}]}\n\n',
b"data: " + json.dumps(err).encode() + b"\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
assert any("error" in o for o in objs), "error event must be forwarded intact"
@pytest.mark.asyncio
async def test_gemini_combined_content_and_usage_chunk() -> None:
"""Gemini thinking models pack usage into the final *content* chunk.
Regression: the parser swallowed any chunk carrying a ``usage`` dict, so
when content + usage arrived together the assistant text was dropped and
the client saw "no assistant messages" despite a 200 + token accounting.
"""
chunks = [
b'data: {"id":"g","choices":[{"delta":{"content":"the answer"},'
b'"finish_reason":"stop"}],"usage":{"prompt_tokens":3,'
b'"completion_tokens":2,"total_tokens":5}}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
# Content delivered exactly once (not dropped, not duplicated by the trailer).
assert contents == ["the answer"]
@pytest.mark.asyncio
async def test_separate_usage_chunk_not_forwarded_as_content() -> None:
"""A pure usage chunk (choices: []) is still swallowed, content intact."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"hello"}}]}\n\n',
b'data: {"id":"x","choices":[],"usage":{"total_tokens":4}}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["hello"]
@pytest.mark.asyncio
async def test_requested_model_override_applied() -> None:
"""Model rewriting still works through the buffered parser."""
chunks = [
b'data: {"id":"x","model":"upstream-model","choices":[{"delta":{"content":"hi"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks, requested_model="routstr-model")
objs = _assert_clean(out)
# The upstream content chunk carried model "upstream-model"; the parser must
# rewrite it to the requested model. (The trailing routstr-generated usage
# chunk is excluded - it is not an upstream-forwarded chunk.)
content_chunks = [o for o in objs if o.get("choices")]
assert content_chunks, "expected at least one forwarded content chunk"
assert all(o.get("model") == "routstr-model" for o in content_chunks)
@pytest.mark.asyncio
async def test_multiline_non_json_data_each_line_prefixed() -> None:
"""A multi-line non-JSON ``data`` block must keep a ``data:`` prefix per line.
Two ``data:`` lines in one event reassemble to ``line one\\nline two``, which
is not JSON, so it takes the raw-forward path. The parser must re-prefix each
line; a bare second line would reach the client without its ``data:`` field
and break naive SSE parsers.
"""
chunks = [
b"data: line one\ndata: line two\n\n",
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
blob = b"".join(out)
for line in blob.split(b"\n"):
stripped = line.strip()
if not stripped or stripped == b"[DONE]":
continue
assert line.startswith(b"data: "), f"bare line leaked to client: {line!r}"
assert b"data: line one" in blob and b"data: line two" in blob

View File

@@ -108,6 +108,39 @@ async def test_credit_balance() -> None:
assert mock_session.refresh.called
@pytest.mark.asyncio
async def test_credit_balance_rejects_zero_amount() -> None:
"""A zero/dust redemption must raise BEFORE any commit, so no orphan
zero-balance key (balance 0, total_spent 0, total_requests 0) is persisted."""
token_data = {
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 0}]}],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(0, "sat", "http://mint:3338"),
):
with pytest.raises(ValueError, match="must be positive"):
await credit_balance(token_str, mock_key, mock_session)
# Critically: no balance UPDATE and no commit happened, so the caller's
# uncommitted key row rolls back instead of persisting as an orphan.
assert not mock_session.exec.called
assert not mock_session.commit.called
@pytest.mark.asyncio
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
@@ -123,6 +156,7 @@ async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
@@ -166,6 +200,7 @@ async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
@@ -261,6 +296,7 @@ async def test_swap_to_primary_mint_success() -> None:
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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

View File

@@ -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>

View 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>
);
}

View File

@@ -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
View 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}`;
}

View File

@@ -6,6 +6,9 @@ const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
turbopack: {
root: __dirname,
},
};
export default nextConfig;

View File

@@ -1,5 +1,6 @@
{
"name": "routstr-service",
"packageManager": "pnpm@10.15.0",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -88,5 +89,11 @@
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
]
}
}

16
uv.lock generated
View File

@@ -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"