Compare commits

...

101 Commits

Author SHA1 Message Date
9qeklajc
af0796689f make openrouter default to provider supporting tool use 2026-06-20 23:55:03 +02:00
9qeklajc
90f1ba89e5 Merge pull request #563 from Routstr/fix-streaming-issue
consolidate streaming
2026-06-20 19:46:22 +02:00
9qeklajc
e29c9241ce Merge pull request #560 from Routstr/fix-deepseek-cache-pricing
Fix deepseek cache pricing
2026-06-20 19:46:07 +02:00
9qeklajc
5a3774a414 consolidate streaming 2026-06-20 11:33:52 +02:00
9qeklajc
b57f2d408e display cr as i 2026-06-20 00:57:11 +02:00
9qeklajc
70d9e1a1ce do correct deepseek cache price calc. 2026-06-20 00:26:34 +02:00
9qeklajc
c0a34a391f Merge pull request #558 from Routstr/display-balance-correctly
make sure balance is set correctly
2026-06-19 23:41:22 +02:00
9qeklajc
a97ea2995a Merge pull request #550 from jeroenubbink/fix/cached-token-overcharge
fix: bill cached input tokens at their real rates across vendor dialects
2026-06-19 23:41:04 +02:00
9qeklajc
5b50a78d95 Merge pull request #559 from Routstr/fix-provider-models-view
collapse when focus change
2026-06-19 14:33:58 +02:00
9qeklajc
ccab5e4216 collapse when focus change 2026-06-19 11:53:30 +02:00
9qeklajc
ff9c645bb1 make sure balance is set correctly 2026-06-19 11:20:56 +02:00
9qeklajc
d4318dcc07 Merge pull request #556 from jeroenubbink/fix/trusted-mint-input-fee
fix: deduct mint NUT-02 input fee when crediting trusted-mint topups
2026-06-18 21:48:24 +02:00
Jeroen Ubbink
75ed865a5f fix: deduct input fee in swap_to_primary_mint same-mint shortcut
The same-mint shortcut in swap_to_primary_mint did a same-mint
split(include_fees=True) — which burns the mint's NUT-02 per-proof input
fee — but returned the full token amount, over-crediting the user (the
same bug already fixed for the trusted-mint receive path). It also
skipped DLEQ verification that the trusted path performs.

Extract the shared same-mint redeem into _redeem_same_mint (load mint,
verify DLEQ, split, credit amount - input_fees) and delegate from both
recieve_token and the shortcut, so the two paths can't drift again. This
shortcut is reachable when PRIMARY_MINT_URL is set outside CASHU_MINTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:48:49 +02:00
Jeroen Ubbink
7969a8da55 test: clarify mocked input-fee comment in trusted-mint test
The comment described "21 proofs @ 100 ppk" arithmetic, but the mocked
token has one proof and get_fees_for_proofs is hard-mocked to 3, so the
math wasn't exercised. Describe what the mock actually does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:26:44 +02:00
9qeklajc
830c299130 Merge pull request #557 from Routstr/rollback-token-generation-when-request-failed
rollback when token generation failed
2026-06-17 22:17:55 +02:00
9qeklajc
e9dced8148 rollback when token generation failed 2026-06-17 17:27:08 +02:00
Jeroen Ubbink
ecd46975b4 fix: deduct mint NUT-02 input fee when crediting trusted-mint topups
recieve_token swaps the incoming proofs at the same mint with
include_fees=True (paying the mint's NUT-02 per-proof input fee) but credited
the full face value. On every topup from a fee-charging trusted mint, routstr
over-credited the user by the fee and its own wallet drifted toward insolvency.

Subtract get_fees_for_proofs(proofs) from the credited amount, mirroring the
foreign-mint swap path which already accounts for it. Adds a fee-charging
trusted-mint unit test (credited == face - input_fee).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:10:37 +02:00
9qeklajc
8f5f3d9738 resolve review comments 2026-06-13 23:40:39 +02:00
9qeklajc
355e3f19ef explicit cache 2026-06-13 23:40:39 +02:00
9qeklajc
439ac48216 update to all providers 2026-06-13 23:40:39 +02:00
9qeklajc
bf05c96dfc Merge pull request #552 from Routstr/fix-reset-reserve-balance
Fix reset reserve balance
2026-06-12 21:52:39 +02:00
9qeklajc
ed6e0c0189 Merge main into fix-reset-reserve-balance
Resolve pay_for_request conflict: keep main's atomic child-key
balance_limit guard and post-rowcount-check ordering, and stamp
reserved_at on both billing and child reservations.
2026-06-12 21:08:53 +02:00
9qeklajc
412bfe479c Merge pull request #547 from jeroenubbink/fix/child-key-balance-limit-atomic
fix: enforce balance_limit atomically on child key requests
2026-06-12 20:23:25 +02:00
9qeklajc
861fda21b7 resolve review comments 2026-06-12 20:20:38 +02:00
9qeklajc
919dcf5535 make key reservation reset predictable 2026-06-12 19:45:39 +02:00
Jeroen Ubbink
cbc424e8e7 build: type-check the entire repo in make targets, matching CI
CI runs 'uv run mypy .' while the Makefile only checked routstr/, so test
files could pass locally and fail the pipeline. lint, type-check and
ci-lint now check everything; --ignore-missing-imports is dropped since
the CI invocation passes without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:22:28 +02:00
Jeroen Ubbink
068fb3572f refactor: drop unused session parameter from calculate_cost
The session was needed when model pricing lived in the DB (73d3613) and has
been dead since pricing moved to the in-memory model map (0da08fb), yet every
caller was still obliged to supply one. get_x_cashu_cost even opened a DB
session per x-cashu request solely to feed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:17:11 +02:00
Jeroen Ubbink
eaf74edbba fix: bill cached input tokens at their real rates across vendor dialects
Cached prompt tokens were billed at the full input rate whenever a vendor's
usage dialect or cache pricing was unknown, overcharging DeepSeek topups
~5-10x on agentic workloads (hits are 10x cheaper upstream) and silently
mispricing OpenAI cached reads and Anthropic cache writes the same way.

Two root causes, two fixes:

- Usage dialects: DeepSeek reports prompt_cache_hit_tokens /
  prompt_cache_miss_tokens, which billing never parsed. Usage normalization
  now lives in payment/usage.py as a union parser over the known,
  non-colliding dialects (OpenAI prompt_tokens_details, Anthropic additive
  cache fields, DeepSeek hit/miss), producing one canonical NormalizedUsage.
  Providers expose it as an overridable BaseUpstreamProvider.normalize_usage
  hook — the escape hatch for future vendors whose fields genuinely
  conflict — and every settlement call site passes the provider's result
  through, so calculate_cost holds no vendor knowledge of its own.

- Cache rates: the OpenRouter model feed omits input_cache_read/-write for
  most DeepSeek models (and e.g. openai/gpt-4o), so billing fell back to the
  full input rate. Missing rates are now backfilled from litellm's bundled
  cost map before the provider fee is applied; the input-rate fallback
  remains only as the documented last resort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:17:11 +02:00
Jeroen Ubbink
28c4008892 fix: enforce balance_limit atomically on child key requests
The Python pre-check on balance_limit provided no concurrency guarantee —
two concurrent requests could both pass the check on stale in-memory state
and both proceed to reserve, exceeding the limit.

Add the balance_limit guard to the child key UPDATE's WHERE clause so the
enforcement is atomic. The HTTP 402 is raised without an explicit rollback:
since session.commit() was never called, the uncommitted billing key update
is discarded when the session context manager closes on exception exit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:37:39 +02:00
9qeklajc
a16bc1220c Merge pull request #546 from Routstr/fix-parent-key-leak-to-child-key
do not leak parent key to child key
2026-06-07 15:27:32 +02:00
9qeklajc
5d3e687876 Merge pull request #545 from Routstr/secure-test-endpoint
secure endpoint
2026-06-07 15:25:53 +02:00
9qeklajc
68a7cd1dfb do not leak parent key to child key 2026-06-07 15:25:13 +02:00
9qeklajc
d96dc20e86 secure endpoint 2026-06-07 15:18:49 +02:00
9qeklajc
934afaa091 Merge pull request #544 from Routstr/pr-reactive-correction
feat: reactive request-correction retry for recoverable upstream 400s
2026-06-07 15:09:18 +02:00
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
9d5e14903b fix swapping 2026-05-13 21:49:03 +02:00
65 changed files with 6524 additions and 926 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

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

@@ -98,7 +98,7 @@ docker-down:
lint:
@echo "🔍 Running linting checks..."
$(RUFF) check .
$(MYPY) routstr/ --ignore-missing-imports
$(MYPY) .
format:
@echo "✨ Formatting code..."
@@ -107,7 +107,7 @@ format:
type-check:
@echo "🔎 Running type checks..."
$(MYPY) routstr/ --ignore-missing-imports
$(MYPY) .
# Development setup
dev-setup:
@@ -234,7 +234,7 @@ ci-test:
ci-lint:
@echo "🤖 Running CI linting..."
$(RUFF) check . --exit-non-zero-on-fix
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
$(MYPY) . --no-error-summary
# Debug helpers
test-debug:

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 reserved_at to api_keys
Revision ID: b5e7c9d1f3a2
Revises: a2b3c4d5e6f7
Create Date: 2026-06-12 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b5e7c9d1f3a2"
down_revision = "a2b3c4d5e6f7"
branch_labels = None
depends_on = None
def upgrade() -> None:
# existing keys keep NULL
# New reservations populate it via pay_for_request.
op.add_column("api_keys", sa.Column("reserved_at", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("api_keys", "reserved_at")

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)
@@ -359,6 +364,7 @@ async def validate_bearer_key(
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(
"Cashu token redemption failed",
extra={
@@ -529,31 +535,19 @@ async def pay_for_request(
)
# Charge the base cost for the request atomically to avoid race conditions
reserved_at_now = int(time.time())
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
reserved_at=reserved_at_now,
total_requests=col(ApiKey.total_requests) + 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also increment total_requests and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_requests=col(ApiKey.total_requests) + 1,
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Concurrent request depleted balance",
@@ -565,7 +559,6 @@ async def pay_for_request(
},
)
# Another concurrent request spent the balance first
raise HTTPException(
status_code=402,
detail={
@@ -577,6 +570,44 @@ async def pay_for_request(
},
)
# Also increment total_requests and reserved_balance on the child key if it's different.
# The balance_limit guard is enforced atomically here — the Python pre-check above
# is a fast-path rejection only and provides no concurrency guarantee.
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(
(col(ApiKey.balance_limit).is_(None))
| (
col(ApiKey.total_spent)
+ col(ApiKey.reserved_balance)
+ cost_per_request
<= col(ApiKey.balance_limit)
)
)
.values(
total_requests=col(ApiKey.total_requests) + 1,
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
reserved_at=reserved_at_now,
)
)
child_result = await session.exec(child_stmt) # type: ignore[call-overload]
if child_result.rowcount == 0:
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
await session.commit()
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
@@ -615,12 +646,19 @@ async def revert_pay_for_request(
False if the reservation was already released (prevents negative reserved_balance)."""
billing_key = await get_billing_key(key, session)
# Keep reserved_at while other reservations remain
cleared_reserved_at = case(
(col(ApiKey.reserved_balance) - cost_per_request > 0, col(ApiKey.reserved_at)),
else_=None,
)
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
reserved_at=cleared_reserved_at,
total_requests=col(ApiKey.total_requests) - 1,
)
)
@@ -636,6 +674,7 @@ async def revert_pay_for_request(
.values(
total_requests=col(ApiKey.total_requests) - 1,
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
reserved_at=cleared_reserved_at,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -670,12 +709,18 @@ async def revert_pay_for_request(
async def adjust_payment_for_tokens(
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
key: ApiKey,
response_data: dict,
session: AsyncSession,
deducted_max_cost: int,
) -> dict:
"""
Adjusts the payment based on token usage in the response.
This is called after the initial payment and the upstream request is complete.
Returns cost data to be included in the response.
The response's usage object is normalized with the default union parser in
``calculate_cost``.
"""
billing_key = await get_billing_key(key, session)
model = response_data.get("model", "unknown")
@@ -758,7 +803,7 @@ async def adjust_payment_for_tokens(
extra={"error": str(e), "fee_msats": fee_msats},
)
match await calculate_cost(response_data, deducted_max_cost, session):
match await calculate_cost(response_data, deducted_max_cost):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
@@ -1258,3 +1303,24 @@ async def periodic_key_reset() -> None:
break
except Exception as e:
logger.error(f"Error in periodic_key_reset: {e}")
STALE_RESERVATION_SWEEP_INTERVAL_SECONDS: int = 60
async def periodic_stale_reservation_sweep() -> None:
"""Background task that releases reservations leaked by client disconnects,
crashes or abandoned streams.
"""
from .core.db import create_session, release_stale_reservations
while True:
try:
async with create_session() as session:
await release_stale_reservations(
session, settings.stale_reservation_timeout_seconds
)
except Exception:
logger.exception("Error in periodic_stale_reservation_sweep")
await asyncio.sleep(STALE_RESERVATION_SWEEP_INTERVAL_SECONDS)

View File

@@ -7,7 +7,7 @@ from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import col, select, update
from sqlmodel import col, or_, select, update
from .auth import get_billing_key, validate_bearer_key
from .core.db import (
@@ -45,10 +45,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
billing_key = await get_billing_key(key, session)
info = {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"balance": billing_key.total_balance,
"reserved": billing_key.reserved_balance,
"is_child": key.parent_key_hash is not None,
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
"balance_limit": key.balance_limit,
@@ -56,7 +55,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
"validity_date": key.validity_date,
}
if not key.parent_key_hash:
if key.parent_key_hash:
info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
else:
# Fetch child keys if this is a parent key
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
results = await session.exec(statement)
@@ -312,9 +313,36 @@ async def refund_wallet_endpoint(
)
if key.reserved_balance > 0:
raise HTTPException(
status_code=400,
detail="Cannot refund key. There are ongoing requests for this api key.",
# Release the reservation if it is stale
cutoff = int(time.time()) - settings.stale_reservation_timeout_seconds
stale_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.reserved_balance) > 0)
.where(
or_(
col(ApiKey.reserved_at).is_(None),
col(ApiKey.reserved_at) < cutoff,
)
)
.values(reserved_balance=0, reserved_at=None)
)
stale_result = await session.exec(stale_release_stmt) # type: ignore[call-overload]
await session.commit()
if stale_result.rowcount == 0:
raise HTTPException(
status_code=400,
detail="Cannot refund key. There are ongoing requests for this api key.",
)
await session.refresh(key)
logger.warning(
"refund_wallet_endpoint: released stale reservation before refund",
extra={
"hashed_key": key.hashed_key,
"stale_timeout_seconds": settings.stale_reservation_timeout_seconds,
},
)
remaining_balance_msats: int = key.total_balance
@@ -341,7 +369,7 @@ async def refund_wallet_endpoint(
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) == pre_debit_balance)
.where(col(ApiKey.reserved_balance) == pre_debit_reserved)
.values(balance=0, reserved_balance=0)
.values(balance=0, reserved_balance=0, reserved_at=None)
)
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
await session.commit()
@@ -354,21 +382,26 @@ async def refund_wallet_endpoint(
)
# --- MINT: balance is locked at zero, safe to create the refund token ---
# Proofs from untrusted mints are swapped to primary_mint on receive.
# Use primary_mint unless key.refund_mint_url is an explicitly trusted mint.
effective_refund_mint = (
key.refund_mint_url
if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints
else settings.primary_mint
)
try:
if key.refund_address:
from .core.settings import settings as global_settings
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
key.refund_mint_url or global_settings.primary_mint,
effective_refund_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
remaining_balance, refund_currency, effective_refund_mint
)
result = {"token": token}
@@ -396,15 +429,26 @@ async def refund_wallet_endpoint(
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
error_msg = str(e)
logger.error(
"refund_wallet_endpoint: mint/send failed",
extra={
"error": error_msg,
"error_type": type(e).__name__,
"hashed_key": key.hashed_key,
"remaining_balance": remaining_balance,
"refund_currency": key.refund_currency,
"refund_mint_url": key.refund_mint_url,
"has_refund_address": bool(key.refund_address),
},
)
if (
"mint" in error_msg.lower()
or "connection" in error_msg.lower()
or isinstance(e, Exception)
and "ConnectError" in str(type(e))
or "ConnectError" in str(type(e))
):
raise HTTPException(status_code=503, detail="Mint service unavailable")
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
else:
raise HTTPException(status_code=500, detail="Refund failed")
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
await _refund_cache_set(bearer_value, result)
@@ -519,10 +563,24 @@ async def create_child_key(
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Deduct cost from parent atomically — guards against concurrent requests
# that both pass the balance check above on stale in-memory state.
deduct_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
.values(
balance=col(ApiKey.balance) - total_cost,
total_spent=col(ApiKey.total_spent) + total_cost,
)
)
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
if result.rowcount == 0:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Generate new keys
import secrets
@@ -547,6 +605,7 @@ async def create_child_key(
new_keys.append("sk-" + new_key_hash)
await session.commit()
await session.refresh(key)
response_data = {
"api_keys": new_keys,
@@ -593,26 +652,6 @@ async def reset_child_key_spent(
return {"success": True, "message": "Child key balance reset successfully."}
@router.get("/cashu-refund/{payment_token_hash}")
async def get_cashu_refund(
payment_token_hash: str,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
result.collected = True
session.add(result)
await session.commit()
return {
"refund_token": result.token,
"amount": result.amount,
"unit": result.unit,
}
@router.api_route(
"/{path:path}",
@@ -626,7 +665,7 @@ async def wallet_catch_all(path: str) -> NoReturn:
)
balance_router.include_router(lightning_router)
balance_router.include_router(lightning_router, include_in_schema=False)
balance_router.include_router(router)
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)

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

@@ -33,6 +33,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
reserved_balance: int = Field(
default=0, description="Reserved balance in millisatoshis (msats)"
)
reserved_at: int | None = Field(
default=None,
description=(
"Unix timestamp of the most recent balance reservation. Used to "
"detect and release stale reservations (e.g. after client "
"disconnects). NULL when no reservation has been made yet."
),
)
refund_address: str | None = Field(
default=None,
description="Lightning address to refund remaining balance after key expires",
@@ -45,6 +53,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
created_at: int | None = Field(
default_factory=lambda: int(time.time()),
nullable=True,
description=(
"Unix timestamp when the key was created. Nullable: keys created "
"before this column existed have no value and sort last."
),
)
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
@@ -79,12 +95,36 @@ class ApiKey(SQLModel, table=True): # type: ignore
async def reset_all_reserved_balances(session: AsyncSession) -> None:
stmt = update(ApiKey).values(reserved_balance=0)
stmt = update(ApiKey).values(reserved_balance=0, reserved_at=None)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
logger.info("Reset reserved balances on startup")
async def release_stale_reservations(
session: AsyncSession, max_age_seconds: int
) -> int:
"""Release reservations whose last reserve is older than max_age_seconds.
"""
cutoff = int(time.time()) - max_age_seconds
stmt = (
update(ApiKey)
.where(col(ApiKey.reserved_balance) > 0)
.where(col(ApiKey.reserved_at).is_not(None))
.where(col(ApiKey.reserved_at) < cutoff)
.values(reserved_balance=0, reserved_at=None)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
released = int(result.rowcount or 0)
if released:
logger.warning(
"Released stale balance reservations",
extra={"released_keys": released, "max_age_seconds": max_age_seconds},
)
return released
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
id: str = Field(primary_key=True)
@@ -132,6 +172,18 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
balance_limit: int | None = Field(
default=None,
description="Max spendable msats for the created key",
)
balance_limit_reset: str | None = Field(
default=None,
description="Reset policy for balance limit (daily, weekly, monthly)",
)
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the created key expires",
)
class CashuTransaction(SQLModel, table=True): # type: ignore

View File

@@ -11,8 +11,9 @@ from starlette.exceptions import HTTPException
from starlette.responses import Response as StarletteResponse
from starlette.types import Scope
from ..auth import periodic_key_reset
from ..auth import periodic_key_reset, periodic_stale_reservation_sweep
from ..balance import balance_router, deprecated_wallet_router
from ..lightning import lightning_router, periodic_invoice_watcher
from ..nostr import (
announce_provider,
providers_cache_refresher,
@@ -23,6 +24,7 @@ from ..payment.models import models_router, update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..upstream.auto_topup import periodic_auto_topup
from ..upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing
from ..upstream.litellm_routing import configure_litellm
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
from .admin import admin_router
@@ -30,6 +32,7 @@ from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
from .settings import SettingsService
from .settings import settings as global_settings
from .version import __version__
@@ -52,15 +55,22 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
models_refresh_task = None
model_maps_refresh_task = None
key_reset_task = None
stale_reservation_task = None
auto_topup_task = None
refund_sweep_task = None
routstr_fee_task = None
invoice_watcher_task = None
try:
# Apply litellm-wide settings (drop_params, chat-completions URL,
# debug logging) before any upstream provider dispatches a request.
configure_litellm()
# TEMPORARY: backfill DeepSeek V4 pricing missing from litellm's cost
# map (BerriAI/litellm#30430). Remove this call and
# deepseek_v4_pricing_shim.py once litellm ships these models.
register_deepseek_v4_pricing()
# Run database migrations on startup
run_migrations()
@@ -119,9 +129,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
key_reset_task = asyncio.create_task(periodic_key_reset())
stale_reservation_task = asyncio.create_task(
periodic_stale_reservation_sweep()
)
auto_topup_task = asyncio.create_task(periodic_auto_topup())
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher())
yield
@@ -155,12 +169,16 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
model_maps_refresh_task.cancel()
if key_reset_task is not None:
key_reset_task.cancel()
if stale_reservation_task is not None:
stale_reservation_task.cancel()
if auto_topup_task is not None:
auto_topup_task.cancel()
if refund_sweep_task is not None:
refund_sweep_task.cancel()
if routstr_fee_task is not None:
routstr_fee_task.cancel()
if invoice_watcher_task is not None:
invoice_watcher_task.cancel()
try:
tasks_to_wait = []
@@ -182,12 +200,16 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(model_maps_refresh_task)
if key_reset_task is not None:
tasks_to_wait.append(key_reset_task)
if stale_reservation_task is not None:
tasks_to_wait.append(stale_reservation_task)
if auto_topup_task is not None:
tasks_to_wait.append(auto_topup_task)
if refund_sweep_task is not None:
tasks_to_wait.append(refund_sweep_task)
if routstr_fee_task is not None:
tasks_to_wait.append(routstr_fee_task)
if invoice_watcher_task is not None:
tasks_to_wait.append(invoice_watcher_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -364,6 +386,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

@@ -67,6 +67,12 @@ class Settings(BaseSettings):
reset_reserved_balance_on_startup: bool = Field(
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
) # deactivate in horizontal scaling setups
# Reservations older than this are considered leaked (client disconnect,
# crash, abandoned stream) and released by the background sweeper and the
# refund endpoint.
stale_reservation_timeout_seconds: int = Field(
default=300, env="STALE_RESERVATION_TIMEOUT_SECONDS"
)
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")

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

@@ -3,9 +3,17 @@ import math
from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.db import AsyncSession
from ..core.settings import settings
from .price import sats_usd_price
from .usage import normalize_usage, parse_token_count
__all__ = [
"CostData",
"CostDataError",
"MaxCostData",
"calculate_cost",
"parse_token_count",
]
logger = get_logger(__name__)
@@ -34,7 +42,8 @@ class CostDataError(BaseModel):
async def calculate_cost(
response_data: dict, max_cost: int, session: AsyncSession
response_data: dict,
max_cost: int,
) -> CostData | MaxCostData | CostDataError:
"""Calculate the cost of an API request based on token usage.
@@ -44,6 +53,9 @@ async def calculate_cost(
Returns:
Cost data or error information
The response's usage object is normalized with the default union parser;
this function holds no vendor-dialect knowledge of its own.
"""
logger.debug(
"Starting cost calculation",
@@ -54,8 +66,9 @@ async def calculate_cost(
},
)
# Check for usage data
if "usage" not in response_data or response_data["usage"] is None:
usage = normalize_usage(response_data.get("usage"))
if usage is None:
logger.warning(
"No usage data in response — billing at MaxCostData with zero "
"tokens. Dashboard will show this request as `(0+0)`. Most "
@@ -84,16 +97,14 @@ async def calculate_cost(
cache_creation_msats=0,
)
usage_data = response_data["usage"]
usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict):
usage_data = {}
# Extract token counts
input_tokens = _extract_token_pair(usage_data, "prompt_tokens", "input_tokens")
output_tokens = _extract_token_pair(usage_data, "completion_tokens", "output_tokens")
# Extract cache tokens (handles OpenAI vs Anthropic formats)
cache_read_tokens, cache_creation_tokens, input_tokens = _extract_cache_tokens(
usage_data, input_tokens
)
input_tokens = usage.input_tokens
output_tokens = usage.output_tokens
cache_read_tokens = usage.cache_read_tokens
cache_creation_tokens = usage.cache_write_tokens
# Try USD cost first
usd_cost = _resolve_usd_cost(usage_data, response_data)
@@ -202,22 +213,6 @@ async def calculate_cost(
# ============================================================================
def parse_token_count(value: object) -> int:
"""Parse a token count from various formats (int, float, str, bool)."""
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
def _coerce_usd(value: object) -> float:
"""Coerce a value to USD float, handling various formats safely."""
if value is None or isinstance(value, bool):
@@ -230,37 +225,6 @@ def _coerce_usd(value: object) -> float:
return 0.0
def _extract_token_pair(
usage_data: dict, standard_field: str, alt_field: str
) -> int:
"""Extract token count trying two field names in order."""
value = parse_token_count(usage_data.get(standard_field, 0))
if value > 0:
return value
return parse_token_count(usage_data.get(alt_field, 0))
def _extract_cache_tokens(usage_data: dict, input_tokens: int) -> tuple[int, int, int]:
"""Extract cache tokens, handling OpenAI vs Anthropic formats.
Returns: (cache_read_tokens, cache_creation_tokens, adjusted_input_tokens)
"""
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
cache_creation = parse_token_count(
usage_data.get("cache_creation_input_tokens", 0)
)
# OpenAI: cache is included in input_tokens, subtract it
prompt_details = usage_data.get("prompt_tokens_details")
if isinstance(prompt_details, dict) and not cache_read:
openai_cached = parse_token_count(prompt_details.get("cached_tokens", 0))
if openai_cached:
cache_read = openai_cached
input_tokens = max(0, input_tokens - cache_read)
return cache_read, cache_creation, input_tokens
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
"""Resolve USD cost with clear priority order.
@@ -336,6 +300,22 @@ def _get_pricing_rates(
raise ValueError("Invalid pricing data") from e
def _resolve_provider_fee(model_id: str) -> float:
"""Resolve the provider fee multiplier for the given model id.
Falls back to 1.0 (no markup) when the provider cannot be resolved so
the USD cost path never silently double-applies or omits the fee.
"""
from ..proxy import get_provider_for_model
if not model_id:
return 1.0
providers = get_provider_for_model(model_id)
if not providers:
return 1.0
return float(providers[0].provider_fee)
def _calculate_from_usd_cost(
usd_cost: float,
input_usd: float,
@@ -347,6 +327,10 @@ def _calculate_from_usd_cost(
response_data: dict,
) -> CostData:
"""Calculate cost from USD figures, deriving input/output split from tokens."""
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
usd_cost = usd_cost * provider_fee
input_usd = input_usd * provider_fee
output_usd = output_usd * provider_fee
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
@@ -434,10 +418,21 @@ def _calculate_from_tokens(
},
)
# Fold the cache-read/write cost into the visible ``input_msats`` so a
# dashboard that renders I / O / T sees ``input + output == total``
# exactly. This mirrors ``_fold_cache_into_input_tokens`` (which rolls the
# cache token counts into the visible prompt total). The standalone
# ``cache_read_msats`` / ``cache_creation_msats`` fields stay populated for
# clients that want the breakdown; nothing sums the components to derive
# ``total_msats`` (it is computed independently above), so this is
# display-only and does not change what is billed.
visible_output_msats = int(calc_output_msats)
visible_input_msats = token_based_cost - visible_output_msats
return CostData(
base_msats=0,
input_msats=int(calc_input_msats),
output_msats=int(calc_output_msats),
input_msats=visible_input_msats,
output_msats=visible_output_msats,
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,

View File

@@ -3,7 +3,7 @@ import json
import random
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel as V2BaseModel
from pydantic.v1 import BaseModel
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -17,6 +17,24 @@ logger = get_logger(__name__)
models_router = APIRouter()
_MODEL_TEST_ENDPOINT_PATHS = {
"chat-completions": "chat/completions",
"completions": "completions",
"embeddings": "embeddings",
"responses": "responses",
}
# Cap the caller-supplied test payload to avoid forwarding oversized bodies
# upstream on the operator's credentials.
_MODEL_TEST_MAX_REQUEST_BYTES = 64 * 1024
async def _require_admin_api(request: Request) -> None:
"""Require admin auth without creating an import-time cycle with core.admin."""
from ..core.admin import require_admin_api
await require_admin_api(request)
class Architecture(BaseModel):
modality: str
@@ -67,6 +85,48 @@ class Model(BaseModel):
return hash(self.id)
def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
"""Fill missing cache rates from litellm's bundled cost map.
The OpenRouter model feed omits ``input_cache_read``/``input_cache_write``
for many models (most DeepSeek entries, openai/gpt-4o, ...). Without a
cache rate, billing falls back to the full input rate, which overcharges
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
(gpt-4o, claude-sonnet-4-5), so both spellings are tried.
Rates already present (e.g. provided by OpenRouter) are authoritative and
never overwritten. Unknown models are returned unchanged.
"""
needs_read = (pricing.input_cache_read or 0.0) <= 0.0
needs_write = (pricing.input_cache_write or 0.0) <= 0.0
if not (needs_read or needs_write):
return pricing
import litellm
info: dict | None = None
for key in (model_id, model_id.split("/", 1)[-1]):
candidate = litellm.model_cost.get(key)
if isinstance(candidate, dict):
info = candidate
break
if info is None:
return pricing
updated = Pricing.parse_obj(pricing.dict())
if needs_read:
read_rate = info.get("cache_read_input_token_cost")
if isinstance(read_rate, (int, float)) and read_rate > 0:
updated.input_cache_read = float(read_rate)
if needs_write:
write_rate = info.get("cache_creation_input_token_cost")
if isinstance(write_rate, (int, float)) and write_rate > 0:
updated.input_cache_write = float(write_rate)
return updated
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
pricing = model.get("pricing", {})
@@ -418,7 +478,9 @@ class ModelTestRequest(V2BaseModel):
request_data: dict
@models_router.post("/api/models/test")
@models_router.post(
"/api/models/test", dependencies=[Depends(_require_admin_api)]
)
async def test_model(
payload: ModelTestRequest,
session: AsyncSession = Depends(get_session),
@@ -446,16 +508,35 @@ async def test_model(
"status_code": 404,
}
base_url = provider.base_url.rstrip("/")
if payload.endpoint_type == "chat-completions":
url = f"{base_url}/chat/completions"
else:
url = f"{base_url}/{payload.endpoint_type}"
endpoint_path = _MODEL_TEST_ENDPOINT_PATHS.get(payload.endpoint_type)
if endpoint_path is None:
raise HTTPException(status_code=400, detail="Unsupported endpoint_type")
actual_model_id = model_row.forwarded_model_id or model_row.id
request_data = dict(payload.request_data)
request_data["model"] = actual_model_id
try:
request_size = len(json.dumps(request_data).encode("utf-8"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid request_data")
if request_size > _MODEL_TEST_MAX_REQUEST_BYTES:
raise HTTPException(status_code=413, detail="request_data too large")
base_url = provider.base_url.rstrip("/")
url = f"{base_url}/{endpoint_path}"
logger.info(
"admin model test",
extra={
"model_id": payload.model_id,
"forwarded_model_id": actual_model_id,
"endpoint_type": payload.endpoint_type,
"upstream_provider_id": model_row.upstream_provider_id,
"request_bytes": request_size,
},
)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {provider.api_key}",

131
routstr/payment/usage.py Normal file
View File

@@ -0,0 +1,131 @@
"""Vendor-agnostic normalization of upstream usage objects.
Upstream providers report token usage in vendor dialects that differ in field
names and in whether cached tokens are included in the input count:
* OpenAI / Azure / xAI / Groq / Moonshot / Qwen / Gemini-compat: cache reads in
``prompt_tokens_details.cached_tokens``, included in ``prompt_tokens``.
* OpenRouter: same as OpenAI plus cache *writes* in
``prompt_tokens_details.cache_write_tokens``, also included in
``prompt_tokens``.
* litellm-normalized: same nesting, but names the write field
``prompt_tokens_details.cache_creation_tokens`` (and additionally mirrors the
Anthropic top-level fields), with ``prompt_tokens`` as the grand total.
* Anthropic native: ``cache_read_input_tokens`` / ``cache_creation_input_tokens``
top-level, additive to (not included in) ``input_tokens``.
* DeepSeek: ``prompt_cache_hit_tokens`` / ``prompt_cache_miss_tokens``, with
``prompt_tokens = hit + miss``.
What decides whether cached tokens must be subtracted out of the input count is
**which prompt field the vendor uses**, not which cache field appears:
* ``prompt_tokens`` present -> cached + cache-write tokens are *included* in it
(OpenAI family, DeepSeek, OpenRouter, litellm); subtract both so
``input_tokens`` holds only the regular-rate portion.
* only ``input_tokens`` (Anthropic native) -> cached tokens are *additive*;
leave ``input_tokens`` untouched.
``normalize_usage`` maps all of them onto one canonical ``NormalizedUsage``
shape so billing code needs no vendor knowledge. The known dialects' field
names do not collide, so a single union parser is safe; a vendor whose fields
would genuinely conflict needs a dedicated branch here.
"""
from pydantic.v1 import BaseModel
class NormalizedUsage(BaseModel):
"""Canonical token usage: input_tokens never includes cached tokens."""
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
def parse_token_count(value: object) -> int:
"""Parse a token count from various formats (int, float, str, bool)."""
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
def _first_token_count(usage_data: dict, *fields: str) -> int:
"""Return the first positive token count among the given fields."""
for field in fields:
value = parse_token_count(usage_data.get(field, 0))
if value > 0:
return value
return 0
def _extract_cache_tokens(usage_data: dict) -> tuple[int, int]:
"""Pull (cache_read, cache_write) across all known dialects.
Precedence (highest first), independent for reads and writes:
* Anthropic top-level: ``cache_read_input_tokens`` /
``cache_creation_input_tokens``.
* Nested ``prompt_tokens_details``: ``cached_tokens`` for reads;
``cache_creation_tokens`` (litellm) or ``cache_write_tokens``
(OpenRouter) for writes.
* DeepSeek: ``prompt_cache_hit_tokens`` for reads (no write concept).
"""
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
cache_write = parse_token_count(usage_data.get("cache_creation_input_tokens", 0))
prompt_details = usage_data.get("prompt_tokens_details")
if isinstance(prompt_details, dict):
if not cache_read:
cache_read = parse_token_count(prompt_details.get("cached_tokens", 0))
if not cache_write:
cache_write = _first_token_count(
prompt_details, "cache_creation_tokens", "cache_write_tokens"
)
if not cache_read:
# DeepSeek: prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens
cache_read = parse_token_count(usage_data.get("prompt_cache_hit_tokens", 0))
return cache_read, cache_write
def normalize_usage(usage_data: object) -> NormalizedUsage | None:
"""Map a vendor usage dict onto the canonical shape, or None if absent.
Cached reads and writes are subtracted from the input count exactly once,
only for dialects that report a ``prompt_tokens`` grand total that already
includes them (OpenAI family, DeepSeek, OpenRouter, litellm). Anthropic
native reports them additively under ``input_tokens`` and is left untouched.
"""
if not isinstance(usage_data, dict):
return None
output_tokens = _first_token_count(
usage_data, "completion_tokens", "output_tokens"
)
cache_read, cache_write = _extract_cache_tokens(usage_data)
# ``prompt_tokens`` is the inclusive grand total; ``input_tokens`` (Anthropic
# native) excludes cached tokens. The field chosen decides whether to subtract.
if "prompt_tokens" in usage_data:
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
input_tokens = max(0, input_tokens - cache_read - cache_write)
else:
input_tokens = parse_token_count(usage_data.get("input_tokens", 0))
return NormalizedUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
)

View File

@@ -1,9 +1,9 @@
import asyncio
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.responses import Response, StreamingResponse
from sqlmodel import select
from .algorithm import create_model_mappings
@@ -18,6 +18,7 @@ from .core.db import (
get_session,
)
from .core.exceptions import UpstreamError
from .core.not_found import build_not_found_response
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
@@ -28,6 +29,7 @@ from .payment.helpers import (
from .payment.models import Model
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
from .upstream.request_correction import correct_request, extract_error_message
logger = get_logger(__name__)
proxy_router = APIRouter()
@@ -151,50 +153,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 +185,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 +269,15 @@ async def proxy(
)
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
"Upstream %s failed (x-cashu) for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
},
)
if i == len(upstreams) - 1:
last_error = e
@@ -329,50 +354,86 @@ async def proxy(
if request_body_dict:
await pay_for_request(key, max_cost_for_model, session)
# Tracks request params already removed in response to upstream rejections,
# shared across providers so a stripped param stays stripped on failover and
# the reactive retry can never loop unboundedly.
already_stripped: set[str] = set()
for i, upstream in enumerate(upstreams):
headers = upstream.prepare_headers(dict(request.headers))
try:
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
while True:
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
except UpstreamError:
# Let the outer UpstreamError handler manage retry/revert
raise
except Exception as e:
# Unexpected error (not an upstream failure) — revert and propagate
logger.error(
"Unexpected error in upstream request, reverting payment",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
else:
response = await upstream.forward_request(
request,
path,
headers,
await revert_pay_for_request(key, session, max_cost_for_model)
raise
# Reactive recovery: some models reject one specific request
# param (e.g. newer Anthropic models deprecating `temperature`).
# When the upstream 400s naming such a param, strip it from the
# body and retry the SAME upstream. ``already_stripped`` bounds
# this to one retry per distinct param so it always terminates.
if response.status_code == 400:
correction = correct_request(
request_body,
key,
max_cost_for_model,
session,
model_obj,
extract_error_message(response),
already_stripped,
)
except UpstreamError:
# Let the outer UpstreamError handler manage retry/revert
raise
except Exception as e:
# Unexpected error (not an upstream failure) — revert and propagate
logger.error(
"Unexpected error in upstream request, reverting payment",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
raise
if correction is not None:
request_body, bad_param = correction.body, correction.label
already_stripped.add(bad_param)
logger.warning(
"Upstream %s rejected param '%s' for model=%s; "
"stripping and retrying same upstream",
upstream.provider_type,
bad_param,
model_id,
extra={
"provider": upstream.provider_type,
"model": model_id,
"stripped_param": bad_param,
"path": path,
},
)
continue
break
if response.status_code != 200:
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
@@ -397,10 +458,14 @@ async def proxy(
)
logger.warning(
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
"Upstream %s returned %s for model=%s, trying next provider",
upstream.provider_type,
response.status_code,
model_id,
extra={
"status_code": response.status_code,
"upstream": upstream.provider_type,
"provider": upstream.provider_type,
"model": model_id,
},
)
continue
@@ -408,26 +473,53 @@ async def proxy(
# 4xx error (user error), or other non-retryable error, or last provider failed
await revert_pay_for_request(key, session, max_cost_for_model)
logger.warning(
"Upstream request failed, revert payment",
"Upstream request failed, revert payment "
"(provider=%s model=%s status=%s path=%s)",
upstream.provider_type,
model_id,
response.status_code,
path,
extra={
"status_code": response.status_code,
"path": path,
"provider": upstream.provider_type,
"model": model_id,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
return response
return response
except asyncio.CancelledError:
logger.warning(
"Client disconnected mid-request, reverting reservation",
extra={
"path": path,
"model": model_id,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
await asyncio.shield(
revert_pay_for_request(key, session, max_cost_for_model)
)
raise
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed: {e}",
extra={"retry": i < len(upstreams) - 1},
"Upstream %s failed for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
"retry": i < len(upstreams) - 1,
},
)
# If this was the last provider

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
"""Inject explicit prompt-cache breakpoints into OpenAI-shaped requests.
Some upstreams cache *explicitly*: the request must carry
``cache_control: {"type": "ephemeral"}`` markers on the content blocks that
should be cached. Two model families use this identical wire format:
* **Anthropic Claude** — direct or via OpenRouter's ``anthropic/*`` models.
* **Alibaba's explicit-cache models on OpenRouter** — ``qwen/qwen3-max``,
``qwen/qwen-plus``, ``qwen/qwen3.6-plus``, ``qwen/qwen3-coder-plus``,
``qwen/qwen3-coder-flash`` and ``deepseek/deepseek-v3.2`` — which OpenRouter
documents as using "the same syntax as Anthropic explicit caching".
Every other provider routstr proxies (OpenAI, Azure, xAI/Grok, Groq, Moonshot,
default DeepSeek, Gemini implicit, Fireworks) caches *automatically* and needs
no markers — they are left untouched.
A client that doesn't know it is talking to one of these models *through*
routstr (e.g. an OpenAI-compatible coding agent pointed at a routstr URL) never
emits the markers — it only adds them when it recognises the provider as
OpenRouter. So caching silently never engages over routstr even though the same
client caches fine talking to OpenRouter directly.
This module restores caching by stamping the standard breakpoints onto the
forwarded body — the system prompt, the last tool, and the last conversation
message (the format allows up to four; we use three, matching the common
agent convention) — but only when the client supplied none of its own, so
explicit client control always wins. The caller is responsible for only
applying this toward an upstream that accepts the markers (OpenRouter /
Anthropic), so they never leak to a provider that would reject them.
"""
from __future__ import annotations
from typing import Any
# The single ephemeral marker stamped onto each chosen breakpoint. A 5-minute
# TTL (the default for ``ephemeral``) — deliberately not the 1h tier, which
# carries a higher cache-write premium and should stay opt-in.
EPHEMERAL_CACHE_CONTROL: dict[str, str] = {"type": "ephemeral"}
# Alibaba's explicit-cache models on OpenRouter. Matched as substrings of the
# model id (any spelling routstr carries). Snapshot endpoints that OpenRouter
# documents as *not* supporting explicit caching (e.g. ``qwen3.5-plus-02-15``)
# are different families and deliberately absent from this list.
_ALIBABA_EXPLICIT_CACHE_SLUGS: tuple[str, ...] = (
"qwen3-max",
"qwen-plus",
"qwen3.6-plus",
"qwen3-coder-plus",
"qwen3-coder-flash",
"deepseek-v3.2",
)
def is_explicit_cache_model(model_id: str | None, *fallbacks: str | None) -> bool:
"""True when the target model uses the explicit ``cache_control`` dialect.
Covers the Claude family (broadly — every Claude model supports it) and
Alibaba's documented explicit-cache models, across the id spellings routstr
carries: the OpenRouter id (``anthropic/claude-...``, ``qwen/qwen3-max``),
the bare upstream id, and any forwarded/canonical alias.
"""
for candidate in (model_id, *fallbacks):
if not candidate:
continue
lowered = candidate.lower()
if "claude" in lowered or "anthropic/" in lowered:
return True
if any(slug in lowered for slug in _ALIBABA_EXPLICIT_CACHE_SLUGS):
return True
return False
def _has_cache_control(obj: Any) -> bool:
"""Recursively detect any client-supplied ``cache_control`` marker."""
if isinstance(obj, dict):
if "cache_control" in obj:
return True
return any(_has_cache_control(v) for v in obj.values())
if isinstance(obj, list):
return any(_has_cache_control(v) for v in obj)
return False
def body_has_cache_control(data: dict) -> bool:
"""True when the request already carries cache_control on messages/tools."""
return _has_cache_control(data.get("messages")) or _has_cache_control(
data.get("tools")
)
def _stamp_text_content(message: dict) -> bool:
"""Add the ephemeral marker to a message's last text block.
A string content is promoted to the array form Anthropic requires for
cache markers; an existing array gets the marker on its last text part.
Returns True when a marker was placed.
"""
content = message.get("content")
if isinstance(content, str):
if not content:
return False
message["content"] = [
{
"type": "text",
"text": content,
"cache_control": dict(EPHEMERAL_CACHE_CONTROL),
}
]
return True
if isinstance(content, list):
for part in reversed(content):
if isinstance(part, dict) and part.get("type") == "text":
part["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
return True
return False
def _stamp_system_prompt(messages: list) -> None:
for message in messages:
if isinstance(message, dict) and message.get("role") in (
"system",
"developer",
):
_stamp_text_content(message)
return
def _stamp_last_tool(tools: Any) -> None:
if isinstance(tools, list) and tools and isinstance(tools[-1], dict):
tools[-1]["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
def _stamp_last_conversation_message(messages: list) -> None:
for message in reversed(messages):
if isinstance(message, dict) and message.get("role") in ("user", "assistant"):
if _stamp_text_content(message):
return
def inject_anthropic_cache_breakpoints(data: dict) -> bool:
"""Stamp ephemeral cache breakpoints onto an OpenAI-shaped chat body.
Mutates ``data`` in place (the established convention in
``prepare_request_body``) and returns True when anything changed. No-ops
when the body isn't chat-shaped or the client already set cache_control.
"""
messages = data.get("messages")
if not isinstance(messages, list) or not messages:
return False
if body_has_cache_control(data):
return False
_stamp_system_prompt(messages)
_stamp_last_tool(data.get("tools"))
_stamp_last_conversation_message(messages)
return True

View File

@@ -0,0 +1,66 @@
"""TEMPORARY: local DeepSeek V4 pricing shim.
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
``cache_read_input_token_cost`` and cache reads fall back to the full input
rate — a ~60% overcharge on cache hits (DeepSeek hits are ~0.2x input).
This module injects the missing entries into ``litellm.model_cost`` at startup
so the existing backfill path resolves them. Rates mirror the open upstream PR
https://github.com/BerriAI/litellm/pull/26380 (issue
https://github.com/BerriAI/litellm/issues/30430).
=== REMOVAL (once litellm ships these models) ===
Delete this file and the single ``register_deepseek_v4_pricing()`` call in
``routstr/core/main.py``. Nothing else depends on it. Entries are only added
when absent, so a stale shim is harmless after upstream lands — but remove it.
"""
import litellm
from ..core import get_logger
logger = get_logger(__name__)
# USD per token. Source: BerriAI/litellm PR #26380.
_DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = {
"deepseek-v4-flash": {
"input_cost_per_token": 1.4e-07,
"output_cost_per_token": 2.8e-07,
"cache_read_input_token_cost": 2.8e-08,
"cache_creation_input_token_cost": 0.0,
"input_cost_per_token_cache_hit": 2.8e-08,
},
"deepseek-v4-pro": {
"input_cost_per_token": 1.74e-06,
"output_cost_per_token": 3.48e-06,
"cache_read_input_token_cost": 1.4e-07,
"cache_creation_input_token_cost": 0.0,
"input_cost_per_token_cache_hit": 1.4e-07,
},
}
def register_deepseek_v4_pricing() -> None:
"""Inject DeepSeek V4 pricing into ``litellm.model_cost`` if absent.
Idempotent and non-destructive: a key already present in the cost map
(e.g. once litellm ships it) is left untouched. Registers both the bare
(``deepseek-v4-flash``) and prefixed (``deepseek/deepseek-v4-flash``)
spellings since ``backfill_cache_pricing`` tries both.
"""
added = []
for bare, rates in _DEEPSEEK_V4_RATES.items():
for key in (bare, f"deepseek/{bare}"):
if key in litellm.model_cost:
continue
entry: dict[str, object] = dict(rates)
entry["litellm_provider"] = "deepseek"
entry["mode"] = "chat"
litellm.model_cost[key] = entry
added.append(key)
if added:
logger.info(
"Registered temporary DeepSeek V4 pricing shim",
extra={"models": added},
)

View File

@@ -1,3 +1,4 @@
import json
from typing import TYPE_CHECKING
import httpx
@@ -18,6 +19,65 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
supports_anthropic_messages = True
litellm_provider_prefix = "openrouter/"
def prepare_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
"""Set provider.require_parameters on tool-use requests.
Without it OpenRouter can route a tool call to an endpoint that doesn't
support function calling and 404 with "No endpoints found that support
tool use". We leave a client-supplied value untouched.
"""
body = super().prepare_request_body(body, model_obj)
if not body:
return body
try:
data = json.loads(body)
except json.JSONDecodeError:
return body
if not isinstance(data, dict) or not data.get("tools"):
return body
provider = data.get("provider")
if not isinstance(provider, dict):
provider = {}
if "require_parameters" in provider:
return body
provider["require_parameters"] = True
data["provider"] = provider
return json.dumps(data).encode()
def _apply_provider_field(self, response_json: object) -> None:
"""Stamp the ``provider`` field for OpenRouter responses.
OpenRouter is a router, not the real serving provider, so a bare
``"openrouter"`` value carries no useful information. Rules:
- Real upstream sub-provider (e.g. ``"GMICloud"``) -> ``"openrouter:GMICloud"``.
- Missing sub-provider, or one that merely echoes ``"openrouter"`` ->
``"unknown"``.
- Idempotent: re-stamping never produces ``"openrouter:openrouter:..."``;
the ``openrouter:`` prefix appears at most once.
"""
if not isinstance(response_json, dict):
return
provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider")
sub = existing.strip() if isinstance(existing, str) else ""
# Strip any already-applied "openrouter:" prefixes (idempotency).
prefix = f"{provider_type}:"
while sub.lower().startswith(prefix.lower()):
sub = sub[len(prefix) :].strip()
# No real sub-provider, or it just echoes our own router name.
if not sub or sub.lower() == provider_type.lower():
response_json["provider"] = "unknown"
return
response_json["provider"] = f"{provider_type}:{sub}"
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.

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

@@ -35,6 +35,24 @@ async def get_balance(unit: str) -> int:
return wallet.available_balance.amount
async def _redeem_same_mint(
wallet: Wallet, token_obj: Token
) -> tuple[int, str, str]: # amount, unit, mint_url
"""Redeem proofs at their own issuing mint (no cross-mint swap).
split() re-mints the incoming proofs into fresh ones we own so the sender
can't double-spend them. With include_fees=True the mint deducts its NUT-02
per-proof input fee, so we end up holding only `amount - input_fees`. Credit
that, not the face value, or routstr over-credits the user and its wallet
drifts insolvent.
"""
await wallet.load_mint(keyset_id=token_obj.keysets[0])
wallet.verify_proofs_dleq(token_obj.proofs)
input_fees = wallet.get_fees_for_proofs(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return int(token_obj.amount) - input_fees, token_obj.unit, token_obj.mint
async def recieve_token(
token: str,
) -> tuple[int, str, str]: # amount, unit, mint_url
@@ -48,27 +66,61 @@ async def recieve_token(
if token_obj.mint not in settings.cashu_mints:
return await swap_to_primary_mint(token_obj, wallet)
await wallet.load_mint(keyset_id=token_obj.keysets[0])
wallet.verify_proofs_dleq(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_obj.amount, token_obj.unit, token_obj.mint
return await _redeem_same_mint(wallet, token_obj)
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
"""Internal send function - returns amount and serialized token"""
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url or settings.primary_mint, unit
effective_mint_url = mint_url or settings.primary_mint
wallet: Wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs_for_mint = sum(p.amount for p in proofs)
# Fallback: proofs from untrusted source mints are swapped to primary_mint
# during receive, so the user's preferred refund_mint_url may have no proofs
# even though the global wallet has the balance.
if proofs_for_mint < amount and effective_mint_url != settings.primary_mint:
logger.info(
f"send: insufficient proofs at {effective_mint_url} "
f"(have {proofs_for_mint}, need {amount}), falling back to primary_mint={settings.primary_mint}"
)
effective_mint_url = settings.primary_mint
wallet = await get_wallet(effective_mint_url, unit)
proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
proofs_for_mint = sum(p.amount for p in proofs)
all_mint_urls = list({k.mint_url for k in wallet.keysets.values()})
proof_summary = {
f"{k.mint_url}/{k.unit.name}": sum(p.amount for p in wallet.proofs if p.id == k.id)
for k in wallet.keysets.values()
}
# Show ALL proofs in DB by keyset_id, regardless of whether the loaded wallet
# knows about that keyset. This reveals proofs orphaned under stale keysets.
raw_proofs_by_keyset: dict[str, int] = {}
for p in wallet.proofs:
raw_proofs_by_keyset[p.id] = raw_proofs_by_keyset.get(p.id, 0) + p.amount
logger.info(
f"send: proof inventory | mint={effective_mint_url} unit={unit} amount={amount} "
f"primary_mint={settings.primary_mint} proofs_for_mint={proofs_for_mint} "
f"all_mints={all_mint_urls} by_keyset={proof_summary} "
f"raw_proofs_by_keyset_id={raw_proofs_by_keyset} "
f"total_wallet_proofs={sum(p.amount for p in wallet.proofs)}"
)
# Reserve proofs only after serialization succeeds — if serialize_proofs or
# swap_to_send fails mid-way, proofs stay unreserved so dashboard balance
# doesn't go negative.
send_proofs, _ = await wallet.select_to_send(
proofs, amount, set_reserved=True, include_fees=False
)
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
proofs, amount, set_reserved=False, include_fees=False
)
try:
token = await wallet.serialize_proofs(
send_proofs, include_dleq=False, legacy=False, memo=None
)
except Exception:
await wallet.set_reserved_for_send(send_proofs, reserved=False)
raise
await wallet.set_reserved_for_send(send_proofs, reserved=True)
return amount, token
@@ -83,10 +135,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 +166,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 +180,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,
},
@@ -171,10 +226,9 @@ async def swap_to_primary_mint(
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
# If the token is already from the primary mint, we don't need to swap
# and we definitely don't want to calculate or pay fees.
# If the token is already from the primary mint, we don't need a cross-mint
# swap — redeem it same-mint. There's no melt/Lightning fee, but the mint's
# NUT-02 input fee still applies; _redeem_same_mint accounts for it.
if token_obj.mint == settings.primary_mint:
logger.info(
"swap_to_primary_mint: token already on primary mint, skipping swap",
@@ -184,8 +238,9 @@ async def swap_to_primary_mint(
"unit": token_obj.unit,
},
)
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_amount, token_obj.unit, token_obj.mint
return await _redeem_same_mint(token_wallet, token_obj)
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
minted_amount = await _calculate_swap_amount(
amount_msat,
@@ -193,6 +248,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 +258,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 +279,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 +287,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 +318,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 +416,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 +565,7 @@ async def fetch_all_balances(
"unit": unit,
"wallet_balance": proofs_balance,
"user_balance": user_balance,
"owner_balance": proofs_balance - user_balance,
"owner_balance": proofs_balance - user_balance if proofs_balance != 0 else 0,
}
return result
except Exception as e:
@@ -460,11 +580,19 @@ async def fetch_all_balances(
}
return error_result
# Build the set of mints to inspect. Received tokens are stored against
# ``primary_mint`` (which defaults to a real mint even when ``cashu_mints``
# is empty), so include it as a fallback — otherwise a node that accepts
# payments would still report empty balances when ``cashu_mints`` is unset.
mint_urls: list[str] = list(settings.cashu_mints)
if settings.primary_mint and settings.primary_mint not in mint_urls:
mint_urls.append(settings.primary_mint)
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in settings.cashu_mints
for mint_url in mint_urls
for unit in units
]
@@ -493,7 +621,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

@@ -69,9 +69,14 @@ async def test_wallet_info_child_key_no_child_keys(
info_response = await integration_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
parent_key = authenticated_client._test_api_key # type: ignore[attr-defined]
parent_key_hash = parent_key.removeprefix("sk-")
assert info_data["is_child"] is True
assert "child_keys" not in info_data
assert "parent_key" not in info_data
assert info_data["parent_key_preview"] == parent_key_hash[:8] + "..."
assert info_data["parent_key_preview"] not in {parent_key, parent_key_hash}
@pytest.mark.integration

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

@@ -1,12 +1,14 @@
import asyncio
import time
from datetime import datetime, timedelta
import pytest
from fastapi import HTTPException
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.core.db import ApiKey
from routstr.core.db import ApiKey, create_session
@pytest.mark.asyncio
@@ -120,6 +122,270 @@ async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None
assert key2.total_spent == 0
@pytest.mark.asyncio
async def test_balance_limit_enforced_atomically_under_concurrency(
patched_db_engine: None,
) -> None:
parent_hash = "parent_limit_atomic"
child_hash = "child_limit_atomic"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=cost,
total_spent=0,
)
session.add(parent)
session.add(child)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_child = await session.get(ApiKey, child_hash)
assert fresh_child is not None
try:
await pay_for_request(fresh_child, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final_child = await session.get(ApiKey, child_hash)
assert final_child is not None
assert final_child.reserved_balance == cost, (
f"Child reserved_balance should equal one reservation, "
f"got {final_child.reserved_balance}"
)
@pytest.mark.asyncio
async def test_parallel_payments_with_parent_and_child_key(
patched_db_engine: None,
) -> None:
parent_hash = "parent_parallel_mixed"
child_hash = "child_parallel_mixed"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=2 * cost,
)
session.add(parent)
session.add(child)
await session.commit()
async def attempt(key_hash: str) -> str:
async with create_session() as session:
fresh_key = await session.get(ApiKey, key_hash)
assert fresh_key is not None
try:
await pay_for_request(fresh_key, cost, session)
return "success"
except HTTPException as exc:
assert exc.status_code == 402
return "blocked"
results = await asyncio.gather(attempt(parent_hash), attempt(child_hash))
assert results == ["success", "success"], (
f"Both parent and child payments should succeed, got: {results}"
)
async with create_session() as session:
final_parent = await session.get(ApiKey, parent_hash)
final_child = await session.get(ApiKey, child_hash)
assert final_parent is not None
assert final_child is not None
# Both requests bill the parent; only the child request reserves on the child.
assert final_parent.reserved_balance == 2 * cost
assert final_parent.total_requests == 2
assert final_child.reserved_balance == cost
assert final_child.total_requests == 1
@pytest.mark.asyncio
async def test_balance_limit_with_existing_total_spent_under_concurrency(
patched_db_engine: None,
) -> None:
parent_hash = "parent_total_spent"
child_hash = "child_total_spent"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
# 700 already spent against a 1000 limit: only one more 300 request fits.
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=1000,
total_spent=700,
)
session.add(parent)
session.add(child)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_child = await session.get(ApiKey, child_hash)
assert fresh_child is not None
try:
await pay_for_request(fresh_child, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final_child = await session.get(ApiKey, child_hash)
assert final_child is not None
assert final_child.reserved_balance == cost
assert final_child.total_spent == 700
@pytest.mark.asyncio
async def test_balance_limit_with_existing_reserved_balance(
patched_db_engine: None,
) -> None:
parent_hash = "parent_reserved_set"
blocked_hash = "child_reserved_blocked"
allowed_hash = "child_reserved_allowed"
cost = 300
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=10000)
# 800 already reserved against a 1000 limit: another 300 must be rejected.
blocked_child = ApiKey(
hashed_key=blocked_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=1000,
reserved_balance=800,
)
# 500 reserved against a 1000 limit: another 300 still fits.
allowed_child = ApiKey(
hashed_key=allowed_hash,
balance=0,
parent_key_hash=parent_hash,
balance_limit=1000,
reserved_balance=500,
)
session.add(parent)
session.add(blocked_child)
session.add(allowed_child)
await session.commit()
async with create_session() as session:
fresh_blocked = await session.get(ApiKey, blocked_hash)
assert fresh_blocked is not None
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(fresh_blocked, cost, session)
assert exc_info.value.status_code == 402
async with create_session() as session:
fresh_allowed = await session.get(ApiKey, allowed_hash)
assert fresh_allowed is not None
await pay_for_request(fresh_allowed, cost, session)
async with create_session() as session:
final_blocked = await session.get(ApiKey, blocked_hash)
final_allowed = await session.get(ApiKey, allowed_hash)
final_parent = await session.get(ApiKey, parent_hash)
assert final_blocked is not None
assert final_allowed is not None
assert final_parent is not None
assert final_blocked.reserved_balance == 800, "Rejected request must not reserve"
assert final_blocked.total_requests == 0
assert final_allowed.reserved_balance == 500 + cost
assert final_allowed.total_requests == 1
# Only the allowed request should have billed the parent.
assert final_parent.reserved_balance == cost
assert final_parent.total_requests == 1
@pytest.mark.asyncio
async def test_child_reservation_discarded_when_parent_balance_depleted(
patched_db_engine: None,
) -> None:
parent_hash = "parent_depleted"
child_hash = "child_depleted"
cost = 300
async with create_session() as session:
# Parent can only afford one request; child has no balance_limit.
parent = ApiKey(hashed_key=parent_hash, balance=cost)
child = ApiKey(
hashed_key=child_hash,
balance=0,
parent_key_hash=parent_hash,
)
session.add(parent)
session.add(child)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_child = await session.get(ApiKey, child_hash)
assert fresh_child is not None
try:
await pay_for_request(fresh_child, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final_parent = await session.get(ApiKey, parent_hash)
final_child = await session.get(ApiKey, child_hash)
assert final_parent is not None
assert final_child is not None
assert final_parent.reserved_balance == cost
# The failed request must not leave a committed reservation on the child.
assert final_child.reserved_balance == cost, (
f"Child reserved_balance should reflect only the successful request, "
f"got {final_child.reserved_balance}"
)
assert final_child.total_requests == 1
@pytest.mark.asyncio
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
# This requires mocking the router call or testing the logic in balance.py

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,224 @@
import time
from types import TracebackType
from typing import Any
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
from routstr.core.db import ModelRow, UpstreamProviderRow
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_requires_admin_auth(
integration_client: AsyncClient,
) -> None:
with patch("httpx.AsyncClient") as mock_async_client:
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "chat-completions",
"request_data": {"messages": []},
},
)
assert response.status_code == 403
mock_async_client.assert_not_called()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_rejects_unsupported_endpoint_type(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token-model-test"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="custom",
base_url="https://api.example.com/v1",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
model = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=provider.id,
enabled=True,
)
integration_session.add(model)
await integration_session.commit()
try:
with patch("httpx.AsyncClient") as mock_async_client:
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "../../abuse",
"request_data": {"messages": []},
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Unsupported endpoint_type"
mock_async_client.assert_not_called()
finally:
admin_sessions.pop(admin_token, None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_rejects_oversized_request_data(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token-model-test-oversized"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="custom",
base_url="https://api.example.com/v1",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
model = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=provider.id,
enabled=True,
)
integration_session.add(model)
await integration_session.commit()
oversized = "x" * (64 * 1024 + 1)
try:
with patch("httpx.AsyncClient") as mock_async_client:
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "chat-completions",
"request_data": {"blob": oversized},
},
)
assert response.status_code == 413
assert response.json()["detail"] == "request_data too large"
mock_async_client.assert_not_called()
finally:
admin_sessions.pop(admin_token, None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_admin_uses_allowed_upstream_path(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token-model-test-success"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="custom",
base_url="https://api.example.com/v1",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
model = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=provider.id,
enabled=True,
forwarded_model_id="upstream-model-a",
)
integration_session.add(model)
await integration_session.commit()
class MockResponse:
status_code = 200
text = '{"ok": true}'
def json(self) -> dict[str, bool]:
return {"ok": True}
class MockAsyncClient:
async def __aenter__(self) -> "MockAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
async def post(
self, url: str, json: dict[str, Any], headers: dict[str, str]
) -> MockResponse:
assert url == "https://api.example.com/v1/chat/completions"
assert json["model"] == "upstream-model-a"
assert headers["Authorization"] == "Bearer sk-upstream-test"
return MockResponse()
try:
with patch("httpx.AsyncClient", return_value=MockAsyncClient()):
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "chat-completions",
"request_data": {"messages": []},
},
)
assert response.status_code == 200
assert response.json() == {
"success": True,
"data": {"ok": True},
"status_code": 200,
}
finally:
admin_sessions.pop(admin_token, None)

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

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

@@ -0,0 +1,59 @@
import hashlib
from types import SimpleNamespace
from typing import AsyncGenerator
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import validate_bearer_key
from routstr.core.db import ApiKey
def _make_engine() -> AsyncEngine:
return create_async_engine(
"sqlite+aiosqlite://",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
engine = _make_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
db_session = AsyncSession(engine, expire_on_commit=False)
try:
yield db_session
finally:
await db_session.close()
await engine.dispose()
@pytest.mark.asyncio
async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
session: AsyncSession,
) -> None:
token = "cashuAfirst_seen_but_redemption_fails"
hashed_key = hashlib.sha256(token.encode()).hexdigest()
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
from routstr.core.settings import settings
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
patch(
"routstr.auth.credit_balance",
new=AsyncMock(side_effect=ValueError("token already spent")),
),
):
with pytest.raises(HTTPException):
await validate_bearer_key(token, session)
assert await session.get(ApiKey, hashed_key) is None

View File

@@ -0,0 +1,189 @@
"""Tests for Anthropic cache-breakpoint injection on forwarded requests.
Anthropic prompt caching is explicit; a client that doesn't recognise a routstr
URL as Anthropic-backed never sends ``cache_control`` markers, so caching never
engages over routstr. ``prepare_request_body`` must stamp the standard
breakpoints for Anthropic-family models while always deferring to client-set
markers and never touching automatic-cache providers.
"""
import json
import os
import pytest
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
from routstr.upstream import GenericUpstreamProvider
from routstr.upstream.cache_breakpoints import (
body_has_cache_control,
inject_anthropic_cache_breakpoints,
is_explicit_cache_model,
)
def _chat_body() -> dict:
return {
"model": "anthropic/claude-sonnet-4.5",
"stream": True,
"messages": [
{"role": "system", "content": "You are concise."},
{"role": "user", "content": "Hello"},
],
"tools": [
{"type": "function", "function": {"name": "a"}},
{"type": "function", "function": {"name": "b"}},
],
}
# ---------------------------------------------------------------------------
# Model detection
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"model_id,expected",
[
("anthropic/claude-sonnet-4.5", True),
("claude-haiku-4-5-20251001", True),
# Alibaba explicit-cache models share Anthropic's wire format
("qwen/qwen3-max", True),
("qwen/qwen3-coder-plus", True),
("deepseek/deepseek-v3.2", True),
# Automatic-cache providers need no markers
("openai/gpt-4o", False),
("google/gemini-2.5-flash", False),
("deepseek/deepseek-chat", False),
("qwen/qwen3.5-plus-02-15", False), # snapshot, no explicit caching
(None, False),
],
)
def test_is_explicit_cache_model(model_id: str | None, expected: bool) -> None:
assert is_explicit_cache_model(model_id) is expected
def test_is_explicit_cache_model_uses_fallbacks() -> None:
# routstr id is opaque but a forwarded/canonical alias reveals the family.
assert is_explicit_cache_model("model-xyz", None, "anthropic/claude-opus-4.1")
# ---------------------------------------------------------------------------
# Breakpoint placement
# ---------------------------------------------------------------------------
def test_injects_three_breakpoints() -> None:
data = _chat_body()
assert inject_anthropic_cache_breakpoints(data) is True
# system prompt promoted to array form with a marker
system = data["messages"][0]["content"]
assert system == [
{
"type": "text",
"text": "You are concise.",
"cache_control": {"type": "ephemeral"},
}
]
# last tool marked
assert data["tools"][-1]["cache_control"] == {"type": "ephemeral"}
assert "cache_control" not in data["tools"][0]
# last user message marked
user = data["messages"][1]["content"]
assert user[-1]["cache_control"] == {"type": "ephemeral"}
def test_defers_to_client_supplied_cache_control() -> None:
data = _chat_body()
data["messages"][1]["content"] = [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
]
assert body_has_cache_control(data) is True
# No additional stamping when the client already controls caching.
assert inject_anthropic_cache_breakpoints(data) is False
assert "cache_control" not in data["tools"][-1]
def test_marks_last_text_part_of_array_content() -> None:
data = _chat_body()
data["messages"][1]["content"] = [
{"type": "text", "text": "first"},
{"type": "image_url", "image_url": {"url": "x"}},
{"type": "text", "text": "last"},
]
inject_anthropic_cache_breakpoints(data)
parts = data["messages"][1]["content"]
assert parts[2]["cache_control"] == {"type": "ephemeral"}
assert "cache_control" not in parts[0]
def test_noop_without_messages() -> None:
assert inject_anthropic_cache_breakpoints({"prompt": "x"}) is False
# ---------------------------------------------------------------------------
# prepare_request_body integration
# ---------------------------------------------------------------------------
def _model(model_id: str): # type: ignore[no-untyped-def]
from routstr.payment.models import Architecture, Model, Pricing
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=200000,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="Claude",
instruct_type=None,
),
pricing=Pricing(prompt=0.0, completion=0.0),
)
def _openrouter_provider() -> "GenericUpstreamProvider":
# OpenRouter endpoint via the generic provider — recognised by base URL.
return GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1")
@pytest.mark.parametrize(
"model_id", ["anthropic/claude-sonnet-4.5", "qwen/qwen3-max", "deepseek/deepseek-v3.2"]
)
def test_prepare_request_body_injects_for_explicit_models(model_id: str) -> None:
provider = _openrouter_provider()
body = json.dumps(_chat_body()).encode()
out = provider.prepare_request_body(body, _model(model_id))
assert out is not None
data = json.loads(out)
assert body_has_cache_control(data) is True
assert data["tools"][-1]["cache_control"] == {"type": "ephemeral"}
def test_prepare_request_body_skips_for_automatic_provider_model() -> None:
provider = _openrouter_provider()
body = json.dumps(_chat_body()).encode()
out = provider.prepare_request_body(body, _model("openai/gpt-4o"))
assert out is not None
data = json.loads(out)
assert body_has_cache_control(data) is False
def test_prepare_request_body_skips_when_upstream_rejects_markers() -> None:
# Claude id but a non-OpenRouter/Anthropic upstream → must NOT inject,
# since the markers could be rejected by an upstream that doesn't accept them.
from routstr.upstream import GenericUpstreamProvider
provider = GenericUpstreamProvider(base_url="https://some-gateway.example/v1")
body = json.dumps(_chat_body()).encode()
out = provider.prepare_request_body(body, _model("anthropic/claude-sonnet-4.5"))
assert out is not None
data = json.loads(out)
assert body_has_cache_control(data) is False

View File

@@ -0,0 +1,245 @@
"""Tests for cache-aware pricing of cached input tokens.
Specifies two things:
1. ``backfill_cache_pricing`` — when the OpenRouter model feed omits cache
rates (it does for most DeepSeek models and e.g. openai/gpt-4o), they are
filled from litellm's bundled cost map instead of silently billing cache
reads at the full input rate. Existing OpenRouter values are never
overwritten, and provider fees apply to backfilled rates like any other.
2. ``calculate_cost`` — cached tokens are billed at the cache rates from the
model's sats_pricing; the full input rate remains only as the documented
last resort when no cache rate could be resolved anywhere.
"""
import os
from unittest.mock import Mock, patch
import litellm
import pytest
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
from routstr.core.settings import settings
from routstr.payment.cost_calculation import CostData, calculate_cost
from routstr.payment.models import (
Architecture,
Model,
Pricing,
backfill_cache_pricing,
)
from routstr.upstream import GenericUpstreamProvider
def _make_model(model_id: str, pricing: Pricing) -> Model:
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=64000,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="Other",
instruct_type=None,
),
pricing=pricing,
)
# ============================================================================
# backfill_cache_pricing — litellm as fallback source for missing cache rates
# ============================================================================
def test_backfill_deepseek_cache_read_from_litellm() -> None:
"""deepseek/deepseek-chat has no input_cache_read on OpenRouter; litellm
knows the real rate (10x cheaper than input)."""
pricing = Pricing(prompt=2.8e-07, completion=4.2e-07)
result = backfill_cache_pricing("deepseek/deepseek-chat", pricing)
expected = litellm.model_cost["deepseek/deepseek-chat"][
"cache_read_input_token_cost"
]
assert result.input_cache_read == expected
assert result.input_cache_read < pricing.prompt # sanity: it's a discount
def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None:
"""OpenRouter ids are vendor-prefixed (openai/gpt-4o); litellm keys most
non-DeepSeek models without the prefix (gpt-4o)."""
pricing = Pricing(prompt=2.5e-06, completion=1e-05)
result = backfill_cache_pricing("openai/gpt-4o", pricing)
expected = litellm.model_cost["gpt-4o"]["cache_read_input_token_cost"]
assert result.input_cache_read == expected
def test_backfill_fills_cache_write_rate() -> None:
"""Anthropic cache writes cost more than input (1.25x); billing them at
the input rate undercharges. litellm carries the write rate."""
pricing = Pricing(prompt=3e-06, completion=1.5e-05)
result = backfill_cache_pricing("anthropic/claude-sonnet-4-5", pricing)
expected = litellm.model_cost["claude-sonnet-4-5"][
"cache_creation_input_token_cost"
]
assert result.input_cache_write == expected
assert result.input_cache_write > pricing.prompt # sanity: write premium
def test_backfill_never_overwrites_openrouter_rates() -> None:
"""When OpenRouter provides a cache rate, it is authoritative."""
pricing = Pricing(
prompt=2.1e-07, completion=7.9e-07, input_cache_read=1.3e-07
)
result = backfill_cache_pricing("deepseek/deepseek-chat", pricing)
assert result.input_cache_read == 1.3e-07
def test_backfill_unknown_model_unchanged() -> None:
"""Models litellm doesn't know stay untouched (last-resort fallback to
the input rate happens later, at billing time)."""
pricing = Pricing(prompt=1e-06, completion=2e-06)
result = backfill_cache_pricing("artificial-dumbness/dumb-1", pricing)
assert result.input_cache_read == 0.0
assert result.input_cache_write == 0.0
def test_provider_fee_applies_to_backfilled_cache_rates() -> None:
"""Backfill happens before the provider fee, so cache rates carry the
same markup as every other price component."""
provider = GenericUpstreamProvider(
base_url="http://upstream.example", provider_fee=2.0
)
model = _make_model(
"deepseek/deepseek-chat", Pricing(prompt=2.8e-07, completion=4.2e-07)
)
adjusted = provider._apply_provider_fee_to_model(model)
litellm_read = litellm.model_cost["deepseek/deepseek-chat"][
"cache_read_input_token_cost"
]
assert adjusted.pricing.input_cache_read == pytest.approx(litellm_read * 2.0)
assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0)
# ============================================================================
# calculate_cost — cached tokens billed at cache rates
# ============================================================================
@pytest.fixture(autouse=True)
def patch_sats_usd_price() -> None: # type: ignore[misc]
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
yield
@pytest.fixture
def model_pricing(monkeypatch: pytest.MonkeyPatch) -> Mock:
"""Model-based pricing: 1 msat per input token, 2 per output token,
0.1 per cache-read token, 1.25 per cache-write token."""
monkeypatch.setattr(settings, "fixed_pricing", False)
model = Mock()
model.sats_pricing = Pricing(
prompt=0.001,
completion=0.002,
input_cache_read=0.0001,
input_cache_write=0.00125,
)
return model
@pytest.mark.asyncio
async def test_deepseek_cache_hits_billed_at_cache_rate(model_pricing: Mock) -> None:
"""The reported overcharge scenario: a 10k-token prompt with 90% cache
hits costs 2900 msats at honest rates, not the 11000 msats that billing
every prompt token at the full input rate would charge."""
response = {
"model": "deepseek-chat",
"usage": {
"prompt_tokens": 10000,
"completion_tokens": 500,
"prompt_cache_hit_tokens": 9000,
"prompt_cache_miss_tokens": 1000,
},
}
with patch("routstr.proxy.get_model_instance", return_value=model_pricing):
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
# 1000 input @ 1 msat + 9000 cache reads @ 0.1 msat + 500 output @ 2 msat.
# input_msats folds the cache-read cost in (1000 + 900) so a dashboard
# rendering I/O/T sees input + output == total; the cache portion stays
# visible in cache_read_msats.
assert result.cache_read_msats == 900
assert result.output_msats == 1000
assert result.input_msats == 1900
assert result.input_msats + result.output_msats == result.total_msats
assert result.total_msats == 2900
@pytest.mark.asyncio
async def test_anthropic_cache_write_billed_at_write_rate(model_pricing: Mock) -> None:
"""Cache writes carry their premium rate (1.25x input here), instead of
being silently billed at the plain input rate."""
response = {
"model": "claude-sonnet-4-5",
"usage": {
"input_tokens": 300,
"output_tokens": 100,
"cache_read_input_tokens": 500,
"cache_creation_input_tokens": 2000,
},
}
with patch("routstr.proxy.get_model_instance", return_value=model_pricing):
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
# 300 @ 1 + 500 @ 0.1 + 2000 @ 1.25 + 100 @ 2
assert result.cache_read_msats == 50
assert result.cache_creation_msats == 2500
assert result.total_msats == 3050
@pytest.mark.asyncio
async def test_missing_cache_rate_falls_back_to_input_rate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Documented last resort: when no cache rate could be resolved anywhere
(OpenRouter and litellm both silent), cache reads bill at the input rate —
never cheaper, never free."""
monkeypatch.setattr(settings, "fixed_pricing", False)
model = Mock()
model.sats_pricing = Pricing(prompt=0.001, completion=0.002)
response = {
"model": "dumb-1",
"usage": {
"prompt_tokens": 10000,
"completion_tokens": 500,
"prompt_cache_hit_tokens": 9000,
"prompt_cache_miss_tokens": 1000,
},
}
with patch("routstr.proxy.get_model_instance", return_value=model):
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
# 1000 @ 1 + 9000 @ 1 (fallback) + 500 @ 2
assert result.total_msats == 11000

View File

@@ -1,10 +1,11 @@
"""Tests for cache token handling in cost calculation.
Covers OpenAI vs Anthropic caching formats, edge cases, and billing accuracy.
Covers OpenAI, Anthropic and DeepSeek caching formats, dialect precedence,
edge cases, and billing accuracy.
"""
import os
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import pytest
@@ -16,12 +17,6 @@ from routstr.core.settings import settings
from routstr.payment.cost_calculation import CostData, MaxCostData, calculate_cost
@pytest.fixture
def mock_session() -> AsyncMock:
"""Mock AsyncSession for cost calculation tests."""
return AsyncMock()
@pytest.fixture(autouse=True)
def mock_fixed_pricing(monkeypatch: pytest.MonkeyPatch) -> None:
"""Mock settings and price to use fixed pricing."""
@@ -41,7 +36,7 @@ def patch_sats_usd_price() -> None: # type: ignore[misc]
# Test 1: OpenAI Cache Format
# ============================================================================
@pytest.mark.asyncio
async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
async def test_openai_cache_subtraction() -> None:
"""OpenAI includes cached_tokens in prompt_tokens, subtract them."""
response = {
"model": "gpt-4",
@@ -53,7 +48,7 @@ async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
}
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 1000 # 2000 - 1000
@@ -65,7 +60,7 @@ async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
# Test 2: Anthropic Cache Format
# ============================================================================
@pytest.mark.asyncio
async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_anthropic_cache_additive(mock_fixed_pricing: None) -> None:
"""Anthropic cache tokens are separate (additive) from input_tokens."""
response = {
"model": "claude-3-5-sonnet",
@@ -76,7 +71,7 @@ async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pric
"cache_read_input_tokens": 0,
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 500
@@ -89,7 +84,7 @@ async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pric
# Test 3: Invalid Cache (Edge Case)
# ============================================================================
@pytest.mark.asyncio
async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_cache_read_exceeds_prompt_tokens(mock_fixed_pricing: None) -> None:
"""Handle buggy upstream reporting cached > prompt_tokens."""
response = {
"model": "gpt-4",
@@ -101,7 +96,7 @@ async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fi
}
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
# Should not go negative
assert isinstance(result, CostData)
@@ -114,7 +109,7 @@ async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fi
# Test 4: Malformed Token Values
# ============================================================================
@pytest.mark.asyncio
async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_malformed_cache_tokens_coerce_to_zero(mock_fixed_pricing: None) -> None:
"""Handle non-numeric cache token values."""
response = {
"model": "gpt-4",
@@ -127,7 +122,7 @@ async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mo
}
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
# Both should coerce to 0
assert isinstance(result, CostData)
@@ -139,7 +134,7 @@ async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mo
# Test 5: Anthropic Cache Not Subtracted
# ============================================================================
@pytest.mark.asyncio
async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_anthropic_cache_not_subtracted(mock_fixed_pricing: None) -> None:
"""Anthropic cache fields should NOT be subtracted from input_tokens."""
response = {
"model": "claude-3-5-sonnet",
@@ -149,7 +144,7 @@ async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixe
"cache_read_input_tokens": 200, # ← Additive, don't subtract
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
# Anthropic: input_tokens stays as-is
assert isinstance(result, CostData)
@@ -161,7 +156,7 @@ async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixe
# Test 6: Only Cache Read, No Regular Input
# ============================================================================
@pytest.mark.asyncio
async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_only_cache_read_tokens(mock_fixed_pricing: None) -> None:
"""Handle response with only cache read tokens."""
response = {
"model": "gpt-4",
@@ -173,7 +168,7 @@ async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricin
}
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 0 # max(0, 0 - 1000)
@@ -185,7 +180,7 @@ async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricin
# Test 7: Only Cache Creation
# ============================================================================
@pytest.mark.asyncio
async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_only_cache_creation_tokens(mock_fixed_pricing: None) -> None:
"""Handle response with only cache creation tokens (Anthropic)."""
response = {
"model": "claude-3-5-sonnet",
@@ -196,7 +191,7 @@ async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pr
"cache_read_input_tokens": 0,
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 500
@@ -209,7 +204,7 @@ async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pr
# Test 8: Both Cache Read and Creation
# ============================================================================
@pytest.mark.asyncio
async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_both_cache_read_and_creation(mock_fixed_pricing: None) -> None:
"""Handle response with both cache read and creation."""
response = {
"model": "claude-3-5-sonnet",
@@ -220,7 +215,7 @@ async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_
"cache_read_input_tokens": 500,
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 300
@@ -233,7 +228,7 @@ async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_
# Test 9: Token Field Fallback
# ============================================================================
@pytest.mark.asyncio
async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_token_field_fallback_order(mock_fixed_pricing: None) -> None:
"""Verify fallback order for token extraction."""
# When prompt_tokens is not present, fall back to input_tokens
response = {
@@ -243,7 +238,7 @@ async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pr
"completion_tokens": 50,
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 250
@@ -254,20 +249,21 @@ async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pr
# Test 10: Float Token Values
# ============================================================================
@pytest.mark.asyncio
async def test_float_token_values_coerced_to_int(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_float_token_values_coerced_to_int(mock_fixed_pricing: None) -> None:
"""Handle float token values by converting to int."""
response = {
"model": "gpt-4",
"usage": {
"prompt_tokens": 100.7, # Float
"completion_tokens": 50.3, # Float
"cache_read_input_tokens": 25.9, # Float
"prompt_tokens_details": {"cached_tokens": 25.9}, # Float
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 100 # Floored
# cached_tokens are part of prompt_tokens (OpenAI dialect) → subtracted: 100 - 25
assert result.input_tokens == 75 # Floored
assert result.output_tokens == 50 # Floored
assert result.cache_read_input_tokens == 25 # Floored
@@ -276,7 +272,7 @@ async def test_float_token_values_coerced_to_int(mock_session: AsyncMock, mock_f
# Test 11: Boolean Cache Tokens
# ============================================================================
@pytest.mark.asyncio
async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_boolean_cache_tokens_coerced_to_zero(mock_fixed_pricing: None) -> None:
"""Handle boolean cache token values by coercing to zero."""
response = {
"model": "gpt-4",
@@ -286,7 +282,7 @@ async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, moc
"cache_read_input_tokens": True, # Boolean
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.cache_read_input_tokens == 0 # Boolean coerced to 0
@@ -297,7 +293,7 @@ async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, moc
# Test 12: Zero Cache Tokens
# ============================================================================
@pytest.mark.asyncio
async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_zero_cache_tokens(mock_fixed_pricing: None) -> None:
"""Handle explicit zero cache tokens."""
response = {
"model": "gpt-4",
@@ -309,21 +305,113 @@ async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: No
}
}
}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.cache_read_input_tokens == 0
assert result.input_tokens == 100
# ============================================================================
# DeepSeek Cache Format
# DeepSeek emits neither OpenAI's prompt_tokens_details nor Anthropic's
# cache_read_input_tokens — only prompt_cache_hit_tokens and
# prompt_cache_miss_tokens, with the documented guarantee
# prompt_tokens = hit + miss. Hits are ~10x cheaper upstream, so billing
# them as regular input is a large overcharge.
# ============================================================================
@pytest.mark.asyncio
async def test_deepseek_cache_hit_tokens_extracted() -> None:
"""DeepSeek cache hits are extracted and removed from regular input.
Payload shape verbatim from the DeepSeek API reference (usage object).
"""
response = {
"model": "deepseek-chat",
"usage": {
"prompt_tokens": 10000, # = hit + miss
"completion_tokens": 500,
"total_tokens": 10500,
"prompt_cache_hit_tokens": 9000,
"prompt_cache_miss_tokens": 1000,
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 1000 # only the cache misses
assert result.cache_read_input_tokens == 9000
assert result.output_tokens == 500
@pytest.mark.asyncio
async def test_deepseek_all_tokens_cached() -> None:
"""A fully cached DeepSeek prompt bills zero regular input tokens."""
response = {
"model": "deepseek-chat",
"usage": {
"prompt_tokens": 5000,
"completion_tokens": 100,
"prompt_cache_hit_tokens": 5000,
"prompt_cache_miss_tokens": 0,
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 0
assert result.cache_read_input_tokens == 5000
@pytest.mark.asyncio
async def test_dialect_precedence_never_double_subtracts() -> None:
"""If a vendor emits both OpenAI-style and DeepSeek-style cache fields for
the same cached tokens, they are counted once, not subtracted twice."""
response = {
"model": "deepseek-chat",
"usage": {
"prompt_tokens": 10000,
"completion_tokens": 500,
"prompt_tokens_details": {"cached_tokens": 9000},
"prompt_cache_hit_tokens": 9000,
"prompt_cache_miss_tokens": 1000,
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 1000 # 10000 - 9000, applied exactly once
assert result.cache_read_input_tokens == 9000
@pytest.mark.asyncio
async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
"""Malformed DeepSeek cache fields degrade to billing all input at full
rate instead of crashing or going negative."""
response = {
"model": "deepseek-chat",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 50,
"prompt_cache_hit_tokens": "garbage",
"prompt_cache_miss_tokens": -5,
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 1000
assert result.cache_read_input_tokens == 0
# ============================================================================
# Test 13: Missing Usage Block
# ============================================================================
@pytest.mark.asyncio
async def test_missing_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_missing_usage_block(mock_fixed_pricing: None) -> None:
"""When usage is missing, return MaxCostData with zero tokens."""
response = {"model": "gpt-4", "choices": [{"message": {"content": "test"}}]}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, MaxCostData)
assert result.input_tokens == 0
@@ -335,10 +423,10 @@ async def test_missing_usage_block(mock_session: AsyncMock, mock_fixed_pricing:
# Test 14: Null Usage Block
# ============================================================================
@pytest.mark.asyncio
async def test_null_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
async def test_null_usage_block(mock_fixed_pricing: None) -> None:
"""When usage is null, return MaxCostData with zero tokens."""
response = {"model": "gpt-4", "usage": None}
result = await calculate_cost(response, max_cost=100000, session=mock_session)
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, MaxCostData)
assert result.input_tokens == 0

View File

@@ -0,0 +1,73 @@
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from routstr.wallet import fetch_all_balances
@asynccontextmanager
async def _fake_session(): # type: ignore[no-untyped-def]
yield MagicMock()
def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
proof = MagicMock(amount=proof_amount)
return [
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
MagicMock(return_value=[proof]),
),
patch(
"routstr.wallet.slow_filter_spend_proofs",
AsyncMock(side_effect=lambda proofs, wallet: proofs),
),
patch(
"routstr.wallet.db.balances_for_mint_and_unit",
AsyncMock(return_value=0),
),
patch("routstr.wallet.db.create_session", _fake_session),
]
@pytest.mark.asyncio
async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
"""With empty cashu_mints, balances are still fetched for primary_mint."""
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", []), patch.object(
settings, "primary_mint", "http://primary:3338"
):
for p in _patches(proof_amount=1000):
p.start()
try:
details, total_wallet, total_user, owner = await fetch_all_balances(
units=["sat"]
)
finally:
patch.stopall()
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
assert total_wallet == 1000
@pytest.mark.asyncio
async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
"""primary_mint already in cashu_mints is not inspected twice."""
from routstr.core.settings import settings
with patch.object(
settings, "cashu_mints", ["http://primary:3338"]
), patch.object(settings, "primary_mint", "http://primary:3338"):
for p in _patches(proof_amount=1000):
p.start()
try:
details, total_wallet, _total_user, _owner = await fetch_all_balances(
units=["sat"]
)
finally:
patch.stopall()
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
assert total_wallet == 1000

View File

@@ -500,7 +500,7 @@ async def test_streaming_emits_sse_and_reconciles_cost_at_end() -> None:
captured_cost_call: dict[str, Any] = {}
async def fake_adjust(
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int, usage: Any = None
) -> dict:
captured_cost_call["combined_data"] = combined_data
captured_cost_call["max_cost"] = max_cost
@@ -589,7 +589,7 @@ async def test_streaming_handles_iterator_yielding_raw_sse_bytes() -> None:
captured: dict[str, Any] = {}
async def fake_adjust(
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int, usage: Any = None
) -> dict:
captured["combined_data"] = combined_data
return fake_cost
@@ -693,7 +693,6 @@ async def test_x_cashu_non_streaming_dispatches_and_refunds_overpaid_amount() ->
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint.example",
payment_token_hash="hash123",
request_id="req-1",
)
@@ -972,7 +971,6 @@ async def test_forward_x_cashu_request_routes_messages_via_litellm() -> None:
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint",
payment_token_hash="h",
)
mock_helper.assert_awaited_once()
@@ -1041,7 +1039,6 @@ async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint",
payment_token_hash="h",
)
assert response.status_code == 200

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,95 @@
import json
import os
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
from routstr.upstream import GenericUpstreamProvider
from routstr.upstream.openrouter import OpenRouterUpstreamProvider
def _model(model_id: str = "openai/gpt-4o"): # type: ignore[no-untyped-def]
from routstr.payment.models import Architecture, Model, Pricing
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=128000,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="GPT",
instruct_type=None,
),
pricing=Pricing(prompt=0.0, completion=0.0),
)
def _tool_body() -> dict:
return {
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [
{
"type": "function",
"function": {"name": "get_weather", "parameters": {}},
}
],
}
def _prepare(provider, body: dict) -> dict: # type: ignore[no-untyped-def]
out = provider.prepare_request_body(json.dumps(body).encode(), _model())
assert out is not None
return json.loads(out)
def test_injects_require_parameters_for_tool_request() -> None:
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), _tool_body())
assert data["provider"]["require_parameters"] is True
def test_generic_provider_on_openrouter_url_is_left_alone() -> None:
# Only OpenRouterUpstreamProvider injects; a generic provider pointed at the
# same base URL doesn't.
provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1")
data = _prepare(provider, _tool_body())
assert "provider" not in data
def test_no_injection_without_tools() -> None:
body = {"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "hi"}]}
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert "provider" not in data
def test_empty_tools_list_does_not_inject() -> None:
body = _tool_body()
body["tools"] = []
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert "provider" not in data
def test_direct_provider_does_not_inject() -> None:
provider = GenericUpstreamProvider(base_url="https://api.openai.com/v1")
data = _prepare(provider, _tool_body())
assert "provider" not in data
def test_keeps_client_set_require_parameters() -> None:
body = _tool_body()
body["provider"] = {"require_parameters": False}
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert data["provider"]["require_parameters"] is False
def test_preserves_other_provider_fields() -> None:
body = _tool_body()
body["provider"] = {"order": ["openai", "azure"]}
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert data["provider"]["order"] == ["openai", "azure"]
assert data["provider"]["require_parameters"] is True

View File

@@ -0,0 +1,378 @@
"""Tests for stale reserved_balance handling (issue #551).
Covers:
- pay_for_request stamping reserved_at on billing and child keys
- release_stale_reservations sweeper semantics
- reset_all_reserved_balances clearing reserved_at
- refund endpoint self-healing stale/legacy reservations
- proxy reverting the reservation when the client disconnects (CancelledError)
"""
import asyncio
import time
from typing import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import (
ApiKey,
release_stale_reservations,
reset_all_reserved_balances,
)
def _make_engine() -> AsyncEngine:
return create_async_engine(
"sqlite+aiosqlite://",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
@pytest.fixture
async def session() -> "AsyncGenerator[AsyncSession, None]":
engine = _make_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
db_session = AsyncSession(engine, expire_on_commit=False)
try:
yield db_session
finally:
await db_session.close()
await engine.dispose()
# ---------------------------------------------------------------------------
# pay_for_request stamps reserved_at
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pay_for_request_sets_reserved_at(session: AsyncSession) -> None:
key = ApiKey(hashed_key="paykey", balance=10_000)
session.add(key)
await session.commit()
before = int(time.time())
await pay_for_request(key, 1_000, session)
await session.refresh(key)
assert key.reserved_balance == 1_000
assert key.reserved_at is not None
assert key.reserved_at >= before
@pytest.mark.asyncio
async def test_pay_for_request_sets_reserved_at_on_child_key(session: AsyncSession) -> None:
parent = ApiKey(hashed_key="parentkey", balance=10_000)
child = ApiKey(hashed_key="childkey", balance=0, parent_key_hash="parentkey")
session.add(parent)
session.add(child)
await session.commit()
await pay_for_request(child, 1_000, session)
await session.refresh(parent)
await session.refresh(child)
assert parent.reserved_balance == 1_000
assert parent.reserved_at is not None
assert child.reserved_balance == 1_000
assert child.reserved_at is not None
@pytest.mark.asyncio
async def test_revert_clears_reserved_at_when_fully_released(
session: AsyncSession,
) -> None:
from routstr.auth import revert_pay_for_request
key = ApiKey(hashed_key="revertkey", balance=10_000)
session.add(key)
await session.commit()
await pay_for_request(key, 1_000, session)
reverted = await revert_pay_for_request(key, session, 1_000)
assert reverted is True
await session.refresh(key)
assert key.reserved_balance == 0
assert key.reserved_at is None
@pytest.mark.asyncio
async def test_revert_keeps_reserved_at_while_other_reservations_remain(
session: AsyncSession,
) -> None:
from routstr.auth import revert_pay_for_request
key = ApiKey(hashed_key="partialrevert", balance=10_000)
session.add(key)
await session.commit()
await pay_for_request(key, 1_000, session)
await pay_for_request(key, 1_000, session)
reverted = await revert_pay_for_request(key, session, 1_000)
assert reverted is True
await session.refresh(key)
assert key.reserved_balance == 1_000
assert key.reserved_at is not None
# ---------------------------------------------------------------------------
# release_stale_reservations sweeper
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_release_stale_reservations_releases_old(session: AsyncSession) -> None:
key = ApiKey(
hashed_key="stalekey",
balance=5_000,
reserved_balance=1_000,
reserved_at=int(time.time()) - 1_000,
)
session.add(key)
await session.commit()
released = await release_stale_reservations(session, max_age_seconds=300)
assert released == 1
await session.refresh(key)
assert key.reserved_balance == 0
assert key.reserved_at is None
@pytest.mark.asyncio
async def test_release_stale_reservations_keeps_fresh(session: AsyncSession) -> None:
key = ApiKey(
hashed_key="freshkey",
balance=5_000,
reserved_balance=1_000,
reserved_at=int(time.time()),
)
session.add(key)
await session.commit()
released = await release_stale_reservations(session, max_age_seconds=300)
assert released == 0
await session.refresh(key)
assert key.reserved_balance == 1_000
assert key.reserved_at is not None
@pytest.mark.asyncio
async def test_release_stale_reservations_skips_null_reserved_at(session: AsyncSession) -> None:
# Reservations without a timestamp may belong to instances running older
# code (rolling deploy) — the background sweeper must not touch them.
key = ApiKey(
hashed_key="legacykey",
balance=5_000,
reserved_balance=1_000,
reserved_at=None,
)
session.add(key)
await session.commit()
released = await release_stale_reservations(session, max_age_seconds=300)
assert released == 0
await session.refresh(key)
assert key.reserved_balance == 1_000
@pytest.mark.asyncio
async def test_reset_all_reserved_balances_clears_reserved_at(session: AsyncSession) -> None:
key = ApiKey(
hashed_key="resetkey",
balance=5_000,
reserved_balance=1_000,
reserved_at=int(time.time()),
)
session.add(key)
await session.commit()
await reset_all_reserved_balances(session)
await session.refresh(key)
assert key.reserved_balance == 0
assert key.reserved_at is None
# ---------------------------------------------------------------------------
# Refund endpoint self-healing
# ---------------------------------------------------------------------------
def _refund_patches(refund_token: str = "cashuArefund"): # type: ignore[no-untyped-def]
return (
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
)
async def _add_key(session: AsyncSession, **kwargs) -> ApiKey: # type: ignore[no-untyped-def]
key = ApiKey(refund_currency="sat", **kwargs)
session.add(key)
await session.commit()
return key
@pytest.mark.asyncio
async def test_refund_self_heals_stale_reservation(session: AsyncSession) -> None:
key = await _add_key(
session,
hashed_key="stalerefund",
balance=5_000,
reserved_balance=2_000,
reserved_at=int(time.time()) - 10_000,
)
p1, p2, p3, p4 = _refund_patches()
with p1, p2, p3, p4:
result = await refund_wallet_endpoint(
authorization="Bearer sk-stalerefund",
x_cashu=None,
session=session,
)
assert isinstance(result, dict)
assert result["token"] == "cashuArefund"
# Full balance refunded (5000 msats -> 5 sats), reservation healed
assert result["sats"] == "5"
await session.refresh(key)
assert key.balance == 0
assert key.reserved_balance == 0
assert key.reserved_at is None
@pytest.mark.asyncio
async def test_refund_self_heals_legacy_null_reserved_at(session: AsyncSession) -> None:
# Keys stuck from before reserved_at existed must be refundable.
key = await _add_key(
session,
hashed_key="legacyrefund",
balance=5_000,
reserved_balance=2_000,
reserved_at=None,
)
p1, p2, p3, p4 = _refund_patches()
with p1, p2, p3, p4:
result = await refund_wallet_endpoint(
authorization="Bearer sk-legacyrefund",
x_cashu=None,
session=session,
)
assert isinstance(result, dict)
assert result["token"] == "cashuArefund"
await session.refresh(key)
assert key.balance == 0
assert key.reserved_balance == 0
@pytest.mark.asyncio
async def test_refund_rejects_recent_reservation(session: AsyncSession) -> None:
from fastapi import HTTPException
await _add_key(
session,
hashed_key="activerefund",
balance=5_000,
reserved_balance=2_000,
reserved_at=int(time.time()),
)
p1, p2, p3, p4 = _refund_patches()
with p1, p2, p3, p4:
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-activerefund",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 400
assert "ongoing requests" in exc_info.value.detail
@pytest.mark.asyncio
async def test_refund_without_reservation_still_works(session: AsyncSession) -> None:
key = await _add_key(
session,
hashed_key="plainrefund",
balance=5_000,
reserved_balance=0,
)
p1, p2, p3, p4 = _refund_patches()
with p1, p2, p3, p4:
result = await refund_wallet_endpoint(
authorization="Bearer sk-plainrefund",
x_cashu=None,
session=session,
)
assert isinstance(result, dict)
assert result["token"] == "cashuArefund"
await session.refresh(key)
assert key.balance == 0
# ---------------------------------------------------------------------------
# Proxy reverts reservation on client disconnect
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_proxy_reverts_reservation_on_client_disconnect() -> None:
from routstr import proxy as proxy_module
key = ApiKey(hashed_key="cancelkey", balance=10_000)
request = MagicMock()
request.method = "POST"
request.headers = {"authorization": "Bearer sk-cancelkey"}
request.body = AsyncMock(return_value=b'{"model": "test-model"}')
upstream = MagicMock()
upstream.provider_type = "test"
upstream.prepare_headers = MagicMock(side_effect=lambda h: h)
upstream.forward_request = AsyncMock(side_effect=asyncio.CancelledError())
session = MagicMock()
revert_mock = AsyncMock(return_value=True)
with (
patch.object(proxy_module, "get_model_instance", return_value=MagicMock()),
patch.object(proxy_module, "get_provider_for_model", return_value=[upstream]),
patch.object(
proxy_module, "get_max_cost_for_model", AsyncMock(return_value=1_000)
),
patch.object(
proxy_module,
"calculate_discounted_max_cost",
AsyncMock(return_value=1_000),
),
patch.object(proxy_module, "check_token_balance", MagicMock()),
patch.object(
proxy_module, "get_bearer_token_key", AsyncMock(return_value=key)
),
patch.object(proxy_module, "pay_for_request", AsyncMock(return_value=1_000)),
patch.object(proxy_module, "revert_pay_for_request", revert_mock),
):
with pytest.raises(asyncio.CancelledError):
await proxy_module.proxy(request, "v1/chat/completions", session=session)
revert_mock.assert_awaited_once_with(key, session, 1_000)

View File

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

View File

@@ -0,0 +1,140 @@
"""Tests for vendor-agnostic usage normalization.
Specifies the seam that keeps vendor usage dialects out of generic billing
code: a canonical ``NormalizedUsage`` shape produced by
``routstr.payment.usage.normalize_usage`` (union parser for the known,
non-colliding dialects). ``calculate_cost`` normalizes the response's usage
object with this parser and needs no vendor knowledge of its own.
"""
import os
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
import pytest
from routstr.payment.usage import NormalizedUsage, normalize_usage
# ============================================================================
# The union parser: one canonical shape for all known dialects
# ============================================================================
@pytest.mark.parametrize(
"usage,expected",
[
# OpenAI: cached_tokens included in prompt_tokens → subtracted
(
{
"prompt_tokens": 2000,
"completion_tokens": 100,
"prompt_tokens_details": {"cached_tokens": 800},
},
NormalizedUsage(
input_tokens=1200,
output_tokens=100,
cache_read_tokens=800,
cache_write_tokens=0,
),
),
# DeepSeek: hit/miss fields, prompt_tokens = hit + miss → hit subtracted
(
{
"prompt_tokens": 10000,
"completion_tokens": 500,
"prompt_cache_hit_tokens": 9000,
"prompt_cache_miss_tokens": 1000,
},
NormalizedUsage(
input_tokens=1000,
output_tokens=500,
cache_read_tokens=9000,
cache_write_tokens=0,
),
),
# Anthropic: cache fields additive, input_tokens NOT reduced
(
{
"input_tokens": 300,
"output_tokens": 100,
"cache_read_input_tokens": 500,
"cache_creation_input_tokens": 2000,
},
NormalizedUsage(
input_tokens=300,
output_tokens=100,
cache_read_tokens=500,
cache_write_tokens=2000,
),
),
# Plain OpenAI without caching
(
{"prompt_tokens": 100, "completion_tokens": 50},
NormalizedUsage(input_tokens=100, output_tokens=50),
),
# OpenRouter: cache writes nested as prompt_tokens_details.cache_write_tokens,
# both reads and writes included in prompt_tokens → both subtracted
(
{
"prompt_tokens": 10000,
"completion_tokens": 60,
"prompt_tokens_details": {
"cached_tokens": 5000,
"cache_write_tokens": 2000,
},
},
NormalizedUsage(
input_tokens=3000,
output_tokens=60,
cache_read_tokens=5000,
cache_write_tokens=2000,
),
),
# litellm-normalized Anthropic: prompt_tokens is the grand total and the
# write field is named cache_creation_tokens; top-level fields mirror it.
# prompt_tokens present → both subtracted (NOT additive like native).
(
{
"prompt_tokens": 10000,
"completion_tokens": 100,
"cache_read_input_tokens": 5000,
"cache_creation_input_tokens": 2000,
"prompt_tokens_details": {
"cached_tokens": 5000,
"cache_creation_tokens": 2000,
},
},
NormalizedUsage(
input_tokens=3000,
output_tokens=100,
cache_read_tokens=5000,
cache_write_tokens=2000,
),
),
],
)
def test_normalize_usage_dialects(usage: dict, expected: NormalizedUsage) -> None:
"""Each known vendor dialect maps onto the same canonical shape."""
assert normalize_usage(usage) == expected
def test_normalize_usage_absent_usage() -> None:
"""Missing/invalid usage yields None so callers can bill at max cost."""
assert normalize_usage(None) is None
assert normalize_usage("not a dict") is None # type: ignore[arg-type]
def test_normalize_usage_never_negative() -> None:
"""Buggy upstreams reporting more cached than prompt tokens clamp to 0."""
result = normalize_usage(
{
"prompt_tokens": 100,
"completion_tokens": 50,
"prompt_cache_hit_tokens": 150,
}
)
assert result is not None
assert result.input_tokens == 0
assert result.cache_read_tokens == 150

View File

@@ -39,6 +39,8 @@ async def test_recieve_token_valid() -> None:
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Fee-free trusted mint (e.g. Minibits): nothing deducted.
mock_wallet.get_fees_for_proofs = Mock(return_value=0)
from routstr.core.settings import settings
@@ -61,6 +63,69 @@ async def test_recieve_token_valid() -> None:
assert mint == "http://mint:3338"
@pytest.mark.asyncio
async def test_recieve_token_trusted_mint_deducts_input_fee() -> None:
"""A trusted mint that charges NUT-02 input fees.
The same-mint receive (`wallet.split(..., include_fees=True)`, a NUT-03 swap
at the same mint — not swap_to_primary_mint) pays the mint's per-proof fee,
so routstr only ends up with `face - input_fee` in fresh proofs. The credited
amount must reflect that, otherwise routstr over-credits the user and its own
wallet drifts toward insolvency.
"""
token_data = {
"token": [
{
"mint": "http://mint:3338",
"proofs": [
{"amount": 1000, "id": "test", "secret": "secret", "C": "curve"}
],
}
],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Mock a 3-sat input fee from the Cashu wallet API.
mock_wallet.get_fees_for_proofs = Mock(return_value=3)
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://mint:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.proofs = [{"amount": 1000}]
mock_deserialize.return_value = mock_token
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
# Patch get_wallet directly so the module-level `_wallets` cache
# (keyed by mint URL) can't hand back a wallet from another test.
with patch(
"routstr.wallet.get_wallet",
AsyncMock(return_value=mock_wallet),
):
amount, unit, mint = await recieve_token(token_str)
assert amount == 997 # 1000 face - 3 sat input fee paid on swap
assert unit == "sat"
assert mint == "http://mint:3338"
mock_wallet.get_fees_for_proofs.assert_called_once_with(
mock_token.proofs
)
# DLEQ is verified before re-minting the incoming proofs.
mock_wallet.verify_proofs_dleq.assert_called_once_with(
mock_token.proofs
)
@pytest.mark.asyncio
async def test_send_token() -> None:
mock_wallet = Mock()
@@ -108,6 +173,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 +221,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 +265,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()
@@ -219,19 +319,31 @@ async def test_recieve_token_untrusted_mint() -> None:
assert mint == "http://mint:3338"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_swap_to_primary_mint_already_on_primary() -> None:
"""Same-mint shortcut: the token is already on the primary mint.
No cross-mint swap (no melt/mint), but the same-mint split(include_fees=True)
still burns the mint's NUT-02 input fee, so the credited amount must be face
minus the input fee — not full face value (the over-credit bug). DLEQ is
verified too, matching the trusted same-mint receive path.
"""
from routstr.core.settings import settings
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = settings.primary_mint
mock_token.keysets = ["keyset1"]
mock_token.amount = 1000
mock_token.unit = "sat"
mock_token.proofs = []
mock_token.proofs = [{"amount": 1000}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.verify_proofs_dleq = Mock()
# Mock a 3-sat input fee from the Cashu wallet API.
mock_token_wallet.get_fees_for_proofs = Mock(return_value=3)
mock_token_wallet.split = AsyncMock(return_value=None)
mock_token_wallet.request_mint = AsyncMock()
mock_token_wallet.melt_quote = AsyncMock()
@@ -239,9 +351,11 @@ async def test_swap_to_primary_mint_already_on_primary() -> None:
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
assert amount == 1000
assert amount == 997 # 1000 face - 3 sat input fee
assert unit == "sat"
assert mint == settings.primary_mint
mock_token_wallet.verify_proofs_dleq.assert_called_once_with(mock_token.proofs)
mock_token_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs)
mock_token_wallet.split.assert_called_once()
mock_token_wallet.request_mint.assert_not_called()
mock_token_wallet.melt_quote.assert_not_called()
@@ -261,6 +375,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

@@ -297,16 +297,13 @@ export default function ProvidersPage() {
};
const toggleProviderExpansion = (providerId: number) => {
const newExpanded = new Set(expandedProviders);
if (newExpanded.has(providerId)) {
newExpanded.delete(providerId);
} else {
newExpanded.add(providerId);
}
setExpandedProviders(newExpanded);
if (!newExpanded.has(providerId)) {
if (expandedProviders.has(providerId)) {
setExpandedProviders(new Set());
setViewingModels(null);
} else {
// Accordion: only one provider open at a time so switching to another
// provider's models auto-collapses the previously expanded one.
setExpandedProviders(new Set([providerId]));
setViewingModels(providerId);
}
};

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

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"