mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
111 Commits
fix/cache-
...
fix/stream
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4defe4f227 | ||
|
|
f8125a8a2d | ||
|
|
999a5634fa | ||
|
|
2c218cce49 | ||
|
|
fa0b366f9a | ||
|
|
be5e323c68 | ||
|
|
f6d1a41728 | ||
|
|
b3bf1f0e90 | ||
|
|
3035292d2a | ||
|
|
ac436d0862 | ||
|
|
19082231f9 | ||
|
|
281607108c | ||
|
|
8314f3c1b0 | ||
|
|
1a9041766b | ||
|
|
01c01fe8ad | ||
|
|
892aed61cc | ||
|
|
c6733dbb62 | ||
|
|
57fef44ce7 | ||
|
|
c671d277d3 | ||
|
|
9460e24f21 | ||
|
|
f31929b538 | ||
|
|
129ea7bb76 | ||
|
|
b88c6d84fa | ||
|
|
238beb3e52 | ||
|
|
dc833d9f5f | ||
|
|
31e7ff8fbd | ||
|
|
a1850dfa55 | ||
|
|
9eacabde0e | ||
|
|
73f52ef1fd | ||
|
|
110ec5da5d | ||
|
|
a5cee796c9 | ||
|
|
aabf08a930 | ||
|
|
07c15de106 | ||
|
|
aea24d23da | ||
|
|
e100c77288 | ||
|
|
057a752b1e | ||
|
|
5daa2602f5 | ||
|
|
d80912b10e | ||
|
|
8f087bf07a | ||
|
|
744321153f | ||
|
|
b81c5add6a | ||
|
|
02109616b9 | ||
|
|
b30346bcb6 | ||
|
|
956a1ac3e1 | ||
|
|
4287f038cf | ||
|
|
82fd2c08a7 | ||
|
|
a1118a510d | ||
|
|
27dedfdf77 | ||
|
|
ebb2267844 | ||
|
|
7c2e2ce512 | ||
|
|
89b93fde98 | ||
|
|
c48693973b | ||
|
|
407f4745a0 | ||
|
|
806771df13 | ||
|
|
0f7d3d2f86 | ||
|
|
3c1b6d5f17 | ||
|
|
a9d5a52b32 | ||
|
|
af2abc3a1c | ||
|
|
b36bb56a30 | ||
|
|
9b4b4d734f | ||
|
|
97172641f8 | ||
|
|
46bbba7027 | ||
|
|
edfb0f127c | ||
|
|
65ccf83dbd | ||
|
|
2baea4149e | ||
|
|
64d9460711 | ||
|
|
bc6d0af6d6 | ||
|
|
f55c3e9c8c | ||
|
|
e3ac06342f | ||
|
|
6097193442 | ||
|
|
e2aa02307b | ||
|
|
66bc1260a4 | ||
|
|
9dc4c979b8 | ||
|
|
ec0fd1143b | ||
|
|
ae9748db02 | ||
|
|
ccee76b31e | ||
|
|
9f04525c82 | ||
|
|
c81de0de0a | ||
|
|
81658d0ba6 | ||
|
|
ee5ced10c7 | ||
|
|
2a27fb239e | ||
|
|
ffde661d93 | ||
|
|
65ac1b0dcd | ||
|
|
ab5596ed70 | ||
|
|
55622cb956 | ||
|
|
7f49ba1771 | ||
|
|
949dc433f1 | ||
|
|
7328a5ac45 | ||
|
|
07d39c2a7b | ||
|
|
bd4f4e8207 | ||
|
|
46b1f8f72d | ||
|
|
1c0570cc0e | ||
|
|
b509581950 | ||
|
|
a8f60643e0 | ||
|
|
e4753b5864 | ||
|
|
66d7bd87b5 | ||
|
|
4c48df8aa8 | ||
|
|
abc1ea5b35 | ||
|
|
52dd011cd8 | ||
|
|
94a3215894 | ||
|
|
5dd3cbbc22 | ||
|
|
55fc25b4de | ||
|
|
36ed3ec9f0 | ||
|
|
2e350e082b | ||
|
|
24b90af6e6 | ||
|
|
e972b62758 | ||
|
|
b9bf0ea23b | ||
|
|
11fc826bfb | ||
|
|
757d4400af | ||
|
|
766d59d666 | ||
|
|
98ddd37853 |
@@ -2,6 +2,9 @@
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-upstream-api-key
|
||||
|
||||
# Tinfoil (confidential inference enclaves, EHBP)
|
||||
# TINFOIL_API_KEY=your-tinfoil-api-key
|
||||
|
||||
# ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Database
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,9 @@ dist/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.*wallet.sqlite3
|
||||
.wallet/
|
||||
AGENTS.md
|
||||
TEST_SUITE_OVERVIEW.md
|
||||
*models.json
|
||||
.cashu
|
||||
.relay
|
||||
@@ -38,3 +41,4 @@ proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
.worktrees
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /app/ui
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
# Copy UI source
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ./
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ui/pnpm-workspace.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ui/ ./
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
- ./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"]
|
||||
restart: unless-stopped
|
||||
|
||||
routstr:
|
||||
build: .
|
||||
@@ -31,6 +32,7 @@ services:
|
||||
- 8000:8000
|
||||
extra_hosts: # Needed to access locally running models
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
@@ -41,6 +43,7 @@ services:
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- routstr
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
|
||||
@@ -113,42 +113,109 @@ All errors follow a consistent JSON structure:
|
||||
**Status:** 402
|
||||
**Resolution:** Top up API key balance
|
||||
|
||||
#### Invalid Token
|
||||
### Cashu Token Redemption Errors
|
||||
|
||||
These errors are returned when a Cashu token you pay with cannot be redeemed.
|
||||
They apply to every endpoint that accepts a token:
|
||||
|
||||
- **Per-request payment** via the `X-Cashu` header (chat completions + Responses API).
|
||||
- **API key top-up** via `POST /v1/wallet/topup`.
|
||||
- **Minting an API key** from a token sent in `Authorization: Bearer <cashu-token>`.
|
||||
|
||||
All three share one classifier, so the same failure yields the same HTTP status
|
||||
and sanitized message everywhere. Structured error envelopes (`X-Cashu` and
|
||||
`Authorization: Bearer <cashu-token>`) also expose the same `type` and `code` —
|
||||
branch on `type` (or `code` for finer granularity). `POST /v1/wallet/topup`
|
||||
keeps its existing plain-string `detail` envelope, so branch on status there.
|
||||
|
||||
| `type` | Status | `code` | Retryable | Meaning |
|
||||
|--------|--------|--------|-----------|---------|
|
||||
| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
|
||||
| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. |
|
||||
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. |
|
||||
| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. |
|
||||
| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. |
|
||||
| `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. |
|
||||
| `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). |
|
||||
| `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. |
|
||||
| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. |
|
||||
|
||||
!!! important "Retry only `mint_unreachable`"
|
||||
Only `mint_unreachable` (503) means the same token will work again later —
|
||||
everything else is a permanent property of the token and must not be
|
||||
blindly retried. Use exponential backoff for the 503. In particular, a
|
||||
`token_consumed` 500 means the mint already spent the token, so a retry
|
||||
would fail as `token_already_spent`.
|
||||
|
||||
#### Mint Unreachable (retryable)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Invalid Cashu token",
|
||||
"code": "invalid_token",
|
||||
"details": {
|
||||
"reason": "Token already spent"
|
||||
}
|
||||
"type": "mint_unreachable",
|
||||
"message": "Cashu mint is unreachable",
|
||||
"code": "cashu_mint_unreachable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Use a valid, unspent token
|
||||
**Status:** 503
|
||||
|
||||
#### Mint Unavailable
|
||||
**Resolution:** The token is valid — the mint is temporarily down. Retry with
|
||||
backoff, or pay with a token from a different mint.
|
||||
|
||||
#### Token Already Spent
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Cannot connect to Cashu mint",
|
||||
"code": "mint_unavailable",
|
||||
"details": {
|
||||
"mint_url": "https://mint.example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
"type": "token_already_spent",
|
||||
"message": "Cashu token already spent",
|
||||
"code": "cashu_token_already_spent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Try again later or use different mint
|
||||
**Status:** 400
|
||||
|
||||
**Resolution:** Use a fresh, unspent token. Do not retry with the same token.
|
||||
|
||||
#### Response envelope differs by endpoint
|
||||
|
||||
The `error` object above is identical everywhere, but the surrounding envelope
|
||||
depends on how you paid:
|
||||
|
||||
- **`X-Cashu` header payments** (chat + Responses API) return the object at the
|
||||
top level, alongside a `request_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" },
|
||||
"request_id": "req-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
The original token is echoed back in the `X-Cashu` **response header only when
|
||||
it is still spendable** (e.g. `mint_unreachable`, `invalid_cashu_token`, fee
|
||||
errors) so you can recover/retry it. It is **not** echoed for spent/consumed
|
||||
tokens (`cashu_token_already_spent`, `cashu_token_consumed`,
|
||||
`cashu_token_zero_value`, `internal_error`) — retrying those can never succeed.
|
||||
|
||||
- **`Authorization: Bearer <cashu-token>`** (API key minting) wraps it in
|
||||
FastAPI's `detail` field:
|
||||
|
||||
```json
|
||||
{ "detail": { "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" } } }
|
||||
```
|
||||
|
||||
- **`POST /v1/wallet/topup`** returns a plain string message under `detail` —
|
||||
it carries the shared HTTP **status** and **message** (e.g. `503` for an
|
||||
unreachable mint) but not the structured `type`/`code`, so branch on the
|
||||
status code here:
|
||||
|
||||
```json
|
||||
{ "detail": "Cashu mint is unreachable" }
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
@@ -347,7 +414,7 @@ class ErrorHandler:
|
||||
'rate_limit',
|
||||
'upstream_timeout',
|
||||
'model_overloaded',
|
||||
'mint_unavailable'
|
||||
'cashu_mint_unreachable'
|
||||
}
|
||||
|
||||
# Errors requiring user action
|
||||
|
||||
150
docs/ehbp-proxy-support.md
Normal file
150
docs/ehbp-proxy-support.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# EHBP Proxy Support for Tinfoil Models
|
||||
|
||||
## Problem
|
||||
|
||||
The SDK's `SecureClient.fetch` encrypts request bodies with HPKE (EHBP protocol)
|
||||
and sends them to the Routstr provider. The Routstr proxy had no EHBP handling:
|
||||
|
||||
1. It tried to `json.loads()` the binary HPKE-sealed body → failed with a 400
|
||||
2. The upstream PPQ.AI public endpoint (`/v1/chat/completions`) doesn't speak
|
||||
EHBP, so the response had no `Ehbp-Response-Nonce` header
|
||||
3. `SecureClient` threw `Missing Ehbp-Response-Nonce header` because it expects
|
||||
every response from an EHBP-configured `baseURL` to carry that header
|
||||
|
||||
## Root cause
|
||||
|
||||
PPQ.AI exposes EHBP-aware inference at `/private/`, separate from the public
|
||||
`/v1/` endpoint. The Routstr proxy was forwarding to `/v1/` (the public
|
||||
endpoint) instead of `/private/` (the enclave endpoint). The public endpoint
|
||||
can't decrypt the body, returns a normal HTTP response, and the SDK can't
|
||||
decrypt it because there's no nonce header.
|
||||
|
||||
The PPQ private-mode proxy (`ppq-private-mode-proxy/lib/proxy.ts`) shows the
|
||||
correct pattern: `SecureClient` talks to `api.ppq.ai/private/v1/chat/completions`,
|
||||
which decrypts inside the attested enclave and returns an EHBP-encrypted
|
||||
response with the `Ehbp-Response-Nonce` header.
|
||||
|
||||
## What was changed
|
||||
|
||||
### `routstr/proxy.py`
|
||||
|
||||
Detects EHBP requests by checking for the `Ehbp-Encapsulated-Key` header (set
|
||||
by the EHBP transport on every encrypted request). For EHBP requests:
|
||||
|
||||
- Skips JSON body parsing (the body is binary ciphertext, not JSON)
|
||||
- Reads the model ID from the `X-Routstr-Model` header (set by the SDK) instead
|
||||
of from `body.model`
|
||||
- Routes through new `forward_ehbp_request` (bearer auth) and
|
||||
`forward_ehbp_x_cashu_request` (x-cashu auth) methods
|
||||
- Skips reactive 400 param correction (can't parse encrypted response body)
|
||||
- Still charges the user via `pay_for_request` (uses `max_cost_for_model` from
|
||||
the model registry, not the body)
|
||||
|
||||
### `routstr/upstream/base.py`
|
||||
|
||||
Keeps EHBP as an explicit opt-in provider capability instead of making every
|
||||
upstream provider appear EHBP-capable:
|
||||
|
||||
- `supports_ehbp = False` by default
|
||||
- `get_ehbp_forwarding_target(path, model_obj)` raises `NotImplementedError`
|
||||
unless a provider opts in and returns a provider-specific EHBP target
|
||||
|
||||
The actual EHBP forwarding logic does **not** live in `base.py`.
|
||||
|
||||
### `routstr/upstream/ehbp.py`
|
||||
|
||||
Contains the shared opaque EHBP transport and billing helpers:
|
||||
|
||||
- `EHBPForwardingTarget` — provider-specific target URL plus extra headers
|
||||
- `forward_ehbp_request()` — forwards the encrypted body, captures Tinfoil
|
||||
usage from a response header or streaming HTTP trailer, and finalizes bearer
|
||||
billing at actual cost (falling back to max cost when usage is unavailable)
|
||||
- `forward_ehbp_x_cashu_request()` — redeems the Cashu token, refunds the full
|
||||
token on upstream failure, and refunds the difference between the redeemed
|
||||
amount and actual cost (or max cost when usage is unavailable)
|
||||
|
||||
### Provider support
|
||||
|
||||
EHBP is currently enabled only for `TinfoilUpstreamProvider`. It forwards to
|
||||
Tinfoil's attested enclave and requests `X-Tinfoil-Usage-Metrics` for billing.
|
||||
PPQ.AI retains its private-target implementation, but `supports_ehbp = False`
|
||||
until it has a provider-specific trusted usage/model-binding strategy.
|
||||
|
||||
## Why it's done this way
|
||||
|
||||
The proxy is a **blind relay** for EHBP requests. It cannot decrypt the body
|
||||
(only the attested enclave can), so it must:
|
||||
|
||||
1. Get the model ID from a header, not the body
|
||||
2. Forward the raw bytes without parsing or transformation
|
||||
3. Stream the response back without SSE/cost parsing
|
||||
4. Pass through EHBP protocol headers (`Ehbp-Encapsulated-Key` on request,
|
||||
`Ehbp-Response-Nonce` on response)
|
||||
|
||||
Cost tracking happens at the proxy level. Routstr reserves or redeems up to
|
||||
`max_cost_for_model`, then Tinfoil's out-of-band usage header/trailer allows it
|
||||
to finalize at actual token cost. If trusted usage is missing or invalid, the
|
||||
proxy safely falls back to max-cost billing.
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
```
|
||||
SDK Routstr Proxy PPQ.AI /private/
|
||||
│ │ │
|
||||
│── X-Routstr-Model: tinfoil-kimi-k2-6 ─│ │
|
||||
│── Ehbp-Encapsulated-Key: <hex> ───────│ │
|
||||
│── Authorization: Bearer <cashu> ──────│ │
|
||||
│── body = HPKE-encrypted(kimi-k2-6) ───│ │
|
||||
│ │ │
|
||||
│ detects Ehbp-Encapsulated-Key │
|
||||
│ reads model from X-Routstr-Model │
|
||||
│ does billing/routing │
|
||||
│ │ │
|
||||
│ adds X-Private-Model: private/kimi-k2-6
|
||||
│ forwards raw body to /private/v1/... │
|
||||
│ │──────────────────────────────▶│
|
||||
│ │ enclave decrypts
|
||||
│ │ runs inference
|
||||
│ │◀── Ehbp-Response-Nonce ──────│
|
||||
│ │◀── encrypted response ────────│
|
||||
│ │ │
|
||||
│ streams response back untouched │
|
||||
│◀── encrypted response ────────────────│ │
|
||||
│ │ │
|
||||
SecureClient reads nonce, decrypts │ │
|
||||
SDK SSE processing sees plaintext │ │
|
||||
```
|
||||
|
||||
## Model ID mapping
|
||||
|
||||
Three parties see three different model IDs:
|
||||
|
||||
| Party | Header/Body | Value | Source |
|
||||
|---|---|---|---|
|
||||
| Routstr proxy | `X-Routstr-Model` header | `tinfoil-kimi-k2-6` | SDK sends full caller-facing id |
|
||||
| Tinfoil usage metrics | `model` field | `kimi-k2-6` | Enclave reports the model actually served |
|
||||
| Tinfoil enclave | `body.model` (encrypted) | `kimi-k2-6` | SDK strips `tinfoil-` prefix before encryption |
|
||||
|
||||
## Implementation status
|
||||
|
||||
A dedicated `TinfoilUpstreamProvider` (`routstr/upstream/tinfoil.py`) now
|
||||
implements the direct blind-upstream pattern described above. The shared EHBP
|
||||
helpers in `routstr/upstream/ehbp.py` were extended to:
|
||||
|
||||
- Request usage metrics via `X-Tinfoil-Request-Usage-Metrics: true`.
|
||||
- Parse `X-Tinfoil-Usage-Metrics` from the response header (non-streaming) or
|
||||
HTTP trailer (streaming).
|
||||
- Override the forwarding URL with a validated `X-Tinfoil-Enclave-Url` when the
|
||||
SDK sends it.
|
||||
- Finalize bearer billing with the dedicated EHBP actual-cost finalizer.
|
||||
- Compute X-Cashu refunds from actual cost instead of max cost.
|
||||
|
||||
See `docs/tinfoil-direct-integration.md` for the full implementation notes.
|
||||
|
||||
## Verification status
|
||||
|
||||
Unit coverage includes usage parsing, target validation, HTTP trailer capture,
|
||||
response-size limits, and bearer payment finalization. End-to-end requests have
|
||||
verified both non-streaming usage headers and streaming usage trailers against
|
||||
Tinfoil. SDK behavior on proxy-generated non-2xx responses without an
|
||||
`Ehbp-Response-Nonce` still merits explicit end-to-end coverage.
|
||||
493
docs/tinfoil-direct-integration.md
Normal file
493
docs/tinfoil-direct-integration.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Tinfoil / PPQ Private-Mode Integration Notes
|
||||
|
||||
This document summarizes the current options for integrating Tinfoil/PPQ private models with Routstr, based on the EHBP work in this branch and local testing against `ppq-private-mode-proxy`.
|
||||
|
||||
## Background
|
||||
|
||||
PPQ private models run behind a Tinfoil/EHBP flow:
|
||||
|
||||
- Request bodies are HPKE-encrypted by a Tinfoil client.
|
||||
- The PPQ `/private/` endpoint routes ciphertext to the attested enclave.
|
||||
- The enclave decrypts, runs inference, and returns an encrypted response.
|
||||
- The caller's Tinfoil client decrypts the response locally.
|
||||
|
||||
The PPQ private-mode proxy (`~/projects/ppq-private-mode-proxy`) uses this pattern with the JavaScript `tinfoil` SDK:
|
||||
|
||||
```ts
|
||||
import { SecureClient } from "tinfoil";
|
||||
|
||||
const apiBase = "https://api.ppq.ai";
|
||||
|
||||
const client = new SecureClient({
|
||||
baseURL: `${apiBase}/private/`,
|
||||
attestationBundleURL: `${apiBase}/private`,
|
||||
transport: "ehbp",
|
||||
});
|
||||
|
||||
await client.ready();
|
||||
|
||||
const response = await client.fetch(`${apiBase}/private/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.PPQ_API_KEY}`,
|
||||
"X-Private-Model": "private/gpt-oss-120b",
|
||||
"x-query-source": "api",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-oss-120b", // enclave-internal model id
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
console.log(json.usage);
|
||||
```
|
||||
|
||||
`X-Private-Model` carries the PPQ-facing private model id, while the encrypted JSON body uses the enclave-internal model id without the `private/` prefix.
|
||||
|
||||
## PPQ private model pricing
|
||||
|
||||
PPQ exposes private model pricing through:
|
||||
|
||||
```text
|
||||
GET https://api.ppq.ai/v1/models?type=all
|
||||
```
|
||||
|
||||
Filter models whose IDs start with `private/`.
|
||||
|
||||
Example private pricing observed:
|
||||
|
||||
| Model | Input USD / 1M tokens | Output USD / 1M tokens |
|
||||
|---|---:|---:|
|
||||
| `private/gpt-oss-120b` | `0.79125` | `1.31875` |
|
||||
| `private/llama3-3-70b` | `1.84625` | `2.90125` |
|
||||
| `private/qwen3-vl-30b` | `1.31875` | `4.22` |
|
||||
| `private/glm-5-2` | `1.5825` | `5.53875` |
|
||||
| `private/gemma4-31b` | `0.47475` | `1.055` |
|
||||
| `private/kimi-k2-6` | `1.5825` | `5.53875` |
|
||||
|
||||
The `ppq-private-mode-proxy` commit `ba984214793d3bca0f7d046b6955d42abc1c6843` changed OpenClaw display metadata to align with a 5% API margin. The live PPQ model endpoint returns the more precise rates above, which already include that margin.
|
||||
|
||||
Actual PPQ private billing is:
|
||||
|
||||
```text
|
||||
price_usd =
|
||||
input_tokens * input_per_1M_tokens / 1_000_000
|
||||
+ output_tokens * output_per_1M_tokens / 1_000_000
|
||||
```
|
||||
|
||||
A test request to `private/gpt-oss-120b` produced:
|
||||
|
||||
```json
|
||||
{
|
||||
"input_count": 74,
|
||||
"output_count": 3,
|
||||
"price_in_usd": 0.00006250875
|
||||
}
|
||||
```
|
||||
|
||||
which exactly matches:
|
||||
|
||||
```text
|
||||
74 * 0.79125 / 1_000_000 + 3 * 1.31875 / 1_000_000
|
||||
= 0.00006250875 USD
|
||||
```
|
||||
|
||||
## Integration architectures
|
||||
|
||||
There are three materially different ways Routstr could integrate Tinfoil/PPQ private inference.
|
||||
|
||||
## Option A: User integrates Tinfoil directly
|
||||
|
||||
```text
|
||||
User app / SDK
|
||||
-> Tinfoil SecureClient / TinfoilAI
|
||||
-> EHBP-encrypted request
|
||||
-> Tinfoil/PPQ private enclave
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- Best privacy for the user.
|
||||
- Routstr is not in the request path.
|
||||
- User's client encrypts requests and decrypts responses.
|
||||
- Usage is visible to the user's app after decryption.
|
||||
- Billing is handled directly by Tinfoil/PPQ.
|
||||
|
||||
Example with Tinfoil's OpenAI-compatible client:
|
||||
|
||||
```ts
|
||||
import { TinfoilAI } from "tinfoil";
|
||||
|
||||
const client = new TinfoilAI({
|
||||
apiKey: process.env.TINFOIL_API_KEY,
|
||||
transport: "ehbp",
|
||||
});
|
||||
|
||||
const res = await client.chat.completions.create({
|
||||
model: "llama3-3-70b",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
});
|
||||
|
||||
console.log(res.choices[0].message.content);
|
||||
console.log(res.usage);
|
||||
```
|
||||
|
||||
This is not a Routstr marketplace flow unless Routstr only acts as discovery/UI around direct Tinfoil/PPQ usage.
|
||||
|
||||
## Option B: Routstr integrates Tinfoil as an upstream client
|
||||
|
||||
```text
|
||||
User -> Routstr plaintext request
|
||||
-> Routstr Tinfoil SecureClient encrypts to PPQ/Tinfoil
|
||||
-> PPQ private enclave
|
||||
-> Routstr receives decrypted response
|
||||
-> Routstr bills from decrypted usage
|
||||
-> Routstr returns plaintext response to user
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- Easier exact billing.
|
||||
- Routstr can read the decrypted OpenAI response and `usage` object.
|
||||
- Routstr can charge exact PPQ token pricing.
|
||||
- Privacy is different: the user sends plaintext to Routstr, and Routstr sees prompts/responses.
|
||||
- End-to-end encryption is only Routstr-to-enclave, not user-to-enclave.
|
||||
|
||||
This should be considered a separate product/provider mode, not the same as an end-to-end private relay.
|
||||
|
||||
Practical implementation options:
|
||||
|
||||
1. Run a Node sidecar that uses the `tinfoil` npm package and expose it as a local HTTP upstream to Routstr.
|
||||
2. Port EHBP client behavior to Python.
|
||||
3. Reuse or adapt `ppq-private-mode-proxy` as a local upstream.
|
||||
|
||||
A Node sidecar is probably the quickest implementation path because `ppq-private-mode-proxy` already demonstrates the full flow.
|
||||
|
||||
## Option C: Routstr uses Tinfoil as a direct blind upstream
|
||||
|
||||
This is the current branch's design intent and is likely the best fit if Tinfoil/PPQ exposes usage metadata headers:
|
||||
|
||||
```text
|
||||
User Tinfoil SecureClient
|
||||
-> encrypted request body
|
||||
-> Routstr proxy
|
||||
-> Tinfoil/PPQ private API
|
||||
with X-Tinfoil-Request-Usage-Metrics: true
|
||||
-> PPQ private enclave
|
||||
<- encrypted response
|
||||
plus X-Tinfoil-Usage-Metrics / cost headers
|
||||
<- Routstr proxy
|
||||
-> user decrypts response
|
||||
```
|
||||
|
||||
In this mode Routstr is still a normal upstream proxy from the user's point of view, but the upstream is Tinfoil/PPQ private inference and the body remains opaque to Routstr.
|
||||
|
||||
Properties:
|
||||
|
||||
- Strongest privacy with Routstr in the path.
|
||||
- Routstr never sees plaintext prompt or plaintext response.
|
||||
- Routstr can authenticate and route based on plaintext headers.
|
||||
- Routstr should request Tinfoil usage metadata by adding `X-Tinfoil-Request-Usage-Metrics: true` to the upstream request.
|
||||
- Tinfoil documents `X-Tinfoil-Usage-Metrics` as an upstream response header for non-streaming requests.
|
||||
- For streaming requests, Tinfoil documents `X-Tinfoil-Usage-Metrics` as an HTTP trailer available only after the response body completes.
|
||||
- If Tinfoil/PPQ returns `X-Tinfoil-Usage-Metrics` or a cost header on the encrypted response, Routstr can bill exactly without decrypting the body.
|
||||
- If usage is only present inside the encrypted response body, Routstr still cannot read it and exact billing is not possible without a separate metadata path.
|
||||
|
||||
This is the only architecture that preserves end-to-end encryption from the user to the PPQ/Tinfoil enclave while still letting Routstr mediate payment. The key requirement is that usage/cost metadata must be returned outside the encrypted body, ideally as a response header available before body streaming begins.
|
||||
|
||||
## Current Routstr problem
|
||||
|
||||
The current EHBP implementation charges successful EHBP requests at `max_cost_for_model` because Routstr cannot decrypt the response body:
|
||||
|
||||
```text
|
||||
successful EHBP request -> charge full reserved max cost
|
||||
```
|
||||
|
||||
That is incorrect for PPQ private models. Max cost should be only a reservation/solvency ceiling. Final charge should use actual PPQ private token pricing.
|
||||
|
||||
Desired behavior:
|
||||
|
||||
```text
|
||||
reserve max cost
|
||||
forward encrypted request
|
||||
obtain actual usage/cost metadata
|
||||
finalize actual cost
|
||||
refund/release the difference
|
||||
```
|
||||
|
||||
## Usage/cost metadata requirement
|
||||
|
||||
For blind-relay exact billing, PPQ should return one of the following outside the encrypted response body:
|
||||
|
||||
```http
|
||||
X-PPQ-Cost-USD: 0.00006250875
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```http
|
||||
X-Private-Usage-Metrics: input=74,output=3
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```http
|
||||
X-Tinfoil-Usage-Metrics: prompt=74,completion=3,total=77
|
||||
```
|
||||
|
||||
Tinfoil proxy documentation references a usage-metrics flow where a proxy can request usage via:
|
||||
|
||||
```http
|
||||
X-Tinfoil-Request-Usage-Metrics: true
|
||||
```
|
||||
|
||||
and read usage from:
|
||||
|
||||
```http
|
||||
X-Tinfoil-Usage-Metrics
|
||||
```
|
||||
|
||||
Docs/example references:
|
||||
|
||||
- https://docs.tinfoil.sh/guides/proxy-server
|
||||
- https://github.com/tinfoilsh/encrypted-request-proxy-example
|
||||
|
||||
During local PPQ testing, PPQ responses included this CORS exposure header:
|
||||
|
||||
```http
|
||||
Access-Control-Expose-Headers: Ehbp-Response-Nonce, X-Private-Usage-Metrics, X-Encrypted-Usage-Metrics, X-Tinfoil-Usage-Metrics
|
||||
```
|
||||
|
||||
However, the actual tested non-streaming response did not include any of these usage headers, even when `X-Tinfoil-Request-Usage-Metrics: true` was sent.
|
||||
|
||||
The decrypted body did include normal OpenAI usage, but only the decrypting Tinfoil client can see that body.
|
||||
|
||||
## Query-history fallback
|
||||
|
||||
PPQ's query history endpoint exposes actual usage and cost:
|
||||
|
||||
```text
|
||||
GET https://api.ppq.ai/queries/history?page=1&page_count=...
|
||||
```
|
||||
|
||||
A record includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "...",
|
||||
"model": "private/gpt-oss-120b",
|
||||
"input_count": 74,
|
||||
"output_count": 3,
|
||||
"price_in_usd": 0.00006250875,
|
||||
"query_type": "chat_completion",
|
||||
"query_source": "api"
|
||||
}
|
||||
```
|
||||
|
||||
This could be used as a fallback, but it is less robust than response headers/trailers because matching a request to a history row can be race-prone under concurrency. It would need a reliable request identifier or metadata field that PPQ stores in history.
|
||||
|
||||
## Recommended Routstr direction
|
||||
|
||||
Use Tinfoil/PPQ as a direct blind upstream and have Routstr explicitly request usage metadata:
|
||||
|
||||
```text
|
||||
User encrypts body
|
||||
Routstr reserves max cost
|
||||
Routstr forwards encrypted body to Tinfoil/PPQ /private/
|
||||
Routstr includes X-Tinfoil-Request-Usage-Metrics: true
|
||||
Tinfoil/PPQ returns X-Tinfoil-Usage-Metrics as a response header for non-streaming,
|
||||
or as an HTTP trailer after the body completes for streaming
|
||||
Routstr finalizes exact charge
|
||||
User decrypts encrypted response
|
||||
```
|
||||
|
||||
This preserves both:
|
||||
|
||||
- privacy: Routstr cannot read prompts/responses;
|
||||
- exact billing: Routstr can charge actual PPQ private model cost, assuming Tinfoil/PPQ returns usage/cost metadata outside the encrypted body.
|
||||
|
||||
Implementation steps:
|
||||
|
||||
1. Update PPQ model fetching to include private models:
|
||||
|
||||
```text
|
||||
GET https://api.ppq.ai/v1/models?type=all
|
||||
```
|
||||
|
||||
2. Register `private/*` models and any Routstr-facing aliases with correct `forwarded_model_id`.
|
||||
|
||||
3. Keep max-cost reservation for bearer keys and X-Cashu solvency checks.
|
||||
|
||||
4. Add parsing support for possible usage/cost headers:
|
||||
|
||||
```http
|
||||
X-PPQ-Cost-USD
|
||||
X-Private-Usage-Metrics
|
||||
X-Tinfoil-Usage-Metrics
|
||||
X-Encrypted-Usage-Metrics
|
||||
```
|
||||
|
||||
5. Finalize by actual cost instead of max cost:
|
||||
|
||||
```text
|
||||
actual_msats = ceil((actual_usd / sats_usd_price()) * 1000)
|
||||
actual_msats = max(actual_msats, settings.min_request_msat)
|
||||
actual_msats = min(actual_msats, reserved_msats)
|
||||
```
|
||||
|
||||
or, if only token counts are available:
|
||||
|
||||
```text
|
||||
actual_usd =
|
||||
input_tokens * input_per_1M_tokens / 1_000_000
|
||||
+ output_tokens * output_per_1M_tokens / 1_000_000
|
||||
```
|
||||
|
||||
6. If usage/cost metadata is missing, choose an explicit policy:
|
||||
|
||||
- fail closed and refund/revert;
|
||||
- query PPQ history as a fallback;
|
||||
- fallback to max-cost billing only if explicitly configured and clearly disclosed.
|
||||
|
||||
Silent max-cost billing should not be the default for PPQ private requests.
|
||||
|
||||
## X-Cashu consideration
|
||||
|
||||
For bearer-auth requests, finalization can happen after the response stream completes if usage is delivered as a trailer.
|
||||
|
||||
For `X-Cashu`, Routstr needs to return the refund token in the response headers. If usage/cost is only available after consuming the encrypted response stream, Routstr may need to buffer EHBP responses before sending them to the client so it can compute the refund amount first.
|
||||
|
||||
Possible approaches:
|
||||
|
||||
1. Prefer a non-trailer response header with actual cost, available before streaming body starts.
|
||||
2. Buffer EHBP X-Cashu responses and then return `X-Cashu` refund.
|
||||
3. Introduce a later/refund-claim mechanism, which would be a larger protocol change.
|
||||
|
||||
## Summary
|
||||
|
||||
- PPQ private models are billed per actual input/output tokens.
|
||||
- Private model rates are available from `GET /v1/models?type=all`.
|
||||
- Current Routstr EHBP billing at max cost is wrong for PPQ private models.
|
||||
- Direct Tinfoil integration inside Routstr would enable exact usage billing but would make Routstr see plaintext.
|
||||
- A blind EHBP relay preserves privacy but requires PPQ/Tinfoil to expose usage/cost in plaintext headers/trailers.
|
||||
- The preferred solution is to keep Routstr blind and have PPQ return billing metadata outside the encrypted body.
|
||||
|
||||
## Implementation status
|
||||
|
||||
Direct Tinfoil upstream integration is implemented in `routstr/upstream/tinfoil.py`
|
||||
and `routstr/upstream/ehbp.py`.
|
||||
|
||||
### What was built
|
||||
|
||||
- `TinfoilUpstreamProvider` (`provider_type = "tinfoil"`):
|
||||
- Base URL: `https://inference.tinfoil.sh`
|
||||
- Fetches models from the public `GET /v1/models` endpoint (no auth needed).
|
||||
- Parses Tinfoil's pricing (`inputTokenPricePer1M`, `outputTokenPricePer1M`,
|
||||
`requestPrice`) into the standard `Model`/`Pricing` schema.
|
||||
- `supports_ehbp = True` — acts as a blind EHBP relay.
|
||||
- `get_ehbp_forwarding_target()` returns a target that includes
|
||||
`X-Tinfoil-Request-Usage-Metrics: true`.
|
||||
- `forward_get_request()` proxies `/attestation` to `https://atc.tinfoil.sh/attestation`
|
||||
so the SDK can fetch attestation bundles through Routstr.
|
||||
- Registered in `routstr/upstream/__init__.py` and seeded from
|
||||
`TINFOIL_API_KEY` env var.
|
||||
|
||||
- `routstr/upstream/ehbp.py`:
|
||||
- `parse_tinfoil_usage_metrics()` parses
|
||||
`prompt=N,completion=N[,total=N][,model=<name>]` into an OpenAI-style
|
||||
usage dict. The `model` field (added in tinfoilsh/confidential-model-router
|
||||
PR #385) is extracted as a string.
|
||||
- `_resolve_ehbp_target_url()` overrides the forwarding URL with
|
||||
`X-Tinfoil-Enclave-Url` when the SDK sends it.
|
||||
- `_strip_proxy_headers()` removes `X-Routstr-Model`,
|
||||
`X-Tinfoil-Enclave-Url`, and `X-Tinfoil-Request-Usage-Metrics` before
|
||||
forwarding to the enclave.
|
||||
- `_compute_ehbp_actual_cost()` converts the usage header into msats via
|
||||
`calculate_cost()`, clamped to `[min_request_msat, max_cost_for_model]`.
|
||||
When the header's `model=<name>` differs from the requested model, the
|
||||
actual served model's pricing is used for cost calculation.
|
||||
- `forward_ehbp_request()` (bearer auth): if `X-Tinfoil-Usage-Metrics` is
|
||||
present in the response header, finalizes with `adjust_payment_for_tokens()`
|
||||
for exact billing; otherwise falls back to max-cost. Billing uses the
|
||||
actual served model when it differs from the requested one.
|
||||
- `forward_ehbp_x_cashu_request()`: if usage is available, computes the
|
||||
refund from actual cost instead of max cost, using the actual served
|
||||
model's pricing when applicable.
|
||||
|
||||
- `routstr/proxy.py`: `/attestation` and `/tee/attestation` paths are forwarded
|
||||
to Tinfoil upstreams without model/cost/auth lookups.
|
||||
|
||||
### Billing behavior
|
||||
|
||||
| Request shape | Usage source | Billing |
|
||||
|---|---|---|
|
||||
| Bearer, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Exact token cost via `adjust_payment_for_tokens` |
|
||||
| Bearer, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Exact token cost (h11 captures trailers) |
|
||||
| Bearer, no usage header/trailer | N/A | Max-cost fallback |
|
||||
| X-Cashu, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Refund = `redeemed - actual_cost` |
|
||||
| X-Cashu, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Refund = `redeemed - actual_cost` (h11 captures trailers) |
|
||||
| X-Cashu, no usage header/trailer | N/A | Refund = `redeemed - max_cost` |
|
||||
|
||||
### Cost response headers
|
||||
|
||||
Since EHBP response bodies are opaque encrypted blobs, per-request cost cannot
|
||||
be injected into the JSON body (as done in the normal proxy flow). Instead,
|
||||
Routstr returns cost info as response headers:
|
||||
|
||||
| Header | Auth | Description |
|
||||
|---|---|---|
|
||||
| `X-Routstr-Cost-Msats` | Bearer, X-Cashu | Total msats charged for this request |
|
||||
| `X-Routstr-Cost-Usd` | Bearer | USD equivalent of the charge |
|
||||
| `X-Routstr-Input-Cost-Msats` | Bearer, X-Cashu | msats attributed to input tokens |
|
||||
| `X-Routstr-Output-Cost-Msats` | Bearer, X-Cashu | msats attributed to output tokens |
|
||||
|
||||
The client/Tinfoil SDK can read these headers from the HTTP response without
|
||||
needing to decrypt the body.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
TINFOIL_API_KEY=your-tinfoil-api-key
|
||||
```
|
||||
|
||||
The provider is auto-seeded on first startup.
|
||||
|
||||
### Usage metrics header format
|
||||
|
||||
Tinfoil returns usage metrics in the `X-Tinfoil-Usage-Metrics` response header
|
||||
(non-streaming) or HTTP trailer (streaming) when `X-Tinfoil-Request-Usage-Metrics:
|
||||
true` is sent. As of tinfoilsh/confidential-model-router PR #385, the format is:
|
||||
|
||||
```
|
||||
prompt=<prompt_tokens>,completion=<completion_tokens>,total=<total_tokens>,model=<served_model>
|
||||
```
|
||||
|
||||
The `model` field carries the actual model name served by the enclave.
|
||||
Routstr uses this to:
|
||||
|
||||
- Verify the served model matches the expected upstream model. The comparison
|
||||
uses ``model_obj.forwarded_model_id`` (the actual upstream ID, e.g.
|
||||
``glm-5-2``) rather than ``model_obj.id`` (the client-facing alias, e.g.
|
||||
``tinfoil-glm-5-2``), so aliased models don't trigger a spurious mismatch.
|
||||
- When they genuinely differ (Tinfoil served a different upstream model than
|
||||
expected), look up the actual served model's pricing and use it for billing.
|
||||
The reverse lookup uses ``get_model_instance``, which resolves
|
||||
``forwarded_model_id`` values registered as routable aliases.
|
||||
- Log the discrepancy for observability.
|
||||
|
||||
If the actual model is not found in Routstr's model registry, billing falls
|
||||
back to the requested model's pricing.
|
||||
|
||||
### What still needs verification
|
||||
|
||||
- ~~End-to-end test with a real Tinfoil SDK client against a Routstr node with
|
||||
`TINFOIL_API_KEY` set.~~ Verified: both non-streaming (header) and streaming
|
||||
(trailer) responses include `model=<name>`.
|
||||
- Streaming trailer capture is implemented by buffering the encrypted response
|
||||
in `forward_with_trailer()` and then using the dedicated EHBP payment
|
||||
finalizers for bearer and X-Cashu requests. This provides actual-cost billing
|
||||
today, at the cost of full time-to-last-byte latency for streaming responses.
|
||||
- Whether Tinfoil's `/v1/responses` endpoint also returns usage metrics
|
||||
headers or trailers.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add fee payout checkpoint
|
||||
|
||||
Revision ID: d7e8f9a0b1c2
|
||||
Revises: c6d7e8f9a0b1
|
||||
Create Date: 2026-07-18 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d7e8f9a0b1c2"
|
||||
down_revision = "c6d7e8f9a0b1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"routstr_fees",
|
||||
sa.Column(
|
||||
"payout_in_progress_msats",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"routstr_fees",
|
||||
sa.Column("payout_started_at", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("routstr_fees", "payout_started_at")
|
||||
op.drop_column("routstr_fees", "payout_in_progress_msats")
|
||||
50
migrations/versions/f9a0b1c2d3e4_add_reservation_releases.py
Normal file
50
migrations/versions/f9a0b1c2d3e4_add_reservation_releases.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""add reservation release idempotency records
|
||||
|
||||
Revision ID: f9a0b1c2d3e4
|
||||
Revises: d7e8f9a0b1c2
|
||||
Create Date: 2026-07-18 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f9a0b1c2d3e4"
|
||||
down_revision = "d7e8f9a0b1c2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"reservation_releases",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("key_hash", sa.String(), nullable=False),
|
||||
sa.Column("billing_key_hash", sa.String(), nullable=False),
|
||||
sa.Column("reserved_msats", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reservation_releases_key_hash",
|
||||
"reservation_releases",
|
||||
["key_hash"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_reservation_releases_billing_key_hash",
|
||||
"reservation_releases",
|
||||
["billing_key_hash"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_reservation_releases_billing_key_hash",
|
||||
table_name="reservation_releases",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_reservation_releases_key_hash",
|
||||
table_name="reservation_releases",
|
||||
)
|
||||
op.drop_table("reservation_releases")
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"aiosqlite>=0.20",
|
||||
"sqlmodel>=0.0.24",
|
||||
"httpx[socks]>=0.25.2",
|
||||
"h11>=0.14",
|
||||
"greenlet>=3.2.1",
|
||||
"alembic>=1.13",
|
||||
"python-json-logger>=2.0.0",
|
||||
|
||||
@@ -86,8 +86,8 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
||||
|
||||
def create_model_mappings(
|
||||
upstreams: list["BaseUpstreamProvider"],
|
||||
overrides_by_id: dict[str, tuple],
|
||||
disabled_model_ids: set[str],
|
||||
overrides_by_key: dict[tuple[str, int], tuple],
|
||||
disabled_model_keys: set[tuple[str, int]],
|
||||
) -> tuple[
|
||||
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
|
||||
]:
|
||||
@@ -107,8 +107,9 @@ def create_model_mappings(
|
||||
|
||||
Args:
|
||||
upstreams: List of all upstream provider instances
|
||||
overrides_by_id: Dict of model overrides from database {model_id: (ModelRow, fee)}
|
||||
disabled_model_ids: Set of model IDs that should be excluded
|
||||
overrides_by_key: Dict of model overrides from database
|
||||
{(model_id_lower, upstream_provider_id): (ModelRow, fee)}
|
||||
disabled_model_keys: Set of provider-scoped model keys that should be excluded
|
||||
|
||||
Returns:
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
@@ -179,14 +180,22 @@ def create_model_mappings(
|
||||
"""Process all models from a given provider."""
|
||||
upstream_prefix = getattr(upstream, "upstream_name", None)
|
||||
provider_key = get_provider_identity(upstream)
|
||||
upstream_db_id = getattr(upstream, "db_id", None)
|
||||
|
||||
for model in upstream.get_cached_models():
|
||||
if not model.enabled or model.id in disabled_model_ids:
|
||||
model_key = (
|
||||
(model.id.lower(), upstream_db_id)
|
||||
if isinstance(upstream_db_id, int)
|
||||
else None
|
||||
)
|
||||
if not model.enabled or (
|
||||
model_key is not None and model_key in disabled_model_keys
|
||||
):
|
||||
continue
|
||||
|
||||
# Apply overrides if present
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
# Apply overrides only for this provider's model row.
|
||||
if model_key is not None and model_key in overrides_by_key:
|
||||
override_row, provider_fee = overrides_by_key[model_key]
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
@@ -237,13 +246,10 @@ def create_model_mappings(
|
||||
|
||||
# Include enabled DB overrides even when provider discovery misses models.
|
||||
# This is important for deployment-based providers like Azure.
|
||||
for model_id, override_data in overrides_by_id.items():
|
||||
if model_id in disabled_model_ids:
|
||||
for (model_id, upstream_provider_id), override_data in overrides_by_key.items():
|
||||
if (model_id, upstream_provider_id) in disabled_model_keys:
|
||||
continue
|
||||
override_row, provider_fee = override_data
|
||||
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
|
||||
if not isinstance(upstream_provider_id, int):
|
||||
continue
|
||||
|
||||
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
|
||||
if upstream_for_override is None:
|
||||
|
||||
173
routstr/auth.py
173
routstr/auth.py
@@ -3,16 +3,24 @@ import hashlib
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import case
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.sql.dml import Update
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import ApiKey, AsyncSession, accumulate_routstr_fee
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
ReservationRelease,
|
||||
accumulate_routstr_fee,
|
||||
)
|
||||
from .core.settings import settings
|
||||
from .payment.cost_calculation import (
|
||||
CostData,
|
||||
@@ -20,7 +28,11 @@ from .payment.cost_calculation import (
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
from .wallet import (
|
||||
classify_redemption_error,
|
||||
credit_balance,
|
||||
deserialize_token_from_string,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
payments_logger = get_logger("routstr.payments")
|
||||
@@ -78,6 +90,37 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def redemption_error_to_http_exception(error: Exception) -> HTTPException:
|
||||
"""Map a Cashu token redemption failure to a sanitized client-facing error.
|
||||
|
||||
Thin wrapper over the shared :func:`classify_redemption_error` so the bearer
|
||||
path stays identical to the X-Cashu and top-up paths.
|
||||
"""
|
||||
classified = classify_redemption_error(error)
|
||||
if classified is None:
|
||||
return HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Internal error during token redemption",
|
||||
"type": "api_error",
|
||||
"code": "internal_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
error_type, status_code, message, error_code = classified
|
||||
return HTTPException(
|
||||
status_code=status_code,
|
||||
detail={
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": error_type,
|
||||
"code": error_code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
@@ -216,7 +259,17 @@ async def validate_bearer_key(
|
||||
|
||||
try:
|
||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
except Exception as decode_error:
|
||||
# A malformed token is a bad token (400 invalid_cashu_token via
|
||||
# the shared taxonomy), not an auth failure (401) — otherwise it
|
||||
# would fall through to the generic "Invalid API key" handler.
|
||||
raise redemption_error_to_http_exception(
|
||||
ValueError(
|
||||
f"Invalid Cashu token: could not decode token ({decode_error})"
|
||||
)
|
||||
) from decode_error
|
||||
logger.debug(
|
||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
)
|
||||
@@ -334,19 +387,32 @@ async def validate_bearer_key(
|
||||
"error_type": type(credit_error).__name__,
|
||||
},
|
||||
)
|
||||
raise credit_error
|
||||
await session.rollback()
|
||||
raise redemption_error_to_http_exception(credit_error) from credit_error
|
||||
|
||||
if msats <= 0:
|
||||
logger.error(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
# 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.
|
||||
# Defense-in-depth: credit_balance already raises
|
||||
# ValueError("Redeemed token amount must be positive…") before
|
||||
# returning (wallet.py), so this branch is only reachable if a
|
||||
# zero/negative row was somehow persisted; drop it so we never
|
||||
# leave an orphan zero-balance key. Reuse the shared taxonomy
|
||||
# (cashu_error) so the envelope matches the mapper above.
|
||||
await session.delete(new_key)
|
||||
await session.commit()
|
||||
raise Exception("Token redemption failed")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Failed to redeem Cashu token: token yielded no value",
|
||||
"type": "cashu_error",
|
||||
"code": "cashu_token_zero_value",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(new_key)
|
||||
await session.commit()
|
||||
@@ -379,7 +445,7 @@ async def validate_bearer_key(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"message": "Invalid or expired Cashu key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
@@ -708,6 +774,95 @@ async def revert_pay_for_request(
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReservationSnapshot:
|
||||
release_id: str
|
||||
key_hash: str
|
||||
billing_key_hash: str
|
||||
|
||||
|
||||
async def get_reservation_snapshot(
|
||||
key: ApiKey, session: AsyncSession
|
||||
) -> ReservationSnapshot:
|
||||
"""Capture the reservation state used for idempotent cleanup."""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
return ReservationSnapshot(
|
||||
release_id=uuid.uuid4().hex,
|
||||
key_hash=key.hashed_key,
|
||||
billing_key_hash=billing_key.hashed_key,
|
||||
)
|
||||
|
||||
|
||||
def _reservation_release_statement(
|
||||
key_hash: str,
|
||||
reserved_msats: int,
|
||||
) -> Update:
|
||||
return (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key_hash)
|
||||
.where(col(ApiKey.reserved_balance) >= reserved_msats)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - reserved_msats,
|
||||
reserved_at=case(
|
||||
(
|
||||
col(ApiKey.reserved_balance) - reserved_msats > 0,
|
||||
col(ApiKey.reserved_at),
|
||||
),
|
||||
else_=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def release_reservation(
|
||||
snapshot: ReservationSnapshot,
|
||||
session: AsyncSession,
|
||||
reserved_msats: int,
|
||||
) -> bool:
|
||||
"""Release one reservation exactly once without charging."""
|
||||
if reserved_msats <= 0:
|
||||
return False
|
||||
|
||||
session.add(
|
||||
ReservationRelease(
|
||||
id=snapshot.release_id,
|
||||
key_hash=snapshot.key_hash,
|
||||
billing_key_hash=snapshot.billing_key_hash,
|
||||
reserved_msats=reserved_msats,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
existing = await session.get(ReservationRelease, snapshot.release_id)
|
||||
return existing is not None and existing.reserved_msats == reserved_msats
|
||||
|
||||
release_stmt = _reservation_release_statement(
|
||||
snapshot.billing_key_hash,
|
||||
reserved_msats,
|
||||
)
|
||||
result = await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
if result.rowcount != 1:
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
if snapshot.billing_key_hash != snapshot.key_hash:
|
||||
child_release_stmt = _reservation_release_statement(
|
||||
snapshot.key_hash,
|
||||
reserved_msats,
|
||||
)
|
||||
child_result = await session.exec( # type: ignore[call-overload]
|
||||
child_release_stmt
|
||||
)
|
||||
if child_result.rowcount != 1:
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: ApiKey,
|
||||
response_data: dict,
|
||||
|
||||
@@ -20,7 +20,14 @@ from .core.db import (
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
from .wallet import (
|
||||
classify_redemption_error,
|
||||
credit_balance,
|
||||
is_mint_connection_error,
|
||||
recieve_token,
|
||||
send_to_lnurl,
|
||||
send_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
@@ -156,30 +163,18 @@ async def topup_wallet_endpoint(
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Token already spent")
|
||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Token value is too small to cover swap fees. {error_msg}",
|
||||
)
|
||||
elif "failed to melt" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to swap foreign mint token. {error_msg}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
# Shared taxonomy so top-up matches the bearer/X-Cashu paths (503 for an
|
||||
# unreachable mint, 422 for fee/swap failures, 400 for token faults).
|
||||
classified = classify_redemption_error(e)
|
||||
if classified is None:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
_type, status_code, message, _code = classified
|
||||
raise HTTPException(status_code=status_code, detail=message)
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
@@ -274,7 +269,20 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
out_tx = out_tx_result.first()
|
||||
if out_tx is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
# The "in" row exists with a request_id, but the "out" (refund)
|
||||
# row hasn't been written yet — the upstream request is still in
|
||||
# flight and the refund will be minted once it completes. Tell the
|
||||
# client to retry instead of 404ing permanently (race condition
|
||||
# where /v1/wallet/refund is polled before the refund exists).
|
||||
logger.debug(
|
||||
"refund_wallet_endpoint: refund pending (in row exists, out row not yet created)",
|
||||
extra={"request_id": in_tx.request_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=425,
|
||||
detail="Refund is pending; retry shortly.",
|
||||
headers={"Retry-After": "2"},
|
||||
)
|
||||
if out_tx.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
|
||||
@@ -441,14 +449,10 @@ async def refund_wallet_endpoint(
|
||||
"has_refund_address": bool(key.refund_address),
|
||||
},
|
||||
)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
or "connection" in error_msg.lower()
|
||||
or "ConnectError" in str(type(e))
|
||||
):
|
||||
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
|
||||
if is_mint_connection_error(e):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from .db import (
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
@@ -411,12 +412,11 @@ async def withdraw(
|
||||
# Get wallet and check balance
|
||||
from .settings import settings as global_settings
|
||||
|
||||
wallet = await get_wallet(
|
||||
withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit
|
||||
)
|
||||
effective_mint = withdraw_request.mint_url or global_settings.primary_mint
|
||||
wallet = await get_wallet(effective_mint, withdraw_request.unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet,
|
||||
withdraw_request.mint_url or global_settings.primary_mint,
|
||||
effective_mint,
|
||||
withdraw_request.unit,
|
||||
not_reserved=True,
|
||||
)
|
||||
@@ -432,7 +432,16 @@ async def withdraw(
|
||||
raise HTTPException(status_code=400, detail="Insufficient wallet balance")
|
||||
|
||||
token = await send_token(
|
||||
withdraw_request.amount, withdraw_request.unit, withdraw_request.mint_url
|
||||
withdraw_request.amount, withdraw_request.unit, effective_mint
|
||||
)
|
||||
await store_cashu_transaction(
|
||||
token=token,
|
||||
amount=withdraw_request.amount,
|
||||
unit=withdraw_request.unit,
|
||||
mint_url=effective_mint,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="admin",
|
||||
)
|
||||
return {"token": token}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ async def store_cashu_transaction(
|
||||
created_at: int | None = None,
|
||||
source: str = "x-cashu",
|
||||
api_key_hashed_key: str | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
tx = CashuTransaction(
|
||||
@@ -304,11 +304,13 @@ async def store_cashu_transaction(
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to store cashu transaction: {e} (type={typ})",
|
||||
extra={"error": str(e), "type": typ},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
@@ -346,12 +348,24 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
)
|
||||
|
||||
|
||||
class ReservationRelease(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "reservation_releases"
|
||||
|
||||
id: str = Field(primary_key=True)
|
||||
key_hash: str = Field(index=True)
|
||||
billing_key_hash: str = Field(index=True)
|
||||
reserved_msats: int
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
|
||||
|
||||
class RoutstrFee(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "routstr_fees"
|
||||
id: int = Field(default=1, primary_key=True)
|
||||
accumulated_msats: int = Field(default=0)
|
||||
total_paid_msats: int = Field(default=0)
|
||||
last_paid_at: int | None = Field(default=None)
|
||||
payout_in_progress_msats: int = Field(default=0)
|
||||
payout_started_at: int | None = Field(default=None)
|
||||
|
||||
|
||||
class CliToken(SQLModel, table=True): # type: ignore
|
||||
@@ -392,18 +406,42 @@ async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
|
||||
return fee
|
||||
|
||||
|
||||
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> None:
|
||||
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> bool:
|
||||
"""Checkpoint a fee payout before making the external payment."""
|
||||
stmt = (
|
||||
update(RoutstrFee)
|
||||
.where(col(RoutstrFee.id) == 1)
|
||||
.where(col(RoutstrFee.payout_in_progress_msats) == 0)
|
||||
.where(col(RoutstrFee.accumulated_msats) >= paid_msats)
|
||||
.values(
|
||||
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
|
||||
payout_in_progress_msats=paid_msats,
|
||||
payout_started_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
async def complete_routstr_fee_payout(
|
||||
session: AsyncSession, paid_msats: int
|
||||
) -> bool:
|
||||
"""Mark a checkpointed payout complete after the external payment succeeds."""
|
||||
stmt = (
|
||||
update(RoutstrFee)
|
||||
.where(col(RoutstrFee.id) == 1)
|
||||
.where(col(RoutstrFee.payout_in_progress_msats) == paid_msats)
|
||||
.values(
|
||||
payout_in_progress_msats=0,
|
||||
payout_started_at=None,
|
||||
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
|
||||
last_paid_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
async def balances_for_mint_and_unit(
|
||||
|
||||
@@ -257,7 +257,20 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["x-routstr-request-id", "x-cashu"],
|
||||
expose_headers=[
|
||||
"x-routstr-request-id",
|
||||
"x-cashu",
|
||||
"x-routstr-cost-msats",
|
||||
"x-routstr-cost-usd",
|
||||
"x-routstr-input-cost-msats",
|
||||
"x-routstr-output-cost-msats",
|
||||
# EHBP (Tinfoil) protocol headers must be exposed so browser clients
|
||||
# can detect and decrypt encrypted responses. Without these, the
|
||||
# browser hides them via CORS and the SDK treats the response as a
|
||||
# plaintext proxy error, returning raw ciphertext.
|
||||
"Ehbp-Response-Nonce",
|
||||
"Ehbp-Encapsulated-Key",
|
||||
],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
|
||||
@@ -20,7 +20,7 @@ import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
BASE_VERSION = "0.4.3"
|
||||
BASE_VERSION = "0.4.4"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_GIT_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
@@ -41,6 +41,28 @@ class CostDataError(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
def _empty_cost(cls: type[CostData] = CostData) -> CostData:
|
||||
"""Build an all-zero cost object — a full refund for an empty response.
|
||||
|
||||
Shared by the two paths that must not bill: an upstream response with no
|
||||
usage data at all, and one that reports a USD cost but carries zero tokens
|
||||
in every bucket.
|
||||
"""
|
||||
return cls(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_msats=0,
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_cost(
|
||||
response_data: dict,
|
||||
max_cost: int,
|
||||
@@ -83,19 +105,7 @@ async def calculate_cost(
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
cache_read_input_tokens=0,
|
||||
cache_creation_input_tokens=0,
|
||||
cache_read_msats=0,
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
return _empty_cost(MaxCostData)
|
||||
|
||||
usage_data = response_data.get("usage") or {}
|
||||
if not isinstance(usage_data, dict):
|
||||
@@ -109,6 +119,27 @@ async def calculate_cost(
|
||||
# Try USD cost first
|
||||
usd_cost = _resolve_usd_cost(usage_data, response_data)
|
||||
if usd_cost > 0:
|
||||
truly_empty = (
|
||||
input_tokens == 0
|
||||
and output_tokens == 0
|
||||
and cache_read_tokens == 0
|
||||
and cache_creation_tokens == 0
|
||||
)
|
||||
if truly_empty:
|
||||
logger.warning(
|
||||
"Upstream reported a USD cost but the response carries no "
|
||||
"tokens at all (input, output, cache-read and cache-creation "
|
||||
"are all zero) — refunding in full rather than billing the "
|
||||
"USD-derived cost for an empty response.",
|
||||
extra={
|
||||
"model": response_data.get("model", "unknown"),
|
||||
"usd_cost": usd_cost,
|
||||
"usage_keys": sorted(usage_data.keys())
|
||||
if isinstance(usage_data, dict)
|
||||
else None,
|
||||
},
|
||||
)
|
||||
return _empty_cost()
|
||||
if input_tokens == 0 and output_tokens == 0:
|
||||
logger.warning(
|
||||
"Upstream reported a USD cost but no token counts — "
|
||||
@@ -126,11 +157,16 @@ async def calculate_cost(
|
||||
},
|
||||
)
|
||||
try:
|
||||
cost_details = usage_data.get("cost_details", {})
|
||||
if not isinstance(cost_details, dict):
|
||||
cost_details = {}
|
||||
input_usd = _coerce_usd(
|
||||
usage_data.get("cost_details", {}).get("input_cost", 0)
|
||||
cost_details.get("input_cost")
|
||||
or cost_details.get("upstream_inference_prompt_cost")
|
||||
)
|
||||
output_usd = _coerce_usd(
|
||||
usage_data.get("cost_details", {}).get("output_cost", 0)
|
||||
cost_details.get("output_cost")
|
||||
or cost_details.get("upstream_inference_completions_cost")
|
||||
)
|
||||
return _calculate_from_usd_cost(
|
||||
usd_cost,
|
||||
@@ -228,7 +264,17 @@ def _coerce_usd(value: object) -> float:
|
||||
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
|
||||
"""Resolve USD cost with clear priority order.
|
||||
|
||||
Priority: cost_details.total_cost → total_cost → cost (in both usage and response).
|
||||
Priority:
|
||||
|
||||
1. ``cost_details.total_cost``
|
||||
2. ``cost_details.upstream_inference_cost`` (BYOK — see below)
|
||||
3. ``total_cost`` → ``cost`` (in both usage and response)
|
||||
|
||||
**BYOK path (PPQ.AI):** when ``is_byok`` is true the ``usage.cost`` field
|
||||
is only a small (~5 %) routing fee, not the inference cost. The real cost
|
||||
lives in ``cost_details.upstream_inference_cost`` and the provider's
|
||||
balance is debited by ``upstream_inference_cost + byok_fee``. Billing just
|
||||
the fee under-charges by ~20×.
|
||||
"""
|
||||
cost_details = usage_data.get("cost_details")
|
||||
if isinstance(cost_details, dict):
|
||||
@@ -236,6 +282,18 @@ def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
|
||||
if cost > 0:
|
||||
return cost
|
||||
|
||||
# PPQ.AI BYOK: upstream_inference_cost is the real inference cost;
|
||||
# usage.cost is only a ~5 % BYOK routing fee. Bill the sum — what PPQ
|
||||
# actually deducts from the balance. For non-BYOK providers (e.g.
|
||||
# OpenRouter) usage.cost already equals upstream_inference_cost, so we
|
||||
# fall through to the normal ``cost`` lookup below.
|
||||
upstream_cost = _coerce_usd(
|
||||
cost_details.get("upstream_inference_cost")
|
||||
)
|
||||
if upstream_cost > 0 and usage_data.get("is_byok"):
|
||||
byok_fee = _coerce_usd(usage_data.get("cost"))
|
||||
return upstream_cost + byok_fee
|
||||
|
||||
for source in [usage_data, response_data]:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
@@ -250,54 +308,92 @@ def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
|
||||
def _get_pricing_rates(
|
||||
response_data: dict,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Get model-based pricing rates or None if using fixed pricing.
|
||||
"""Get configured rates, falling back to LiteLLM's model cost map.
|
||||
|
||||
Returns: (input_rate, output_rate, cache_read_rate, cache_write_rate)
|
||||
Returns: (input_rate, output_rate, cache_read_rate, cache_write_rate).
|
||||
``None`` means configured fixed pricing should be used by the caller.
|
||||
"""
|
||||
if settings.fixed_pricing:
|
||||
if settings.fixed_pricing and (
|
||||
settings.fixed_per_1k_input_tokens
|
||||
or settings.fixed_per_1k_output_tokens
|
||||
):
|
||||
return None
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
from .models import litellm_cost_entry
|
||||
|
||||
response_model = response_data.get("model", "")
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error("Invalid model in response", extra={"response_model": response_model})
|
||||
raise ValueError(f"Invalid model: {response_model}")
|
||||
if model_obj and model_obj.sats_pricing:
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
mscr = float(model_obj.sats_pricing.input_cache_read or 0)
|
||||
mscw = float(model_obj.sats_pricing.input_cache_write or 0)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
mspp_1k = mspp * 1_000_000.0
|
||||
mspc_1k = mspc * 1_000_000.0
|
||||
mscr_1k = mscr * 1_000_000.0 if mscr > 0 else mspp_1k
|
||||
mscw_1k = mscw * 1_000_000.0 if mscw > 0 else mspp_1k
|
||||
source = "configured"
|
||||
except Exception as e:
|
||||
logger.error("Invalid pricing data", extra={"error": str(e)})
|
||||
raise ValueError("Invalid pricing data") from e
|
||||
else:
|
||||
pricing_model = (
|
||||
model_obj.forwarded_model_id if model_obj else None
|
||||
) or response_model
|
||||
pricing = litellm_cost_entry(pricing_model)
|
||||
if pricing is None:
|
||||
logger.error(
|
||||
"Model pricing not found in configured models or LiteLLM",
|
||||
extra={
|
||||
"response_model": response_model,
|
||||
"pricing_model": pricing_model,
|
||||
},
|
||||
)
|
||||
raise ValueError(f"Pricing not found for model: {response_model}")
|
||||
|
||||
input_usd = _coerce_usd(pricing.get("input_cost_per_token"))
|
||||
output_usd = _coerce_usd(pricing.get("output_cost_per_token"))
|
||||
if input_usd <= 0 or output_usd <= 0:
|
||||
raise ValueError(f"Incomplete LiteLLM pricing for model: {pricing_model}")
|
||||
|
||||
provider_fee = _resolve_provider_fee(response_model)
|
||||
usd_per_sat = sats_usd_price()
|
||||
mspp_1k = input_usd * provider_fee * 1_000_000.0 / usd_per_sat
|
||||
mspc_1k = output_usd * provider_fee * 1_000_000.0 / usd_per_sat
|
||||
cache_read_usd = _coerce_usd(
|
||||
pricing.get("cache_read_input_token_cost")
|
||||
)
|
||||
raise ValueError("Model pricing not defined")
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
mscr = float(model_obj.sats_pricing.input_cache_read or 0)
|
||||
mscw = float(model_obj.sats_pricing.input_cache_write or 0)
|
||||
|
||||
mspp_1k = mspp * 1_000_000.0
|
||||
mspc_1k = mspc * 1_000_000.0
|
||||
mscr_1k = mscr * 1_000_000.0 if mscr > 0 else mspp_1k
|
||||
mscw_1k = mscw * 1_000_000.0 if mscw > 0 else mspp_1k
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": mspp_1k,
|
||||
"output_price_msats_per_1k": mspc_1k,
|
||||
"cache_read_price_msats_per_1k": mscr_1k,
|
||||
"cache_write_price_msats_per_1k": mscw_1k,
|
||||
},
|
||||
cache_write_usd = _coerce_usd(
|
||||
pricing.get("cache_creation_input_token_cost")
|
||||
)
|
||||
return mspp_1k, mspc_1k, mscr_1k, mscw_1k
|
||||
except Exception as e:
|
||||
logger.error("Invalid pricing data", extra={"error": str(e)})
|
||||
raise ValueError("Invalid pricing data") from e
|
||||
mscr_1k = (
|
||||
cache_read_usd * provider_fee * 1_000_000.0 / usd_per_sat
|
||||
if cache_read_usd > 0
|
||||
else mspp_1k
|
||||
)
|
||||
mscw_1k = (
|
||||
cache_write_usd * provider_fee * 1_000_000.0 / usd_per_sat
|
||||
if cache_write_usd > 0
|
||||
else mspp_1k
|
||||
)
|
||||
source = "litellm"
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"pricing_source": source,
|
||||
"input_price_msats_per_1k": mspp_1k,
|
||||
"output_price_msats_per_1k": mspc_1k,
|
||||
"cache_read_price_msats_per_1k": mscr_1k,
|
||||
"cache_write_price_msats_per_1k": mscw_1k,
|
||||
},
|
||||
)
|
||||
return mspp_1k, mspc_1k, mscr_1k, mscw_1k
|
||||
|
||||
|
||||
def _resolve_provider_fee(model_id: str) -> float:
|
||||
@@ -336,8 +432,12 @@ def _calculate_from_usd_cost(
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
if input_usd > 0 or output_usd > 0:
|
||||
input_msats = int((input_usd * sats_per_usd) * 1000)
|
||||
output_msats = int((output_usd * sats_per_usd) * 1000)
|
||||
# The total is the authoritative billed amount. Allocating that integer
|
||||
# total proportionally avoids losing sub-millisatoshi remainders when
|
||||
# input and output components are each truncated independently.
|
||||
component_usd = input_usd + output_usd
|
||||
input_msats = math.floor(cost_in_msats * input_usd / component_usd)
|
||||
output_msats = cost_in_msats - input_msats
|
||||
else:
|
||||
effective_input_tokens = (
|
||||
input_tokens + cache_read_tokens + cache_creation_tokens
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
from cashu.wallet.wallet import Proof, Wallet
|
||||
|
||||
# The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung or
|
||||
# very slow mint can block a melt (and any caller, e.g. the payout loop)
|
||||
# indefinitely. Bound it here so callers fail instead of hanging forever.
|
||||
MELT_TIMEOUT_SECONDS = 60
|
||||
|
||||
try:
|
||||
from bech32 import bech32_decode, convertbits # type: ignore
|
||||
except ModuleNotFoundError: # pragma: no cover – allow runtime miss
|
||||
@@ -220,10 +226,18 @@ async def raw_send_to_lnurl(
|
||||
if amount:
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
|
||||
_ = await wallet.melt(
|
||||
proofs=proofs,
|
||||
invoice=bolt11_invoice,
|
||||
fee_reserve_sat=melt_quote_resp.fee_reserve,
|
||||
quote_id=melt_quote_resp.quote,
|
||||
)
|
||||
try:
|
||||
_ = await asyncio.wait_for(
|
||||
wallet.melt(
|
||||
proofs=proofs,
|
||||
invoice=bolt11_invoice,
|
||||
fee_reserve_sat=melt_quote_resp.fee_reserve,
|
||||
quote_id=melt_quote_resp.quote,
|
||||
),
|
||||
timeout=MELT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError as e:
|
||||
raise LNURLError(
|
||||
f"Melt timed out after {MELT_TIMEOUT_SECONDS}s (mint unresponsive)"
|
||||
) from e
|
||||
return final_amount
|
||||
|
||||
@@ -85,6 +85,30 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def litellm_cost_entry(model_id: str) -> dict | None:
|
||||
"""Look up ``model_id`` in litellm's bundled cost map.
|
||||
|
||||
litellm ships per-model USD rates keyed by the exact OpenRouter id
|
||||
(``deepseek/deepseek-chat``) or the bare model name (``gpt-4o``,
|
||||
``claude-sonnet-4-5``), so both spellings are tried. Keys are lowercase, so
|
||||
a mixed-case upstream id (``deepseek-ai/DeepSeek-V4-Flash``) is retried via
|
||||
a case-insensitive scan. Returns the matched cost dict, or ``None``.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
candidates = (model_id, model_id.split("/", 1)[-1])
|
||||
for key in candidates:
|
||||
info = litellm.model_cost.get(key)
|
||||
if isinstance(info, dict):
|
||||
return info
|
||||
|
||||
lowered = {c.lower() for c in candidates}
|
||||
for key, info in litellm.model_cost.items():
|
||||
if isinstance(key, str) and key.lower() in lowered and isinstance(info, dict):
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
||||
"""Fill missing cache rates from litellm's bundled cost map.
|
||||
|
||||
@@ -92,12 +116,8 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
||||
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. litellm keys are
|
||||
lowercase, but a generic upstream may report a mixed-case id
|
||||
(``deepseek-ai/DeepSeek-V4-Flash``); an exact match is attempted first, then
|
||||
a case-insensitive fallback so such ids still resolve.
|
||||
cache writes (1.25x). The lookup (see ``litellm_cost_entry``) tries both
|
||||
id spellings and a case-insensitive fallback.
|
||||
|
||||
Rates already present (e.g. provided by OpenRouter) are authoritative and
|
||||
never overwritten. Unknown models are returned unchanged.
|
||||
@@ -107,28 +127,7 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
||||
if not (needs_read or needs_write):
|
||||
return pricing
|
||||
|
||||
import litellm
|
||||
|
||||
candidates = (model_id, model_id.split("/", 1)[-1])
|
||||
info: dict | None = None
|
||||
for key in candidates:
|
||||
candidate = litellm.model_cost.get(key)
|
||||
if isinstance(candidate, dict):
|
||||
info = candidate
|
||||
break
|
||||
if info is None:
|
||||
# Case-insensitive fallback: a mixed-case upstream id (e.g.
|
||||
# ``deepseek-ai/DeepSeek-V4-Flash``) won't match litellm's lowercase
|
||||
# keys exactly. Build a lowercased index once and retry.
|
||||
lowered = {c.lower() for c in candidates}
|
||||
for key, candidate in litellm.model_cost.items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and key.lower() in lowered
|
||||
and isinstance(candidate, dict)
|
||||
):
|
||||
info = candidate
|
||||
break
|
||||
info = litellm_cost_entry(model_id)
|
||||
if info is None:
|
||||
return pricing
|
||||
|
||||
|
||||
158
routstr/proxy.py
158
routstr/proxy.py
@@ -29,6 +29,7 @@ from .payment.helpers import (
|
||||
)
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.ehbp import forward_ehbp_request, forward_ehbp_x_cashu_request
|
||||
from .upstream.helpers import init_upstreams
|
||||
from .upstream.request_correction import correct_request, extract_error_message
|
||||
|
||||
@@ -104,6 +105,34 @@ def get_unique_models() -> list[Model]:
|
||||
return list(_unique_models.values())
|
||||
|
||||
|
||||
def _is_tinfoil_attestation_path(path: str) -> bool:
|
||||
"""Return True for exact Tinfoil attestation routes, with optional slash."""
|
||||
return path in {
|
||||
"attestation",
|
||||
"attestation/",
|
||||
"tee/attestation",
|
||||
"tee/attestation/",
|
||||
}
|
||||
|
||||
|
||||
def _select_unauthenticated_get_upstreams(
|
||||
path: str, upstreams: list[BaseUpstreamProvider]
|
||||
) -> list[BaseUpstreamProvider]:
|
||||
"""Select upstream candidates for unauthenticated GET bypass paths.
|
||||
|
||||
Tinfoil attestation endpoints are provider-specific. Trying every enabled
|
||||
upstream can return an unrelated provider's 404 before Tinfoil is reached,
|
||||
so route those paths only to Tinfoil providers.
|
||||
"""
|
||||
if _is_tinfoil_attestation_path(path):
|
||||
return [
|
||||
upstream
|
||||
for upstream in upstreams
|
||||
if getattr(upstream, "provider_type", None) == "tinfoil"
|
||||
]
|
||||
return upstreams
|
||||
|
||||
|
||||
async def refresh_model_maps() -> None:
|
||||
"""Refresh global model and provider maps using the cost-based algorithm."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -118,22 +147,23 @@ async def refresh_model_maps() -> None:
|
||||
result = await session.exec(query)
|
||||
provider_rows = result.all()
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
|
||||
disabled_model_ids: set[str] = set()
|
||||
overrides_by_key: dict[tuple[str, int], tuple[ModelRow, float]] = {}
|
||||
disabled_model_keys: set[tuple[str, int]] = set()
|
||||
|
||||
for provider in provider_rows:
|
||||
if not provider.enabled:
|
||||
continue
|
||||
for model in provider.models:
|
||||
model_key = (model.id.lower(), model.upstream_provider_id)
|
||||
if model.enabled:
|
||||
overrides_by_id[model.id] = (model, provider.provider_fee)
|
||||
overrides_by_key[model_key] = (model, provider.provider_fee)
|
||||
else:
|
||||
disabled_model_ids.add(model.id)
|
||||
disabled_model_keys.add(model_key)
|
||||
|
||||
_model_instances, _provider_map, _unique_models = create_model_mappings(
|
||||
upstreams=_upstreams,
|
||||
overrides_by_id=overrides_by_id,
|
||||
disabled_model_ids=disabled_model_ids,
|
||||
overrides_by_key=overrides_by_key,
|
||||
disabled_model_keys=disabled_model_keys,
|
||||
)
|
||||
|
||||
|
||||
@@ -166,6 +196,7 @@ _API_PATH_PREFIXES = (
|
||||
"moderations",
|
||||
"providers",
|
||||
"tee/",
|
||||
"attestation",
|
||||
)
|
||||
|
||||
|
||||
@@ -184,20 +215,54 @@ async def proxy(
|
||||
|
||||
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
|
||||
# /tee/* GET requests (e.g. attestation) don't map to models — just
|
||||
# forward to all enabled upstreams without model/cost/auth lookups.
|
||||
if request.method == "GET" and path.startswith("tee/"):
|
||||
all_upstreams = _upstreams
|
||||
# EHBP (Encrypted HTTP Body Protocol) requests carry an Ehbp-Encapsulated-Key
|
||||
# header and a binary HPKE-sealed body. The proxy cannot parse the body to
|
||||
# extract the model id, so the SDK sends it in X-Routstr-Model. Forward the
|
||||
# raw encrypted body to the upstream's /private/ endpoint and stream the
|
||||
# encrypted response back untouched — the SDK's SecureClient decrypts it.
|
||||
is_ehbp = "ehbp-encapsulated-key" in headers
|
||||
if is_ehbp:
|
||||
request_body_dict = {}
|
||||
model_id = headers.get("x-routstr-model", "")
|
||||
if not model_id:
|
||||
return create_error_response(
|
||||
"invalid_request",
|
||||
"EHBP request missing X-Routstr-Model header",
|
||||
400,
|
||||
request=request,
|
||||
)
|
||||
else:
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
if is_responses_api:
|
||||
model_id = extract_model_from_responses_request(request_body_dict)
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
# Exact Tinfoil attestation GET routes don't map to models — forward
|
||||
# without model/cost/auth lookups. Do not prefix-match here: paths such as
|
||||
# /attestationjunk must continue through normal authentication.
|
||||
if request.method == "GET" and _is_tinfoil_attestation_path(path):
|
||||
selected_upstreams = _select_unauthenticated_get_upstreams(path, _upstreams)
|
||||
if not selected_upstreams:
|
||||
return create_error_response(
|
||||
"upstream_error",
|
||||
"No upstream available for unauthenticated GET path",
|
||||
502,
|
||||
request=request,
|
||||
)
|
||||
|
||||
last_error_response = None
|
||||
for i, upstream in enumerate(all_upstreams):
|
||||
for i, upstream in enumerate(selected_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:
|
||||
if (
|
||||
response.status_code in [502, 429]
|
||||
and i < len(selected_upstreams) - 1
|
||||
):
|
||||
logger.warning(
|
||||
"Upstream %s returned %s for tee GET %s, trying next",
|
||||
"Upstream %s returned %s for unauthenticated GET %s, trying next",
|
||||
upstream.provider_type,
|
||||
response.status_code,
|
||||
path,
|
||||
@@ -206,23 +271,18 @@ async def proxy(
|
||||
return response
|
||||
except UpstreamError as e:
|
||||
logger.warning(
|
||||
"Upstream %s failed for tee GET %s: %s",
|
||||
"Upstream %s failed for unauthenticated GET %s: %s",
|
||||
upstream.provider_type,
|
||||
path,
|
||||
e,
|
||||
)
|
||||
if i == len(all_upstreams) - 1:
|
||||
if i == len(selected_upstreams) - 1:
|
||||
last_error_response = create_upstream_error_response(e, request)
|
||||
continue
|
||||
return last_error_response or create_error_response(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
if is_responses_api:
|
||||
model_id = extract_model_from_responses_request(request_body_dict)
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
|
||||
if not model_obj:
|
||||
@@ -239,6 +299,16 @@ async def proxy(
|
||||
request=request,
|
||||
)
|
||||
|
||||
if is_ehbp:
|
||||
upstreams = [upstream for upstream in upstreams if upstream.supports_ehbp]
|
||||
if not upstreams:
|
||||
return create_error_response(
|
||||
"unsupported_request",
|
||||
f"No EHBP-capable provider found for model '{model_id}'",
|
||||
400,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# todo figure out cost calculation since fallback provider is usually not the same price
|
||||
# Use first provider for initial checks/cost calculation
|
||||
# primary_upstream = upstreams[0]
|
||||
@@ -258,7 +328,23 @@ async def proxy(
|
||||
last_error = None
|
||||
for i, upstream in enumerate(upstreams):
|
||||
try:
|
||||
if is_responses_api:
|
||||
if is_ehbp:
|
||||
if not upstream.supports_ehbp:
|
||||
logger.warning(
|
||||
"Upstream %s does not support EHBP for model=%s",
|
||||
upstream.provider_type,
|
||||
model_id,
|
||||
)
|
||||
continue
|
||||
return await forward_ehbp_x_cashu_request(
|
||||
request=request,
|
||||
x_cashu_token=x_cashu,
|
||||
path=path,
|
||||
max_cost_for_model=max_cost_for_model,
|
||||
model_obj=model_obj,
|
||||
upstream=upstream,
|
||||
)
|
||||
elif is_responses_api:
|
||||
return await upstream.handle_x_cashu_responses(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
)
|
||||
@@ -347,7 +433,7 @@ async def proxy(
|
||||
"upstream_error", "All upstreams failed", 502, request=request
|
||||
)
|
||||
|
||||
if request_body_dict:
|
||||
if is_ehbp or request_body_dict:
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
|
||||
# Tracks request params already removed in response to upstream rejections,
|
||||
@@ -361,7 +447,29 @@ async def proxy(
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if is_responses_api:
|
||||
if is_ehbp:
|
||||
if not upstream.supports_ehbp:
|
||||
logger.warning(
|
||||
"Upstream %s does not support EHBP for model=%s",
|
||||
upstream.provider_type,
|
||||
model_id,
|
||||
)
|
||||
raise UpstreamError(
|
||||
f"Provider {upstream.provider_type} does not support EHBP",
|
||||
status_code=400,
|
||||
)
|
||||
response = await forward_ehbp_request(
|
||||
request=request,
|
||||
path=path,
|
||||
headers=headers,
|
||||
request_body=request_body,
|
||||
upstream=upstream,
|
||||
key=key,
|
||||
max_cost_for_model=max_cost_for_model,
|
||||
session=session,
|
||||
model_obj=model_obj,
|
||||
)
|
||||
elif is_responses_api:
|
||||
response = await upstream.forward_responses_request(
|
||||
request,
|
||||
path,
|
||||
@@ -406,7 +514,7 @@ async def proxy(
|
||||
# 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:
|
||||
if response.status_code == 400 and not is_ehbp:
|
||||
correction = correct_request(
|
||||
request_body,
|
||||
extract_error_message(response),
|
||||
|
||||
@@ -11,6 +11,7 @@ from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
from .tinfoil import TinfoilUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
@@ -26,6 +27,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
RoutstrUpstreamProvider,
|
||||
TinfoilUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
@@ -4,7 +4,12 @@ import json
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import UpstreamProviderRow, create_session
|
||||
from ..core.db import (
|
||||
CashuTransaction,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
from ..wallet import send_token
|
||||
from .routstr import RoutstrUpstreamProvider
|
||||
|
||||
@@ -123,7 +128,6 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
print(amount, mint_url)
|
||||
try:
|
||||
token = await send_token(amount, "sat", mint_url)
|
||||
except Exception as e:
|
||||
@@ -138,6 +142,22 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
stored = await store_cashu_transaction(
|
||||
token=token,
|
||||
amount=amount,
|
||||
unit="sat",
|
||||
mint_url=mint_url,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="auto_topup",
|
||||
)
|
||||
if not stored:
|
||||
logger.critical(
|
||||
"Aborting auto top-up because its cashu token could not be persisted",
|
||||
extra={"provider_id": row.id, "mint_url": mint_url},
|
||||
)
|
||||
return
|
||||
|
||||
result = await provider.topup(token)
|
||||
|
||||
if "error" in result:
|
||||
@@ -149,6 +169,26 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
||||
},
|
||||
)
|
||||
else:
|
||||
async with create_session() as session:
|
||||
transaction = (
|
||||
await session.exec(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.token == token,
|
||||
CashuTransaction.type == "out",
|
||||
CashuTransaction.source == "auto_topup",
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if transaction is None:
|
||||
logger.critical(
|
||||
"Completed auto top-up transaction is missing from the database",
|
||||
extra={"provider_id": row.id, "mint_url": mint_url},
|
||||
)
|
||||
else:
|
||||
transaction.collected = True
|
||||
session.add(transaction)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Auto top-up completed successfully",
|
||||
extra={
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import math
|
||||
import traceback
|
||||
import typing
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
|
||||
from typing import Any, Mapping, Self, cast
|
||||
@@ -13,7 +14,11 @@ from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..auth import (
|
||||
adjust_payment_for_tokens,
|
||||
get_reservation_snapshot,
|
||||
release_reservation,
|
||||
)
|
||||
from ..core import get_logger
|
||||
from ..core.db import (
|
||||
ApiKey,
|
||||
@@ -40,7 +45,12 @@ from ..payment.models import (
|
||||
list_models,
|
||||
)
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..wallet import recieve_token, send_token
|
||||
from ..wallet import (
|
||||
SPENT_TOKEN_CODES,
|
||||
classify_redemption_error,
|
||||
recieve_token,
|
||||
send_token,
|
||||
)
|
||||
from . import messages_dispatch
|
||||
from .cache_breakpoints import (
|
||||
inject_anthropic_cache_breakpoints,
|
||||
@@ -50,6 +60,9 @@ from .count_tokens import count_tokens_locally
|
||||
from .litellm_routing import detect_litellm_prefix
|
||||
from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .ehbp import ConfidentialInferenceProfile, EHBPForwardingTarget
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -987,6 +1000,9 @@ class BaseUpstreamProvider:
|
||||
async with create_session() as session:
|
||||
fresh_key = await session.get(key.__class__, key.hashed_key)
|
||||
if fresh_key:
|
||||
reservation_snapshot = await get_reservation_snapshot(
|
||||
fresh_key, session
|
||||
)
|
||||
cost_data: dict
|
||||
try:
|
||||
adjustment_input = (
|
||||
@@ -1004,25 +1020,40 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model,
|
||||
)
|
||||
usage_finalized = True
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error during usage finalization",
|
||||
except BaseException as e:
|
||||
logger.critical(
|
||||
"Error during usage finalization — CRITICAL",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"error": str(e),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Fall back so we still emit a non-zero sats cost downstream.
|
||||
cost_data = {
|
||||
"base_msats": 0,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": 0,
|
||||
"total_usd": 0.0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
}
|
||||
try:
|
||||
await session.rollback()
|
||||
released = await release_reservation(
|
||||
reservation_snapshot,
|
||||
session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
if not released:
|
||||
logger.critical(
|
||||
"Billing reservation could not be released",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"reserved_balance": fresh_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
except Exception as release_error:
|
||||
logger.critical(
|
||||
"Billing reservation release failed",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"error": str(release_error),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
if usage_chunk_data is None:
|
||||
if not hasattr(self, "_current_stream_id"):
|
||||
@@ -2840,6 +2871,26 @@ class BaseUpstreamProvider:
|
||||
# Don't revert here — proxy.py owns payment revert to avoid double-revert
|
||||
raise UpstreamError("An unexpected server error occurred", status_code=500)
|
||||
|
||||
supports_ehbp: bool = False
|
||||
|
||||
def get_confidential_inference_profile(self) -> "ConfidentialInferenceProfile | None":
|
||||
"""Return provider policy for encrypted/confidential inference forwarding."""
|
||||
return None
|
||||
|
||||
def get_ehbp_forwarding_target(
|
||||
self, path: str, model_obj: Model
|
||||
) -> "EHBPForwardingTarget":
|
||||
"""Return the EHBP forwarding target for this provider.
|
||||
|
||||
Providers must explicitly opt in by setting ``supports_ehbp = True``
|
||||
and overriding this method. Most upstreams do not accept EHBP-encrypted
|
||||
request bodies, so the base provider intentionally does not provide a
|
||||
default endpoint.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support EHBP forwarding"
|
||||
)
|
||||
|
||||
async def forward_responses_request(
|
||||
self,
|
||||
request: Request,
|
||||
@@ -3981,9 +4032,19 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
redeemed = False
|
||||
try:
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
# Reject a zero/negative redemption (empty/dust token, or a value
|
||||
# fully consumed by fees) before marking the token redeemed, so it
|
||||
# classifies as cashu_token_zero_value like the bearer/top-up paths
|
||||
# rather than being forwarded as a free request.
|
||||
if amount <= 0:
|
||||
raise ValueError(
|
||||
f"Redeemed token amount must be positive, got {amount} {unit}"
|
||||
)
|
||||
redeemed = True
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
@@ -4027,40 +4088,37 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
# Use same error handling as regular X-Cashu
|
||||
if "already spent" in error_message.lower():
|
||||
# Post-redemption the token is spent; a forwarding failure must not
|
||||
# be reported as a retryable redemption error (see handle_x_cashu).
|
||||
if redeemed:
|
||||
return create_error_response(
|
||||
"token_already_spent",
|
||||
"The provided CASHU token has already been spent",
|
||||
400,
|
||||
"upstream_error",
|
||||
"Payment succeeded but the upstream request failed",
|
||||
502,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
code="upstream_request_failed",
|
||||
)
|
||||
|
||||
if "invalid token" in error_message.lower():
|
||||
classified = classify_redemption_error(e)
|
||||
if classified is None:
|
||||
return create_error_response(
|
||||
"invalid_token",
|
||||
"The provided CASHU token is invalid",
|
||||
400,
|
||||
"api_error",
|
||||
"Internal error during token redemption",
|
||||
500,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
code="internal_error",
|
||||
)
|
||||
|
||||
if "mint error" in error_message.lower():
|
||||
return create_error_response(
|
||||
"mint_error",
|
||||
f"CASHU mint error: {error_message}",
|
||||
422,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
error_type, status_code, message, error_code = classified
|
||||
# Echo the token back only when it is still spendable, so clients
|
||||
# can recover it; a spent/consumed token is never re-offered.
|
||||
echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token
|
||||
return create_error_response(
|
||||
"cashu_error",
|
||||
f"CASHU token processing failed: {error_message}",
|
||||
400,
|
||||
error_type,
|
||||
message,
|
||||
status_code,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
token=echo_token,
|
||||
code=error_code,
|
||||
)
|
||||
|
||||
async def forward_x_cashu_responses_request(
|
||||
@@ -4650,9 +4708,19 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
redeemed = False
|
||||
try:
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
# Reject a zero/negative redemption (empty/dust token, or a value
|
||||
# fully consumed by fees) before marking the token redeemed, so it
|
||||
# classifies as cashu_token_zero_value like the bearer/top-up paths
|
||||
# rather than being forwarded as a free request.
|
||||
if amount <= 0:
|
||||
raise ValueError(
|
||||
f"Redeemed token amount must be positive, got {amount} {unit}"
|
||||
)
|
||||
redeemed = True
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
@@ -4696,39 +4764,38 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
if "already spent" in error_message.lower():
|
||||
# Once redeemed the token is spent, so a later forwarding failure
|
||||
# must not surface as a retryable mint_unreachable (spent-token retry
|
||||
# bait). Redemption classification only applies while not redeemed.
|
||||
if redeemed:
|
||||
return create_error_response(
|
||||
"token_already_spent",
|
||||
"The provided CASHU token has already been spent",
|
||||
400,
|
||||
"upstream_error",
|
||||
"Payment succeeded but the upstream request failed",
|
||||
502,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
code="upstream_request_failed",
|
||||
)
|
||||
|
||||
if "invalid token" in error_message.lower():
|
||||
classified = classify_redemption_error(e)
|
||||
if classified is None:
|
||||
return create_error_response(
|
||||
"invalid_token",
|
||||
"The provided CASHU token is invalid",
|
||||
400,
|
||||
"api_error",
|
||||
"Internal error during token redemption",
|
||||
500,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
code="internal_error",
|
||||
)
|
||||
|
||||
if "mint error" in error_message.lower():
|
||||
return create_error_response(
|
||||
"mint_error",
|
||||
f"CASHU mint error: {error_message}",
|
||||
422,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
error_type, status_code, message, error_code = classified
|
||||
# Echo the token back only when it is still spendable, so clients
|
||||
# can recover it; a spent/consumed token is never re-offered.
|
||||
echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token
|
||||
return create_error_response(
|
||||
"cashu_error",
|
||||
f"CASHU token processing failed: {error_message}",
|
||||
400,
|
||||
error_type,
|
||||
message,
|
||||
status_code,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
token=echo_token,
|
||||
code=error_code,
|
||||
)
|
||||
|
||||
def _apply_provider_fee_to_model(self, model: Model) -> Model:
|
||||
|
||||
1200
routstr/upstream/ehbp.py
Normal file
1200
routstr/upstream/ehbp.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,12 @@ from typing import TYPE_CHECKING
|
||||
import httpx
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .pricing_resolver import (
|
||||
FallbackPricingResolver,
|
||||
ResolvedPricing,
|
||||
_as_float,
|
||||
estimate_context_length,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
@@ -64,6 +70,40 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def _native_pricing(
|
||||
self, model_id: str, model_spec: dict
|
||||
) -> ResolvedPricing | None:
|
||||
"""Read pricing/metadata from Venice's bespoke ``model_spec`` schema.
|
||||
|
||||
Returns ``None`` when the upstream reported no *usable* native price —
|
||||
absent, non-numeric, negative, or both-zero — so the caller falls
|
||||
through to the shared resolution chain instead of fabricating a number
|
||||
or trusting a bogus one. This mirrors the money-safety guards the
|
||||
litellm and OpenRouter rungs already apply: a both-zero price would
|
||||
serve the model free, a negative one would credit the caller, and a
|
||||
non-numeric string would otherwise throw and drop the whole catalog.
|
||||
"""
|
||||
pricing_info = model_spec.get("pricing", {})
|
||||
input_usd = _as_float(pricing_info.get("input", {}).get("usd"))
|
||||
output_usd = _as_float(pricing_info.get("output", {}).get("usd"))
|
||||
if input_usd is None or output_usd is None:
|
||||
return None
|
||||
if input_usd < 0 or output_usd < 0 or (input_usd == 0 and output_usd == 0):
|
||||
return None
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
if capabilities.get("supportsVision", False):
|
||||
input_modalities.append("image")
|
||||
|
||||
return ResolvedPricing(
|
||||
prompt=input_usd / 1_000_000,
|
||||
completion=output_usd / 1_000_000,
|
||||
context_length=model_spec.get("availableContextTokens"),
|
||||
source="native",
|
||||
input_modalities=input_modalities,
|
||||
)
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from upstream API using /models endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
@@ -78,6 +118,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
resolver = FallbackPricingResolver()
|
||||
models_list = []
|
||||
for model_data in data.get("data", []):
|
||||
model_id = model_data.get("id", "")
|
||||
@@ -89,41 +130,44 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
owned_by = model_data.get("owned_by", "unknown")
|
||||
model_spec = model_data.get("model_spec", {})
|
||||
|
||||
context_length = 4096
|
||||
if model_spec.get("availableContextTokens"):
|
||||
context_length = model_spec["availableContextTokens"]
|
||||
elif any(
|
||||
pattern in model_id.lower() for pattern in ["32k", "32000"]
|
||||
):
|
||||
context_length = 32768
|
||||
elif any(
|
||||
pattern in model_id.lower() for pattern in ["16k", "16000"]
|
||||
):
|
||||
context_length = 16384
|
||||
elif any(pattern in model_id.lower() for pattern in ["8k", "8000"]):
|
||||
context_length = 8192
|
||||
elif "gpt-4" in model_id.lower():
|
||||
context_length = 8192
|
||||
elif "claude" in model_id.lower():
|
||||
context_length = 200000
|
||||
resolved = self._native_pricing(model_id, model_spec)
|
||||
if resolved is None:
|
||||
resolved = await resolver.resolve(model_id)
|
||||
|
||||
pricing_info = model_spec.get("pricing", {})
|
||||
input_pricing = pricing_info.get("input", {})
|
||||
output_pricing = pricing_info.get("output", {})
|
||||
if resolved is None:
|
||||
# Fail closed: never invent a price. Import the model
|
||||
# disabled with a warning so the operator can price it
|
||||
# (the admin UI surfaces disabled remote models).
|
||||
logger.warning(
|
||||
f"No pricing source resolved for '{model_id}' from "
|
||||
f"{self.upstream_name}; importing it disabled",
|
||||
extra={"model_id": model_id, "base_url": self.base_url},
|
||||
)
|
||||
resolved = ResolvedPricing(
|
||||
prompt=0.0,
|
||||
completion=0.0,
|
||||
context_length=None,
|
||||
source="unresolved",
|
||||
)
|
||||
enabled = False
|
||||
else:
|
||||
enabled = True
|
||||
|
||||
prompt_price = input_pricing.get("usd", 0.001) / 1000000
|
||||
completion_price = output_pricing.get("usd", 0.001) / 1000000
|
||||
# Prefer the source's own modality string (OpenRouter ships
|
||||
# one, e.g. "text+image->text"); otherwise derive it from the
|
||||
# captured input/output modalities in the same "in->out" shape
|
||||
# rather than flattening vision models to "text->text".
|
||||
modality = resolved.modality or (
|
||||
f"{'+'.join(resolved.input_modalities)}"
|
||||
f"->{'+'.join(resolved.output_modalities)}"
|
||||
)
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
output_modalities = ["text"]
|
||||
|
||||
if capabilities.get("supportsVision", False):
|
||||
input_modalities.append("image")
|
||||
|
||||
modality = "text"
|
||||
if capabilities.get("supportsVision", False):
|
||||
modality = "text->text"
|
||||
# A source can carry a price but no context (e.g. a litellm
|
||||
# entry missing max_input_tokens); fall back to an id-based
|
||||
# estimate so we never persist a zero-length window.
|
||||
context_length = resolved.context_length or estimate_context_length(
|
||||
model_id
|
||||
)
|
||||
|
||||
spec_name = model_spec.get("name", model_name)
|
||||
description = f"{spec_name}"
|
||||
@@ -139,30 +183,33 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
context_length=context_length,
|
||||
architecture=Architecture(
|
||||
modality=modality,
|
||||
input_modalities=input_modalities,
|
||||
output_modalities=output_modalities,
|
||||
tokenizer="unknown",
|
||||
instruct_type=None,
|
||||
input_modalities=resolved.input_modalities,
|
||||
output_modalities=resolved.output_modalities,
|
||||
tokenizer=resolved.tokenizer,
|
||||
instruct_type=resolved.instruct_type,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=prompt_price,
|
||||
completion=completion_price,
|
||||
prompt=resolved.prompt,
|
||||
completion=resolved.completion,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
max_prompt_cost=0.001,
|
||||
max_completion_cost=0.001,
|
||||
max_cost=0.001,
|
||||
input_cache_read=resolved.input_cache_read,
|
||||
input_cache_write=resolved.input_cache_write,
|
||||
),
|
||||
sats_pricing=None,
|
||||
per_request_limits=None,
|
||||
top_provider=TopProvider(
|
||||
context_length=context_length,
|
||||
max_completion_tokens=context_length // 2,
|
||||
is_moderated=False,
|
||||
max_completion_tokens=(
|
||||
resolved.max_completion_tokens
|
||||
if resolved.max_completion_tokens is not None
|
||||
else context_length // 2
|
||||
),
|
||||
is_moderated=bool(resolved.is_moderated),
|
||||
),
|
||||
enabled=True,
|
||||
enabled=enabled,
|
||||
upstream_provider_id=None,
|
||||
canonical_slug=None,
|
||||
)
|
||||
|
||||
@@ -94,12 +94,10 @@ async def get_all_models_with_overrides(
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
|
||||
row.id: (
|
||||
overrides_by_key: dict[tuple[str, int], tuple[ModelRow, float]] = {
|
||||
(row.id.lower(), row.upstream_provider_id): (
|
||||
row,
|
||||
providers_by_id[row.upstream_provider_id].provider_fee
|
||||
if row.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
providers_by_id[row.upstream_provider_id].provider_fee,
|
||||
)
|
||||
for row in override_rows
|
||||
if row.upstream_provider_id is not None
|
||||
@@ -107,17 +105,28 @@ async def get_all_models_with_overrides(
|
||||
and providers_by_id[row.upstream_provider_id].enabled
|
||||
}
|
||||
|
||||
all_models: dict[str, Model] = {}
|
||||
all_models: dict[tuple[str, str], Model] = {}
|
||||
|
||||
for upstream in upstreams:
|
||||
upstream_db_id = getattr(upstream, "db_id", None)
|
||||
provider_key = (
|
||||
f"db:{upstream_db_id}"
|
||||
if isinstance(upstream_db_id, int)
|
||||
else f"{getattr(upstream, 'provider_type', '')}|{getattr(upstream, 'base_url', '')}"
|
||||
)
|
||||
for model in upstream.get_cached_models():
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
all_models[model.id] = _row_to_model(
|
||||
model_key = (
|
||||
(model.id.lower(), upstream_db_id)
|
||||
if isinstance(upstream_db_id, int)
|
||||
else None
|
||||
)
|
||||
if model_key is not None and model_key in overrides_by_key:
|
||||
override_row, provider_fee = overrides_by_key[model_key]
|
||||
all_models[(model.id.lower(), provider_key)] = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
elif model.enabled:
|
||||
all_models[model.id] = model
|
||||
all_models[(model.id.lower(), provider_key)] = model
|
||||
|
||||
return list(all_models.values())
|
||||
|
||||
@@ -263,6 +272,7 @@ async def _seed_providers_from_settings(
|
||||
("PERPLEXITY_API_KEY", "perplexity", None, None),
|
||||
("FIREWORKS_API_KEY", "fireworks", None, None),
|
||||
("XAI_API_KEY", "xai", None, None),
|
||||
("TINFOIL_API_KEY", "tinfoil", None, None),
|
||||
]
|
||||
|
||||
for env_key, provider_type, _, _ in env_mappings:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
@@ -19,38 +18,6 @@ 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.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic.v1 import BaseModel, Field
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider, TopupData
|
||||
from .ehbp import EHBPForwardingTarget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
@@ -39,6 +40,10 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
default_base_url = "https://api.ppq.ai"
|
||||
platform_url = "https://ppq.ai/api-docs"
|
||||
IGNORED_MODEL_IDS: list[str] = ["auto"]
|
||||
# PPQ.AI has a private encrypted endpoint, but this proxy currently has no
|
||||
# provider-attested usage extractor/model binding for it. Keep EHBP disabled
|
||||
# until a ConfidentialInferenceProfile can bill it without max-cost fallback.
|
||||
supports_ehbp = False
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
@@ -70,6 +75,20 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id
|
||||
|
||||
def get_ehbp_forwarding_target(
|
||||
self, path: str, model_obj: Model
|
||||
) -> EHBPForwardingTarget:
|
||||
"""Return the PPQ.AI private enclave target for EHBP requests.
|
||||
|
||||
PPQ.AI exposes EHBP-aware inference under /private/v1/... separate
|
||||
from the public /v1/... endpoint. The encrypted body remains opaque to
|
||||
Routstr, so PPQ.AI also needs X-Private-Model for routing/billing.
|
||||
"""
|
||||
return EHBPForwardingTarget(
|
||||
url=f"{self.base_url.rstrip('/')}/private/{path.lstrip('/')}",
|
||||
headers={"X-Private-Model": model_obj.forwarded_model_id or model_obj.id},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account without requiring an instance.
|
||||
|
||||
203
routstr/upstream/pricing_resolver.py
Normal file
203
routstr/upstream/pricing_resolver.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Shared price/metadata resolution chain for upstream model discovery.
|
||||
|
||||
Most OpenAI-compatible ``/models`` responses carry no pricing. Rather than let
|
||||
a provider fabricate one, this module resolves a model through decreasingly
|
||||
trustworthy sources — litellm's bundled cost map (curated list prices, mirrors
|
||||
provider docs), then the OpenRouter feed (resale prices, broader coverage) —
|
||||
and returns ``None`` when none of them know the model, so the caller can fail
|
||||
closed instead of inventing a number.
|
||||
|
||||
Provider-native pricing (a gateway's own ``/models`` schema, e.g. Venice's
|
||||
``model_spec``) is authoritative and handled by the provider before this chain
|
||||
is consulted; only the shared fallback lives here so a later refactor can hoist
|
||||
it into the base provider unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedPricing:
|
||||
"""Per-token pricing plus whatever metadata the answering source carried.
|
||||
|
||||
Prices are USD per token. ``source`` records provenance
|
||||
(``native``/``litellm``/``openrouter``/``unresolved``) so later work can
|
||||
surface where each price came from.
|
||||
"""
|
||||
|
||||
prompt: float
|
||||
completion: float
|
||||
context_length: int | None
|
||||
source: str
|
||||
modality: str | None = None
|
||||
max_completion_tokens: int | None = None
|
||||
input_cache_read: float = 0.0
|
||||
input_cache_write: float = 0.0
|
||||
input_modalities: list[str] = field(default_factory=lambda: ["text"])
|
||||
output_modalities: list[str] = field(default_factory=lambda: ["text"])
|
||||
tokenizer: str = "unknown"
|
||||
instruct_type: str | None = None
|
||||
is_moderated: bool | None = None
|
||||
|
||||
|
||||
def estimate_context_length(model_id: str) -> int:
|
||||
"""Best-effort context window from a model id when no source reports one.
|
||||
|
||||
The last rung of the fallback chain, reached only for a model whose price
|
||||
resolved but whose context did not (or that imported disabled). Context is
|
||||
not a billing input, so a rough id-based guess is acceptable here where a
|
||||
guessed *price* never would be.
|
||||
"""
|
||||
lowered = model_id.lower()
|
||||
if any(pattern in lowered for pattern in ["32k", "32000"]):
|
||||
return 32768
|
||||
if any(pattern in lowered for pattern in ["16k", "16000"]):
|
||||
return 16384
|
||||
if any(pattern in lowered for pattern in ["8k", "8000"]):
|
||||
return 8192
|
||||
if "gpt-4" in lowered:
|
||||
return 8192
|
||||
if "claude" in lowered:
|
||||
return 200000
|
||||
return 4096
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
"""OpenRouter reports prices as strings; coerce, ``None`` if unparseable."""
|
||||
try:
|
||||
return float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(value: object) -> int | None:
|
||||
"""Coerce an already-numeric token count to ``int``, else ``None``."""
|
||||
return int(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _from_litellm(model_id: str) -> ResolvedPricing | None:
|
||||
# Lazy import so the resolver stays import-light and shares the exact
|
||||
# lookup semantics used by cache-rate backfill.
|
||||
from ..payment.models import litellm_cost_entry
|
||||
|
||||
info = litellm_cost_entry(model_id)
|
||||
if info is None:
|
||||
return None
|
||||
|
||||
prompt = info.get("input_cost_per_token")
|
||||
completion = info.get("output_cost_per_token")
|
||||
if not isinstance(prompt, (int, float)) or not isinstance(completion, (int, float)):
|
||||
return None
|
||||
# A both-zero entry is litellm listing a model without a real price (free
|
||||
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
|
||||
# the model for free. Reject it (and any negative) so the caller falls
|
||||
# through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
|
||||
if prompt < 0 or completion < 0 or (prompt == 0 and completion == 0):
|
||||
return None
|
||||
|
||||
input_modalities = ["text"]
|
||||
if info.get("supports_vision"):
|
||||
input_modalities.append("image")
|
||||
|
||||
return ResolvedPricing(
|
||||
prompt=float(prompt),
|
||||
completion=float(completion),
|
||||
# max_input_tokens is the context window; max_tokens is litellm's
|
||||
# completion cap (it tracks max_output_tokens for ~94% of models), so
|
||||
# it is never a context source. A missing window falls to the id-based
|
||||
# estimate downstream rather than borrowing the output cap.
|
||||
context_length=_as_int(info.get("max_input_tokens")),
|
||||
source="litellm",
|
||||
max_completion_tokens=_as_int(info.get("max_output_tokens")),
|
||||
input_cache_read=float(info.get("cache_read_input_token_cost") or 0.0),
|
||||
input_cache_write=float(info.get("cache_creation_input_token_cost") or 0.0),
|
||||
input_modalities=input_modalities,
|
||||
)
|
||||
|
||||
|
||||
def _match_openrouter(model_id: str, feed: list[dict]) -> dict | None:
|
||||
"""Find ``model_id`` in the OpenRouter feed, exact id before bare tail.
|
||||
|
||||
Bare-tail matching (``deepseek-chat`` ↔ ``deepseek/deepseek-chat``) is a
|
||||
looser, lower-trust match — OpenRouter fans a model out across resellers —
|
||||
so an exact id match always wins first. When several entries share the bare
|
||||
tail, the one with the highest *combined* (prompt + completion) per-token
|
||||
cost wins: the choice must be deterministic (not feed-order-dependent) and
|
||||
money-safe whichever way traffic leans, since undercharging is the hazard.
|
||||
Ranking on prompt alone could pick an entry that is cheap on input but dear
|
||||
on output. The live feed has no such collisions today; this only governs
|
||||
the latent case.
|
||||
"""
|
||||
bare = model_id.split("/", 1)[-1]
|
||||
exact = next((m for m in feed if m.get("id") == model_id), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
matches = [m for m in feed if m.get("id", "").split("/", 1)[-1] == bare]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
def _combined_cost(m: dict) -> float:
|
||||
pricing = m.get("pricing", {})
|
||||
return (_as_float(pricing.get("prompt")) or 0.0) + (
|
||||
_as_float(pricing.get("completion")) or 0.0
|
||||
)
|
||||
|
||||
return max(matches, key=_combined_cost)
|
||||
|
||||
|
||||
def _from_openrouter(model_id: str, feed: list[dict]) -> ResolvedPricing | None:
|
||||
entry = _match_openrouter(model_id, feed)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
pricing = entry.get("pricing", {})
|
||||
prompt = _as_float(pricing.get("prompt"))
|
||||
completion = _as_float(pricing.get("completion"))
|
||||
if prompt is None or completion is None:
|
||||
return None
|
||||
|
||||
architecture = entry.get("architecture", {})
|
||||
top_provider = entry.get("top_provider", {})
|
||||
|
||||
return ResolvedPricing(
|
||||
prompt=prompt,
|
||||
completion=completion,
|
||||
context_length=_as_int(entry.get("context_length")),
|
||||
source="openrouter",
|
||||
modality=architecture.get("modality"),
|
||||
max_completion_tokens=_as_int(top_provider.get("max_completion_tokens")),
|
||||
input_cache_read=_as_float(pricing.get("input_cache_read")) or 0.0,
|
||||
input_cache_write=_as_float(pricing.get("input_cache_write")) or 0.0,
|
||||
input_modalities=architecture.get("input_modalities") or ["text"],
|
||||
output_modalities=architecture.get("output_modalities") or ["text"],
|
||||
tokenizer=architecture.get("tokenizer") or "unknown",
|
||||
instruct_type=architecture.get("instruct_type"),
|
||||
is_moderated=top_provider.get("is_moderated"),
|
||||
)
|
||||
|
||||
|
||||
class FallbackPricingResolver:
|
||||
"""Resolves models via litellm → OpenRouter for one discovery pass.
|
||||
|
||||
The OpenRouter catalog is fetched at most once and only when a model
|
||||
actually misses litellm, so a provider full of litellm-known models never
|
||||
touches the network. Instantiate one per ``fetch_models`` call.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._openrouter_feed: list[dict] | None = None
|
||||
|
||||
async def resolve(self, model_id: str) -> ResolvedPricing | None:
|
||||
"""Resolve ``model_id``; ``None`` if no source knows it."""
|
||||
resolved = _from_litellm(model_id)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
|
||||
if self._openrouter_feed is None:
|
||||
# Lazy import so tests can patch the feed at its source.
|
||||
from ..payment.models import async_fetch_openrouter_models
|
||||
|
||||
self._openrouter_feed = await async_fetch_openrouter_models()
|
||||
return _from_openrouter(model_id, self._openrouter_feed)
|
||||
236
routstr/upstream/tinfoil.py
Normal file
236
routstr/upstream/tinfoil.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core.exceptions import UpstreamError
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing
|
||||
from .base import BaseUpstreamProvider
|
||||
from .ehbp import (
|
||||
_ENCLAVE_URL_HEADER,
|
||||
_PROXY_ONLY_HEADERS,
|
||||
_RESPONSE_USAGE_HEADER,
|
||||
ConfidentialInferenceProfile,
|
||||
EHBPForwardingTarget,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TinfoilModelPricing(BaseModel):
|
||||
inputTokenPricePer1M: float = 0.0
|
||||
outputTokenPricePer1M: float = 0.0
|
||||
requestPrice: float = 0.0
|
||||
|
||||
|
||||
class TinfoilModel(BaseModel):
|
||||
id: str
|
||||
context_window: int = 0
|
||||
created: int = 0
|
||||
multimodal: bool = False
|
||||
reasoning: bool = False
|
||||
tool_calling: bool = False
|
||||
type: str = "chat"
|
||||
pricing: TinfoilModelPricing = TinfoilModelPricing()
|
||||
endpoints: list[str] = []
|
||||
|
||||
|
||||
class TinfoilUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Direct upstream provider for the Tinfoil inference API.
|
||||
|
||||
Tinfoil hosts open-source models inside attested secure enclaves and exposes
|
||||
an OpenAI-compatible API at ``https://inference.tinfoil.sh``. Request and
|
||||
response bodies are encrypted end-to-end with EHBP (HPKE), so Routstr acts
|
||||
as a blind relay: it forwards the opaque encrypted body, never sees
|
||||
plaintext, and bills from the ``X-Tinfoil-Usage-Metrics`` header that
|
||||
Tinfoil returns outside the encrypted body when
|
||||
``X-Tinfoil-Request-Usage-Metrics: true`` is set.
|
||||
"""
|
||||
|
||||
provider_type = "tinfoil"
|
||||
default_base_url = "https://inference.tinfoil.sh"
|
||||
platform_url = "https://docs.tinfoil.sh"
|
||||
supports_ehbp = True
|
||||
confidential_inference_profile = ConfidentialInferenceProfile(
|
||||
usage_response_header=_RESPONSE_USAGE_HEADER,
|
||||
client_target_url_header=_ENCLAVE_URL_HEADER,
|
||||
allow_client_target_override=True,
|
||||
proxy_only_headers=_PROXY_ONLY_HEADERS,
|
||||
)
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_from_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "TinfoilUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Tinfoil",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": False,
|
||||
"can_topup": False,
|
||||
"can_show_balance": False,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("tinfoil/")
|
||||
|
||||
def get_confidential_inference_profile(self) -> ConfidentialInferenceProfile:
|
||||
return self.confidential_inference_profile
|
||||
|
||||
async def forward_get_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle Tinfoil-specific GET endpoints.
|
||||
|
||||
* ``/attestation`` (or ``/tee/attestation``): proxy to the Tinfoil ATC
|
||||
(attestation bundle proxy) at ``https://atc.tinfoil.sh/attestation``.
|
||||
* Other GETs: forward to the provider base URL
|
||||
(``https://inference.tinfoil.sh``). ``X-Tinfoil-Enclave-Url`` is an
|
||||
EHBP-only header used for encrypted POST requests and is not honored
|
||||
for unencrypted GET requests.
|
||||
"""
|
||||
clean_path = path.removeprefix("tee/").rstrip("/")
|
||||
if clean_path == "attestation":
|
||||
return await self._proxy_attestation(headers)
|
||||
return await super().forward_get_request(request, path, headers)
|
||||
|
||||
async def _proxy_attestation(self, headers: dict) -> Response:
|
||||
url = "https://atc.tinfoil.sh/attestation"
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.AsyncHTTPTransport(retries=1),
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"Accept": headers.get("accept", "application/json"),
|
||||
},
|
||||
)
|
||||
response_headers = dict(resp.headers)
|
||||
response_headers.pop("content-encoding", None)
|
||||
response_headers.pop("content-length", None)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise UpstreamError(
|
||||
f"Error fetching Tinfoil attestation: {type(exc).__name__}",
|
||||
status_code=502,
|
||||
) from exc
|
||||
|
||||
def get_ehbp_forwarding_target(
|
||||
self, path: str, model_obj: Model
|
||||
) -> EHBPForwardingTarget:
|
||||
"""Return the Tinfoil enclave target for EHBP requests.
|
||||
|
||||
Requests usage metrics from the enclave so Routstr can bill exactly
|
||||
without decrypting the response body. The actual forwarding URL is
|
||||
overridden at dispatch time by ``X-Tinfoil-Enclave-Url`` when the SDK
|
||||
sends it (see ``routstr/upstream/ehbp.py``).
|
||||
"""
|
||||
return EHBPForwardingTarget(
|
||||
url=f"{self.base_url.rstrip('/')}/{path.lstrip('/')}",
|
||||
headers={"X-Tinfoil-Request-Usage-Metrics": "true"},
|
||||
profile=self.confidential_inference_profile,
|
||||
)
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from the public Tinfoil models endpoint.
|
||||
|
||||
``GET /v1/models`` is unauthenticated and returns all available models
|
||||
with their pricing in USD per 1M tokens.
|
||||
"""
|
||||
url = f"{self.base_url}/v1/models"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models_data = data.get("data", [])
|
||||
|
||||
models: list[Model] = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
tf = TinfoilModel.parse_obj(model_data)
|
||||
input_price = tf.pricing.inputTokenPricePer1M
|
||||
output_price = tf.pricing.outputTokenPricePer1M
|
||||
request_price = tf.pricing.requestPrice
|
||||
|
||||
modality = "text->text"
|
||||
input_modalities = ["text"]
|
||||
output_modalities = ["text"]
|
||||
if tf.multimodal:
|
||||
modality = "text->text+image"
|
||||
input_modalities = ["text", "image"]
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=tf.id,
|
||||
name=tf.id,
|
||||
created=tf.created,
|
||||
description=f"Tinfoil {tf.type} model",
|
||||
context_length=tf.context_window,
|
||||
architecture=Architecture(
|
||||
modality=modality,
|
||||
input_modalities=input_modalities,
|
||||
output_modalities=output_modalities,
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=input_price / 1_000_000,
|
||||
completion=output_price / 1_000_000,
|
||||
request=request_price,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse Tinfoil model",
|
||||
extra={
|
||||
"model_id": model_data.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return models
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from Tinfoil",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
189
routstr/upstream/tinfoil_trailer.py
Normal file
189
routstr/upstream/tinfoil_trailer.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""h11-based HTTP client for EHBP requests that captures HTTP trailers.
|
||||
|
||||
httpx/httpcore silently discard HTTP trailers during chunked transfer
|
||||
decoding. Tinfoil returns ``X-Tinfoil-Usage-Metrics`` as a trailer on
|
||||
streaming responses, so we need a lower-level HTTP client that preserves
|
||||
trailers from the h11 ``EndOfMessage`` event.
|
||||
|
||||
Because EHBP response bodies are opaque encrypted blobs, buffering the full
|
||||
response is acceptable — the client decrypts the complete body regardless of
|
||||
whether it arrived streamed or buffered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import h11
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_READ_BUFSIZE = 65536
|
||||
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
_DEFAULT_CLOSE_TIMEOUT_SECONDS = 1.0
|
||||
_DEFAULT_MAX_RESPONSE_BYTES = 25 * 1024 * 1024
|
||||
_HOP_BY_HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrailerResponse:
|
||||
"""Buffered HTTP response with optional trailer headers."""
|
||||
|
||||
status_code: int
|
||||
headers: list[tuple[str, str]]
|
||||
body: bytes
|
||||
trailers: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_header(headers: list[tuple[str, str]], name: str) -> str | None:
|
||||
name_lower = name.lower()
|
||||
for k, v in headers:
|
||||
if k.lower() == name_lower:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _strip_hop_by_hop_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Remove connection-specific headers before serializing a new request."""
|
||||
connection_tokens: set[str] = set()
|
||||
for key, value in headers.items():
|
||||
if key.lower() == "connection":
|
||||
connection_tokens.update(
|
||||
token.strip().lower() for token in value.split(",") if token.strip()
|
||||
)
|
||||
|
||||
excluded = _HOP_BY_HOP_HEADERS | connection_tokens
|
||||
return {key: value for key, value in headers.items() if key.lower() not in excluded}
|
||||
|
||||
|
||||
async def forward_with_trailer(
|
||||
*,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: bytes,
|
||||
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
max_response_bytes: int = _DEFAULT_MAX_RESPONSE_BYTES,
|
||||
close_timeout_seconds: float = _DEFAULT_CLOSE_TIMEOUT_SECONDS,
|
||||
) -> TrailerResponse:
|
||||
"""Send an HTTP/1.1 request via h11 and capture HTTP trailers.
|
||||
|
||||
Returns a :class:`TrailerResponse` with the full buffered body and any
|
||||
trailer headers from the ``EndOfMessage`` event.
|
||||
"""
|
||||
parsed = urlsplit(url)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ValueError(f"Invalid URL (no hostname): {url}")
|
||||
port = parsed.port or 443
|
||||
path = parsed.path or "/"
|
||||
if parsed.query:
|
||||
path = f"{path}?{parsed.query}"
|
||||
|
||||
# FastAPI has already decoded the incoming request body. Do not carry the
|
||||
# original connection's framing or other hop-by-hop metadata into the new
|
||||
# upstream connection.
|
||||
headers = _strip_hop_by_hop_headers(headers)
|
||||
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port, ssl=ssl_ctx),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
try:
|
||||
# Build HTTP/1.1 request
|
||||
header_lines = [f"{method} {path} HTTP/1.1"]
|
||||
has_host = any(k.lower() == "host" for k in headers)
|
||||
if not has_host:
|
||||
header_lines.append(f"Host: {host}")
|
||||
header_lines.append("Connection: close")
|
||||
|
||||
for key, value in headers.items():
|
||||
if key.lower() == "host":
|
||||
continue
|
||||
header_lines.append(f"{key}: {value}")
|
||||
|
||||
if body and not any(k.lower() == "content-length" for k in headers):
|
||||
header_lines.append(f"Content-Length: {len(body)}")
|
||||
|
||||
request_data = "\r\n".join(header_lines).encode() + b"\r\n\r\n"
|
||||
if body:
|
||||
request_data += body
|
||||
|
||||
writer.write(request_data)
|
||||
await asyncio.wait_for(writer.drain(), timeout=timeout_seconds)
|
||||
|
||||
# Parse response with h11
|
||||
conn = h11.Connection(h11.CLIENT)
|
||||
status_code = 0
|
||||
resp_headers: list[tuple[str, str]] = []
|
||||
body_chunks: list[bytes] = []
|
||||
body_size = 0
|
||||
trailers: list[tuple[str, str]] = []
|
||||
|
||||
while True:
|
||||
event = conn.next_event()
|
||||
|
||||
if event is h11.NEED_DATA:
|
||||
data = await asyncio.wait_for(
|
||||
reader.read(_READ_BUFSIZE),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
conn.receive_data(data if data else b"")
|
||||
continue
|
||||
|
||||
if isinstance(event, h11.Response):
|
||||
status_code = event.status_code
|
||||
resp_headers = [(k.decode(), v.decode()) for k, v in event.headers]
|
||||
|
||||
elif isinstance(event, h11.Data):
|
||||
body_size += len(event.data)
|
||||
if body_size > max_response_bytes:
|
||||
raise ValueError(
|
||||
f"EHBP response exceeded {max_response_bytes} bytes"
|
||||
)
|
||||
body_chunks.append(event.data)
|
||||
|
||||
elif isinstance(event, h11.EndOfMessage):
|
||||
for k, v in event.headers:
|
||||
trailers.append((k.decode(), v.decode()))
|
||||
break
|
||||
|
||||
elif isinstance(event, h11.PAUSED):
|
||||
# Shouldn't happen for simple request/response, but break safely
|
||||
logger.warning("h11 PAUSED event during EHBP response parsing")
|
||||
break
|
||||
|
||||
elif isinstance(event, h11.ConnectionClosed):
|
||||
break
|
||||
|
||||
return TrailerResponse(
|
||||
status_code=status_code,
|
||||
headers=resp_headers,
|
||||
body=b"".join(body_chunks),
|
||||
trailers=trailers,
|
||||
)
|
||||
finally:
|
||||
writer.close()
|
||||
if close_timeout_seconds > 0:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
writer.wait_closed(), timeout=close_timeout_seconds
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import typing
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
from cashu.core.base import Proof, Token
|
||||
from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
@@ -31,6 +33,149 @@ _CashuMintInfo.model_rebuild(force=True)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MintConnectionError(Exception):
|
||||
"""The mint could not be reached (network transport failure).
|
||||
|
||||
Maps to a 503, not a 4xx: the token is fine, the mint is just unavailable.
|
||||
"""
|
||||
|
||||
|
||||
class TokenConsumedError(Exception):
|
||||
"""A failure that happened AFTER the token's proofs were spent (melt
|
||||
succeeded, or redemption already returned) — e.g. minting on the primary
|
||||
mint or the DB credit then failed.
|
||||
|
||||
Non-retryable: the same token will not work again. Seals the cause chain so
|
||||
a transport error underneath is never re-surfaced as a retryable
|
||||
mint_unreachable.
|
||||
"""
|
||||
|
||||
|
||||
# httpx base classes cover their subclasses. HTTPStatusError is excluded on
|
||||
# purpose — that means the mint answered, just with an error status.
|
||||
_TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = (
|
||||
httpx.NetworkError,
|
||||
httpx.TimeoutException,
|
||||
ConnectionError, # refused/reset/aborted
|
||||
socket.gaierror, # DNS failure
|
||||
asyncio.TimeoutError,
|
||||
)
|
||||
|
||||
|
||||
def is_mint_connection_error(error: BaseException) -> bool:
|
||||
"""True if ``error`` (or anything in its cause/context chain) is a mint
|
||||
transport failure. Walks the chain because some sites re-raise transport
|
||||
errors wrapped in ValueError/MintConnectionError; matches on TYPE, not text.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = error
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
if isinstance(current, TokenConsumedError):
|
||||
# Sealed: the token was already spent, so whatever transport error
|
||||
# sits underneath must not make this look retryable.
|
||||
return False
|
||||
if isinstance(current, MintConnectionError):
|
||||
return True
|
||||
if isinstance(current, _TRANSPORT_EXC_TYPES):
|
||||
return True
|
||||
current = current.__cause__ or current.__context__
|
||||
return False
|
||||
|
||||
|
||||
# Redemption ``code`` values whose token is spent/consumed/unusable — the
|
||||
# X-Cashu path must NOT echo the original token for these (echoing invites a
|
||||
# retry with a token that can never succeed again).
|
||||
SPENT_TOKEN_CODES: frozenset[str] = frozenset(
|
||||
{
|
||||
"cashu_token_already_spent",
|
||||
"cashu_token_consumed",
|
||||
"cashu_token_zero_value",
|
||||
"internal_error",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def classify_redemption_error(
|
||||
error: Exception,
|
||||
) -> tuple[str, int, str, str] | None:
|
||||
"""Map a token-redemption failure to ``(type, status, message, code)``.
|
||||
|
||||
Single source of truth for every endpoint that redeems a token (bearer,
|
||||
X-Cashu, top-up) so the same failure yields the same taxonomy everywhere.
|
||||
``type`` and ``code`` are stable client contract; ``message`` is sanitized
|
||||
(raw error text stays in logs). Returns None for an unclassified internal
|
||||
fault — the caller emits a generic 500.
|
||||
"""
|
||||
if isinstance(error, TokenConsumedError):
|
||||
return (
|
||||
"token_consumed",
|
||||
500,
|
||||
"Token was redeemed but could not be credited; do not retry",
|
||||
"cashu_token_consumed",
|
||||
)
|
||||
if is_mint_connection_error(error):
|
||||
return (
|
||||
"mint_unreachable",
|
||||
503,
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
)
|
||||
lowered = str(error).lower()
|
||||
if "already spent" in lowered:
|
||||
return (
|
||||
"token_already_spent",
|
||||
400,
|
||||
"Cashu token already spent",
|
||||
"cashu_token_already_spent",
|
||||
)
|
||||
if (
|
||||
"insufficient" in lowered
|
||||
or "melt fee" in lowered
|
||||
or "exceed token amount" in lowered
|
||||
or "estimate fees" in lowered
|
||||
):
|
||||
return (
|
||||
"mint_error",
|
||||
422,
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
)
|
||||
if "failed to melt" in lowered:
|
||||
return (
|
||||
"mint_error",
|
||||
422,
|
||||
"Failed to swap token from foreign mint",
|
||||
"cashu_foreign_mint_swap_failed",
|
||||
)
|
||||
if ("invalid" in lowered or "decode" in lowered) and "token" in lowered:
|
||||
# Anchored to "token" so internal faults whose text merely contains
|
||||
# "invalid"/"decode" fall through to the 500 branch, not a token error.
|
||||
return (
|
||||
"invalid_token",
|
||||
400,
|
||||
"Invalid Cashu token",
|
||||
"invalid_cashu_token",
|
||||
)
|
||||
if "must be positive" in lowered or "yielded no value" in lowered:
|
||||
# Redeemed to <= 0 (empty/dust token, or value fully consumed by fees).
|
||||
# Consumed, so non-retryable, but its own code — not the generic bucket.
|
||||
return (
|
||||
"cashu_error",
|
||||
400,
|
||||
"Failed to redeem Cashu token: token yielded no value",
|
||||
"cashu_token_zero_value",
|
||||
)
|
||||
if isinstance(error, ValueError):
|
||||
return (
|
||||
"cashu_error",
|
||||
400,
|
||||
"Failed to redeem Cashu token",
|
||||
"cashu_token_redemption_failed",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def get_balance(unit: str) -> int:
|
||||
wallet = await get_wallet(settings.primary_mint, unit)
|
||||
return wallet.available_balance.amount
|
||||
@@ -258,6 +403,8 @@ async def _calculate_swap_amount(
|
||||
"swap_to_primary_mint: fee estimation failed",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
if is_mint_connection_error(e):
|
||||
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||
raise ValueError(f"Failed to estimate fees: {e}") from e
|
||||
|
||||
|
||||
@@ -383,6 +530,13 @@ async def swap_to_primary_mint(
|
||||
quote_id=melt_quote.quote,
|
||||
)
|
||||
except Exception as e:
|
||||
# A down mint won't fix itself by retrying with a smaller amount.
|
||||
if is_mint_connection_error(e):
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed — mint unreachable",
|
||||
extra={"error": str(e), "foreign_mint": token_obj.mint},
|
||||
)
|
||||
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||
shortfall = _melt_insufficient_shortfall(e)
|
||||
recomputed = 0
|
||||
if shortfall is not None:
|
||||
@@ -458,21 +612,21 @@ async def swap_to_primary_mint(
|
||||
# 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(
|
||||
raise TokenConsumedError(
|
||||
f"Swap recovery failed: mint signed outputs but proofs are "
|
||||
f"unrecoverable (mint reports them spent). "
|
||||
f"Expected {minted_amount}, recovered {balance_gained}. "
|
||||
f"Local wallet DB ('.wallet/') state is corrupted — "
|
||||
f"the counter for keyset is stuck at a bad index range."
|
||||
)
|
||||
except ValueError:
|
||||
except TokenConsumedError:
|
||||
raise
|
||||
except Exception as recovery_err:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: recovery failed",
|
||||
extra={"error": str(recovery_err)},
|
||||
)
|
||||
raise ValueError(
|
||||
raise TokenConsumedError(
|
||||
f"Mint on primary failed and recovery unsuccessful: {e}"
|
||||
) from e
|
||||
else:
|
||||
@@ -485,7 +639,10 @@ async def swap_to_primary_mint(
|
||||
"mint_quote_id": mint_quote.quote,
|
||||
},
|
||||
)
|
||||
raise
|
||||
# Foreign proofs already melted (spent) — non-retryable.
|
||||
raise TokenConsumedError(
|
||||
"Mint on primary failed after successful melt"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: completed successfully",
|
||||
@@ -543,19 +700,33 @@ async def credit_balance(
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
)
|
||||
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
# If pruning removed this key after redemption, do not commit a no-op
|
||||
# balance update and pretend the top-up succeeded.
|
||||
if (getattr(result, "rowcount", 0) or 0) == 0:
|
||||
raise ValueError("API key disappeared before credit could be recorded")
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
# The token is already redeemed (spent) here, so any crediting failure
|
||||
# is post-redemption and non-retryable — surface it as TokenConsumedError
|
||||
# (a key that vanished mid-flight, or an unexpected DB fault), never a
|
||||
# retryable/token-error taxonomy.
|
||||
try:
|
||||
# Atomic UPDATE to prevent race conditions during concurrent topups.
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
# If pruning removed this key after redemption, do not commit a no-op
|
||||
# balance update and pretend the top-up succeeded.
|
||||
if (getattr(result, "rowcount", 0) or 0) == 0:
|
||||
raise TokenConsumedError(
|
||||
"Token redeemed but the API key disappeared before the "
|
||||
"credit could be recorded"
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
except TokenConsumedError:
|
||||
raise
|
||||
except Exception as db_error:
|
||||
raise TokenConsumedError(
|
||||
"Token redeemed but crediting the balance failed"
|
||||
) from db_error
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Balance updated successfully",
|
||||
@@ -748,104 +919,132 @@ async def fetch_all_balances(
|
||||
async def periodic_payout() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(settings.payout_interval_seconds)
|
||||
print(settings.payout_interval_seconds)
|
||||
if not settings.receive_ln_address:
|
||||
continue
|
||||
try:
|
||||
if not settings.receive_ln_address:
|
||||
continue
|
||||
|
||||
# Include the primary mint even if it is not listed in cashu_mints,
|
||||
# matching fetch_all_balances(); otherwise primary-mint funds never
|
||||
# auto-payout.
|
||||
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)
|
||||
|
||||
async with db.create_session() as session:
|
||||
for mint_url in settings.cashu_mints:
|
||||
for mint_url in mint_urls:
|
||||
for unit in ["sat", "msat"]:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
await asyncio.sleep(5)
|
||||
user_balance = await db.balances_for_mint_and_unit(
|
||||
session, mint_url, unit
|
||||
)
|
||||
if unit == "sat":
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
available_balance = proofs_balance - user_balance
|
||||
# Threshold is configured in sats; convert for msat wallets.
|
||||
min_amount = (
|
||||
settings.min_payout_sat
|
||||
if unit == "sat"
|
||||
else settings.min_payout_sat * 1000
|
||||
)
|
||||
if available_balance > min_amount:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet,
|
||||
proofs,
|
||||
settings.receive_ln_address,
|
||||
unit,
|
||||
amount=available_balance,
|
||||
# Isolate failures per mint/unit so one slow or failing
|
||||
# mint does not abort payout for every other mint/unit.
|
||||
try:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
logger.info(
|
||||
"Payout sent successfully",
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
await asyncio.sleep(5)
|
||||
user_balance = await db.balances_for_mint_and_unit(
|
||||
session, mint_url, unit
|
||||
)
|
||||
if unit == "sat":
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
available_balance = proofs_balance - user_balance
|
||||
# Threshold is configured in sats; convert for msat wallets.
|
||||
min_amount = (
|
||||
settings.min_payout_sat
|
||||
if unit == "sat"
|
||||
else settings.min_payout_sat * 1000
|
||||
)
|
||||
if available_balance > min_amount:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet,
|
||||
proofs,
|
||||
settings.receive_ln_address,
|
||||
unit,
|
||||
amount=available_balance,
|
||||
)
|
||||
logger.info(
|
||||
"Payout sent successfully",
|
||||
extra={
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"balance": available_balance,
|
||||
"amount_received": amount_received,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error sending payout: {type(e).__name__}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"balance": available_balance,
|
||||
"amount_received": amount_received,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error sending payout: {type(e).__name__}",
|
||||
f"Error in periodic payout cycle: {type(e).__name__}",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
async def _refund_sweep_once(cutoff: int) -> None:
|
||||
async with db.create_session() as session:
|
||||
stmt = select(db.CashuTransaction).where(
|
||||
db.CashuTransaction.type == "out",
|
||||
db.CashuTransaction.collected == False, # noqa: E712
|
||||
db.CashuTransaction.swept == False, # noqa: E712
|
||||
db.CashuTransaction.created_at < cutoff,
|
||||
)
|
||||
results = await session.exec(stmt)
|
||||
refunds = results.all()
|
||||
|
||||
for refund in refunds:
|
||||
try:
|
||||
await recieve_token(refund.token)
|
||||
refund.swept = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Swept uncollected refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"amount": refund.amount,
|
||||
"unit": refund.unit,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already spent" in error_msg:
|
||||
refund.collected = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Refund already spent (client collected), marking swept",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to sweep refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def refund_sweep_once() -> None:
|
||||
"""Sweep eligible uncollected refund tokens once."""
|
||||
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
|
||||
await _refund_sweep_once(cutoff)
|
||||
|
||||
|
||||
async def periodic_refund_sweep() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60 * 60) # every hour
|
||||
try:
|
||||
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
|
||||
async with db.create_session() as session:
|
||||
stmt = select(db.CashuTransaction).where(
|
||||
db.CashuTransaction.type == "out",
|
||||
db.CashuTransaction.collected == False, # noqa: E712
|
||||
db.CashuTransaction.swept == False, # noqa: E712
|
||||
db.CashuTransaction.created_at < cutoff,
|
||||
)
|
||||
results = await session.exec(stmt)
|
||||
refunds = results.all()
|
||||
|
||||
for refund in refunds:
|
||||
try:
|
||||
await recieve_token(refund.token)
|
||||
refund.swept = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Swept uncollected refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"amount": refund.amount,
|
||||
"unit": refund.unit,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already spent" in error_msg:
|
||||
refund.collected = True
|
||||
session.add(refund)
|
||||
logger.info(
|
||||
"Refund already spent (client collected), marking swept",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to sweep refund",
|
||||
extra={
|
||||
"id": refund.id,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await refund_sweep_once()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in periodic refund sweep",
|
||||
@@ -868,17 +1067,56 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
fee = await db.get_routstr_fee(session)
|
||||
if fee.payout_in_progress_msats:
|
||||
logger.critical(
|
||||
"Routstr fee payout requires manual reconciliation",
|
||||
extra={
|
||||
"payout_in_progress_msats": fee.payout_in_progress_msats,
|
||||
"payout_started_at": fee.payout_started_at,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
accumulated_sats = fee.accumulated_msats // 1000
|
||||
if accumulated_sats >= ROUTSTR_FEE_DEFAULT_PAYOUT:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, settings.primary_mint, "sat", not_reserved=True
|
||||
)
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet, proofs, ROUTSTR_LN_ADDRESS, "sat", amount=accumulated_sats
|
||||
)
|
||||
paid_msats = accumulated_sats * 1000
|
||||
await db.reset_routstr_fee(session, paid_msats)
|
||||
payout_checkpointed = await db.reset_routstr_fee(
|
||||
session, paid_msats
|
||||
)
|
||||
if not payout_checkpointed:
|
||||
logger.warning("Routstr fee payout was already claimed")
|
||||
continue
|
||||
|
||||
try:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet,
|
||||
proofs,
|
||||
ROUTSTR_LN_ADDRESS,
|
||||
"sat",
|
||||
amount=accumulated_sats,
|
||||
)
|
||||
except Exception:
|
||||
logger.critical(
|
||||
"Routstr fee payout outcome is unknown; manual reconciliation required",
|
||||
extra={"payout_in_progress_msats": paid_msats},
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
payout_completed = await db.complete_routstr_fee_payout(
|
||||
session, paid_msats
|
||||
)
|
||||
if not payout_completed:
|
||||
logger.critical(
|
||||
"Routstr fee payout sent but checkpoint was not completed",
|
||||
extra={"payout_in_progress_msats": paid_msats},
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Routstr fee payout sent",
|
||||
extra={
|
||||
|
||||
@@ -174,12 +174,12 @@ async def test_topup_retries_when_quote_fee_exceeds_estimate(
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_returns_400_when_retries_exhausted(
|
||||
async def test_topup_returns_422_when_retries_exhausted(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50)
|
||||
exhausts the retry budget: clean 400 with an actionable message, melt never
|
||||
executed."""
|
||||
exhausts the retry budget: clean 422 mint_error/too-small taxonomy, melt
|
||||
never executed."""
|
||||
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
|
||||
1000, fee_reserves=[1, 10, 25, 50]
|
||||
)
|
||||
@@ -188,7 +188,7 @@ async def test_topup_returns_400_when_retries_exhausted(
|
||||
authenticated_client, mock_token, token_wallet, primary_wallet
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == 422
|
||||
assert "too small to cover swap fees" in response.json()["detail"]
|
||||
assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
|
||||
token_wallet.melt.assert_not_called()
|
||||
|
||||
@@ -12,10 +12,7 @@ from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ResponseValidator,
|
||||
)
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -79,29 +76,31 @@ async def test_api_key_generation_invalid_token(
|
||||
# Capture initial state
|
||||
await db_snapshot.capture()
|
||||
|
||||
# Test various invalid tokens
|
||||
invalid_tokens = [
|
||||
CashuTokenGenerator.generate_invalid_token(), # Malformed token
|
||||
"not-a-cashu-token", # Wrong format
|
||||
"cashuA", # Empty token
|
||||
"cashuA" + "x" * 1000, # Invalid base64
|
||||
# Non-Cashu bearer values are invalid API keys (401). Malformed values that
|
||||
# look like Cashu tokens use the shared Cashu taxonomy (400 invalid_token).
|
||||
invalid_tokens: list[tuple[str, int, str | None]] = [
|
||||
("not-a-cashu-token", 401, None),
|
||||
("sk-not-a-real-api-key", 401, None),
|
||||
("cashuA", 400, "invalid_cashu_token"),
|
||||
("cashuA" + "x" * 1000, 400, "invalid_cashu_token"),
|
||||
]
|
||||
|
||||
for invalid_token in invalid_tokens:
|
||||
for invalid_token, expected_status, expected_code in invalid_tokens:
|
||||
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
||||
response = await integration_client.get("/v1/wallet/info")
|
||||
|
||||
# Should fail with 401
|
||||
assert response.status_code == 401, (
|
||||
assert response.status_code == expected_status, (
|
||||
f"Token {invalid_token[:20]}... should be invalid"
|
||||
)
|
||||
|
||||
# Validate error response
|
||||
validator = ResponseValidator()
|
||||
error_validation = validator.validate_error_response(
|
||||
response, expected_status=401, expected_error_key="detail"
|
||||
response, expected_status=expected_status, expected_error_key="detail"
|
||||
)
|
||||
assert error_validation["valid"]
|
||||
if expected_code is not None:
|
||||
assert response.json()["detail"]["error"]["code"] == expected_code
|
||||
|
||||
# Verify no database changes
|
||||
diff = await db_snapshot.diff()
|
||||
|
||||
@@ -14,6 +14,7 @@ from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
from routstr.wallet import MintConnectionError
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -503,26 +504,19 @@ async def test_mint_unavailability_handling(
|
||||
|
||||
# The global mock in conftest.py is already in place,
|
||||
# so we need to temporarily modify it
|
||||
from unittest.mock import patch
|
||||
raw_error = "Mint unavailable: Connection refused"
|
||||
|
||||
# Make the send_token method raise an exception
|
||||
# Make the send_token method raise a typed mint connection exception.
|
||||
with patch(
|
||||
"routstr.balance.send_token",
|
||||
side_effect=Exception("Mint unavailable: Connection refused"),
|
||||
side_effect=MintConnectionError(raw_error),
|
||||
):
|
||||
# The exception should propagate as a 503 error (Service Unavailable)
|
||||
# But we need to handle it properly
|
||||
try:
|
||||
response = await authenticated_client.post("/v1/wallet/refund")
|
||||
# If we get here, check the status code
|
||||
assert response.status_code == 503
|
||||
assert "Mint service unavailable" in response.json()["detail"]
|
||||
except Exception as e:
|
||||
# If the exception propagates, that's also a failure scenario
|
||||
assert "Mint unavailable" in str(e)
|
||||
response = await authenticated_client.post("/v1/wallet/refund")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Mint service unavailable"
|
||||
assert raw_error not in response.text
|
||||
|
||||
# Balance should remain unchanged (transaction should roll back)
|
||||
# Note: Current implementation might not handle this perfectly
|
||||
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||
assert wallet_response.status_code == 200
|
||||
assert wallet_response.json()["balance"] == 10_000_000
|
||||
|
||||
51
tests/unit/test_admin_withdraw.py
Normal file
51
tests/unit/test_admin_withdraw.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core import admin
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("requested_mint", [None, "https://secondary.example"])
|
||||
async def test_withdraw_uses_effective_mint_and_records_outgoing_transaction(
|
||||
monkeypatch: pytest.MonkeyPatch, requested_mint: str | None
|
||||
) -> None:
|
||||
primary_mint = "https://primary.example"
|
||||
effective_mint = requested_mint or primary_mint
|
||||
wallet = object()
|
||||
proofs = [SimpleNamespace(amount=40), SimpleNamespace(amount=60)]
|
||||
token = "cashuBoutgoing"
|
||||
|
||||
get_wallet = AsyncMock(return_value=wallet)
|
||||
get_proofs = Mock(return_value=proofs)
|
||||
filter_proofs = AsyncMock(return_value=proofs)
|
||||
send_token = AsyncMock(return_value=token)
|
||||
store_transaction = AsyncMock(return_value=True)
|
||||
|
||||
monkeypatch.setattr(admin, "get_wallet", get_wallet)
|
||||
monkeypatch.setattr(admin, "get_proofs_per_mint_and_unit", get_proofs)
|
||||
monkeypatch.setattr(admin, "slow_filter_spend_proofs", filter_proofs)
|
||||
monkeypatch.setattr(admin, "send_token", send_token)
|
||||
monkeypatch.setattr(admin, "store_cashu_transaction", store_transaction)
|
||||
monkeypatch.setattr(admin.settings, "primary_mint", primary_mint)
|
||||
|
||||
result = await admin.withdraw(
|
||||
Mock(),
|
||||
admin.WithdrawRequest(amount=75, mint_url=requested_mint, unit="sat"),
|
||||
)
|
||||
|
||||
assert result == {"token": token}
|
||||
get_wallet.assert_awaited_once_with(effective_mint, "sat")
|
||||
get_proofs.assert_called_once_with(wallet, effective_mint, "sat", not_reserved=True)
|
||||
filter_proofs.assert_awaited_once_with(proofs, wallet)
|
||||
send_token.assert_awaited_once_with(75, "sat", effective_mint)
|
||||
store_transaction.assert_awaited_once_with(
|
||||
token=token,
|
||||
amount=75,
|
||||
unit="sat",
|
||||
mint_url=effective_mint,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="admin",
|
||||
)
|
||||
@@ -137,8 +137,8 @@ def test_create_model_mappings_includes_db_override_for_missing_cached_model(
|
||||
|
||||
model_instances, provider_map, unique_models = create_model_mappings(
|
||||
upstreams=[provider],
|
||||
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
|
||||
disabled_model_ids=set(),
|
||||
overrides_by_key={("azure/gpt-4o", 7): (override_row, 1.01)},
|
||||
disabled_model_keys=set(),
|
||||
)
|
||||
|
||||
assert "azure/gpt-4o" in model_instances
|
||||
@@ -182,11 +182,73 @@ def test_create_model_mappings_dedupes_with_provider_identity_not_provider_type(
|
||||
|
||||
_, provider_map, _ = create_model_mappings(
|
||||
upstreams=[provider_a, provider_b],
|
||||
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
|
||||
disabled_model_ids=set(),
|
||||
overrides_by_key={("azure/gpt-4o", 2): (override_row, 1.01)},
|
||||
disabled_model_keys=set(),
|
||||
)
|
||||
|
||||
providers_for_alias = provider_map["azure/gpt-4o"]
|
||||
assert provider_a in providers_for_alias
|
||||
assert provider_b in providers_for_alias
|
||||
assert len(providers_for_alias) == 2
|
||||
|
||||
|
||||
def test_create_model_mappings_applies_override_only_to_matching_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Same-id overrides must not add provider-specific aliases to other providers."""
|
||||
provider_a_model = create_test_model("same-id", prompt_price=0.01)
|
||||
provider_a = create_test_provider(
|
||||
"provider-a",
|
||||
"https://provider-a.example/v1",
|
||||
db_id=1,
|
||||
models=[provider_a_model],
|
||||
)
|
||||
provider_b_model = create_test_model("same-id", prompt_price=0.02)
|
||||
provider_b = create_test_provider(
|
||||
"provider-b",
|
||||
"https://provider-b.example/v1",
|
||||
db_id=2,
|
||||
models=[provider_b_model],
|
||||
)
|
||||
|
||||
override_model = create_test_model("same-id", prompt_price=0.001)
|
||||
override_model.alias_ids = ["provider-b-only"]
|
||||
override_row = SimpleNamespace(id="same-id", upstream_provider_id=2, enabled=True)
|
||||
|
||||
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
|
||||
return override_model
|
||||
|
||||
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
|
||||
|
||||
_, provider_map, _ = create_model_mappings(
|
||||
upstreams=[provider_a, provider_b],
|
||||
overrides_by_key={("same-id", 2): (override_row, 1.01)},
|
||||
disabled_model_keys=set(),
|
||||
)
|
||||
|
||||
assert provider_map["provider-b-only"] == [provider_b]
|
||||
assert set(provider_map["same-id"]) == {provider_a, provider_b}
|
||||
|
||||
|
||||
def test_create_model_mappings_disables_only_matching_provider() -> None:
|
||||
"""Disabled overrides are scoped to the provider row, not the shared model id."""
|
||||
provider_a = create_test_provider(
|
||||
"provider-a",
|
||||
"https://provider-a.example/v1",
|
||||
db_id=1,
|
||||
models=[create_test_model("same-id")],
|
||||
)
|
||||
provider_b = create_test_provider(
|
||||
"provider-b",
|
||||
"https://provider-b.example/v1",
|
||||
db_id=2,
|
||||
models=[create_test_model("same-id")],
|
||||
)
|
||||
|
||||
_, provider_map, _ = create_model_mappings(
|
||||
upstreams=[provider_a, provider_b],
|
||||
overrides_by_key={},
|
||||
disabled_model_keys={("same-id", 2)},
|
||||
)
|
||||
|
||||
assert provider_map["same-id"] == [provider_a]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import AsyncGenerator
|
||||
from typing import AsyncGenerator, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
@@ -12,6 +13,19 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import validate_bearer_key
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.wallet import MintConnectionError
|
||||
|
||||
|
||||
def _value_error_wrapping_transport() -> ValueError:
|
||||
"""A ValueError re-raised ``from`` a real httpx transport error, mirroring
|
||||
``wallet.py`` wrapping a connection failure. The sanitized classifier must
|
||||
still see the mint-unreachable signal through the ``__cause__`` chain."""
|
||||
try:
|
||||
raise httpx.ConnectError("All connection attempts failed")
|
||||
except httpx.ConnectError as exc:
|
||||
err = ValueError("Failed to estimate fees: connection failed")
|
||||
err.__cause__ = exc
|
||||
return err
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
@@ -57,3 +71,223 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_status", "expected_type", "expected_message", "expected_code"),
|
||||
[
|
||||
(
|
||||
ValueError("Mint Error: Token already spent. (Code: 11001)"),
|
||||
400,
|
||||
"token_already_spent",
|
||||
"Cashu token already spent",
|
||||
"cashu_token_already_spent",
|
||||
),
|
||||
(
|
||||
# Raw httpx transport error propagated unwrapped from cashu.
|
||||
httpx.ConnectError("All connection attempts failed"),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
# Typed error raised by wallet.py at a wrap site.
|
||||
MintConnectionError("connect to http://mint:3338 refused"),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
# ValueError wrapping the httpx error in its __cause__ chain.
|
||||
_value_error_wrapping_transport(),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
# asyncio.TimeoutError is builtin TimeoutError on 3.11+.
|
||||
TimeoutError("Timed out connecting to Cashu mint http://mint:3338"),
|
||||
503,
|
||||
"mint_unreachable",
|
||||
"Cashu mint is unreachable",
|
||||
"cashu_mint_unreachable",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Token amount (5 sat) is insufficient to cover melt fees. "
|
||||
"Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Failed to melt token from foreign mint http://foreign:3338: boom"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Failed to swap token from foreign mint",
|
||||
"cashu_foreign_mint_swap_failed",
|
||||
),
|
||||
(
|
||||
ValueError("could not decode token"),
|
||||
400,
|
||||
"invalid_token",
|
||||
"Invalid Cashu token",
|
||||
"invalid_cashu_token",
|
||||
),
|
||||
(
|
||||
ValueError("some unexpected wallet condition"),
|
||||
400,
|
||||
"cashu_error",
|
||||
"Failed to redeem Cashu token",
|
||||
"cashu_token_redemption_failed",
|
||||
),
|
||||
(
|
||||
ValueError("Redeemed token amount must be positive, got 0 msats"),
|
||||
400,
|
||||
"cashu_error",
|
||||
"Failed to redeem Cashu token: token yielded no value",
|
||||
"cashu_token_zero_value",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_redemption_failure_returns_sanitized_error(
|
||||
session: AsyncSession,
|
||||
error: Exception,
|
||||
expected_status: int,
|
||||
expected_type: str,
|
||||
expected_message: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
"""Redemption failures reuse the shared X-Cashu taxonomy (carried in
|
||||
``type``), expose stable sanitized messages and granular machine-readable
|
||||
``code`` values, and leave no orphan ApiKey row."""
|
||||
token = "cashuAredemption_fails_with_specific_error"
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||
patch(
|
||||
"routstr.auth.credit_balance",
|
||||
new=AsyncMock(side_effect=error),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == expected_status
|
||||
detail = cast(dict[str, dict[str, object]], exc_info.value.detail)
|
||||
error_detail = detail["error"]
|
||||
assert error_detail["type"] == expected_type
|
||||
assert error_detail["code"] == expected_code
|
||||
assert error_detail["message"] == expected_message
|
||||
assert str(error) not in cast(str, error_detail["message"])
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_redemption_error_returns_internal_error(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Unexpected (non-wallet) failures surface as generic 500s without
|
||||
leaking internal details, instead of masquerading as token errors."""
|
||||
token = "cashuAredemption_fails_with_internal_error"
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||
patch(
|
||||
"routstr.auth.credit_balance",
|
||||
new=AsyncMock(side_effect=RuntimeError("db exploded at /var/lib/secret")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||
error_detail = detail["error"]
|
||||
assert error_detail["code"] == "internal_error"
|
||||
assert "/var/lib/secret" not in error_detail["message"]
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_error_with_invalid_keyword_does_not_masquerade(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""A non-wallet fault whose text merely contains "invalid" (but not
|
||||
"token") must fall through to a generic 500, not a 401 token error.
|
||||
|
||||
Guards the anchored `"invalid"/"decode"` + `"token"` gate against stdlib/
|
||||
driver strings like "Invalid isoformat string" leaking as token errors."""
|
||||
token = "cashuAinternal_fault_mentions_invalid"
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||
patch(
|
||||
"routstr.auth.credit_balance",
|
||||
new=AsyncMock(
|
||||
side_effect=RuntimeError("Invalid isoformat string: '2020-13-99'")
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||
assert detail["error"]["code"] == "internal_error"
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_cashu_token_returns_400_invalid_token(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""A malformed 'cashu...' token that fails to decode maps to 400
|
||||
invalid_cashu_token (shared taxonomy), not the generic 401 invalid_api_key."""
|
||||
token = "cashuAthis_is_not_a_valid_token"
|
||||
|
||||
with patch(
|
||||
"routstr.auth.deserialize_token_from_string",
|
||||
side_effect=ValueError("unable to decode token: bad base64"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||
assert detail["error"]["type"] == "invalid_token"
|
||||
assert detail["error"]["code"] == "invalid_cashu_token"
|
||||
# Raw decoder text must not leak to the client.
|
||||
assert "base64" not in detail["error"]["message"]
|
||||
|
||||
143
tests/unit/test_auto_topup.py
Normal file
143
tests/unit/test_auto_topup.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import CashuTransaction
|
||||
from routstr.upstream.auto_topup import _check_and_topup
|
||||
|
||||
|
||||
def _row() -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.id = "provider-1"
|
||||
row.base_url = "https://provider.test"
|
||||
row.api_key = "secret"
|
||||
row.provider_settings = json.dumps(
|
||||
{
|
||||
"auto_topup": True,
|
||||
"topup_threshold": 100,
|
||||
"topup_amount_limit": 50,
|
||||
"topup_mint_url": "https://mint.test",
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, transaction: CashuTransaction) -> None:
|
||||
self.transaction = transaction
|
||||
self.commit = AsyncMock()
|
||||
|
||||
async def __aenter__(self) -> "_Session":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def exec(self, query: object) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.first.return_value = self.transaction
|
||||
return result
|
||||
|
||||
def add(self, transaction: CashuTransaction) -> None:
|
||||
self.transaction = transaction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_topup_persists_before_sending_and_marks_success_collected() -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_balance = AsyncMock(return_value=0)
|
||||
provider.topup = AsyncMock(return_value={"balance": 50})
|
||||
transaction = CashuTransaction(
|
||||
token="cashu-token", amount=50, unit="sat", source="auto_topup"
|
||||
)
|
||||
session = _Session(transaction)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.send_token",
|
||||
AsyncMock(return_value="cashu-token"),
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.store_cashu_transaction",
|
||||
AsyncMock(return_value=True),
|
||||
) as store,
|
||||
patch("routstr.upstream.auto_topup.create_session", return_value=session),
|
||||
):
|
||||
await _check_and_topup(_row())
|
||||
|
||||
store.assert_awaited_once_with(
|
||||
token="cashu-token",
|
||||
amount=50,
|
||||
unit="sat",
|
||||
mint_url="https://mint.test",
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="auto_topup",
|
||||
)
|
||||
provider.topup.assert_awaited_once_with("cashu-token")
|
||||
assert transaction.collected is True
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("outcome", [{"error": "rejected"}, RuntimeError("network")])
|
||||
async def test_auto_topup_failure_leaves_persisted_token_uncollected(
|
||||
outcome: object,
|
||||
) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_balance = AsyncMock(return_value=0)
|
||||
provider.topup = AsyncMock(
|
||||
side_effect=outcome if isinstance(outcome, Exception) else None,
|
||||
return_value=outcome,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.send_token",
|
||||
AsyncMock(return_value="cashu-token"),
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.store_cashu_transaction",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch("routstr.upstream.auto_topup.create_session") as create_session,
|
||||
):
|
||||
if isinstance(outcome, Exception):
|
||||
with pytest.raises(RuntimeError):
|
||||
await _check_and_topup(_row())
|
||||
else:
|
||||
await _check_and_topup(_row())
|
||||
|
||||
create_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_topup_does_not_send_untracked_token() -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_balance = AsyncMock(return_value=0)
|
||||
provider.topup = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.send_token",
|
||||
AsyncMock(return_value="cashu-token"),
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.auto_topup.store_cashu_transaction",
|
||||
AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
await _check_and_topup(_row())
|
||||
provider.topup.assert_not_awaited()
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routstr.balance import refund_wallet_endpoint
|
||||
from routstr.balance import refund_wallet_endpoint, topup_wallet_endpoint
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
from routstr.wallet import credit_balance
|
||||
from routstr.wallet import MintConnectionError, credit_balance
|
||||
|
||||
|
||||
def _make_cashu_tx(
|
||||
@@ -103,6 +104,64 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_pending_raises_425() -> None:
|
||||
"""in row exists with a request_id but out row not yet created → 425.
|
||||
|
||||
This is the race condition where /v1/wallet/refund is polled while the
|
||||
upstream request is still in flight. The endpoint must signal "retry"
|
||||
rather than a permanent 404.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
x_cashu_token = "cashuApending_token"
|
||||
in_tx = _make_cashu_tx(
|
||||
token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-pending"
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(None)])
|
||||
session.add = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu=x_cashu_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 425
|
||||
assert exc_info.value.headers == {"Retry-After": "2"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_in_tx_without_request_id_raises_404() -> None:
|
||||
"""in row exists but has no request_id (cannot link to a refund) → 404.
|
||||
|
||||
This is a genuine "no refund will ever exist" case, distinct from the
|
||||
pending 425 path.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
x_cashu_token = "cashuAnoreqid_token"
|
||||
in_tx = _make_cashu_tx(
|
||||
token=x_cashu_token, amount=0, unit="msat", type="in", request_id=None
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.exec = AsyncMock(side_effect=[_exec_result(in_tx)])
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-somekey",
|
||||
x_cashu=x_cashu_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_x_cashu_swept_raises_410() -> None:
|
||||
from fastapi import HTTPException
|
||||
@@ -339,7 +398,10 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
|
||||
patch(
|
||||
"routstr.balance.send_token",
|
||||
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
|
||||
),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||
@@ -353,10 +415,46 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Mint service unavailable"
|
||||
assert "raw mint outage detail" not in exc_info.value.detail
|
||||
# Verify two exec calls: debit + restore
|
||||
assert session.exec.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
|
||||
"""Unexpected send-side failures restore balance without leaking exception text."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||
raw_error = "database secret token raw-mint-response"
|
||||
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
|
||||
session.commit = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.send_token", AsyncMock(side_effect=RuntimeError(raw_error))),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||
patch("routstr.balance.logger"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await refund_wallet_endpoint(
|
||||
authorization="Bearer sk-testhash",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == "Refund failed"
|
||||
assert raw_error not in exc_info.value.detail
|
||||
assert session.exec.await_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -401,3 +499,190 @@ async def test_refund_unknown_sk_bearer_returns_401() -> None:
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
session.get.assert_awaited_once()
|
||||
|
||||
|
||||
# --- Topup redemption error taxonomy (POST /v1/wallet/topup) ------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
httpx.ConnectError("All connection attempts failed"),
|
||||
MintConnectionError("connect to mint refused"),
|
||||
TimeoutError("timed out connecting to mint"),
|
||||
],
|
||||
)
|
||||
async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
|
||||
"""A down mint must surface 503 (retryable), not 400 or 500 — the token is
|
||||
fine, so the client should retry once the mint recovers."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Cashu mint is unreachable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_already_spent_still_returns_400() -> None:
|
||||
"""Regression: the mint-unreachable short-circuit must not swallow the
|
||||
existing ValueError substring buckets."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(side_effect=ValueError("Token already spent")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "Cashu token already spent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_zero_value_returns_400_zero_value_message() -> None:
|
||||
"""A dust/zero redemption maps to the documented zero-value message, not the
|
||||
generic redemption-failed one."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(
|
||||
side_effect=ValueError("Redeemed token amount must be positive, got 0 msats")
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "Failed to redeem Cashu token: token yielded no value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_token_consumed_returns_500() -> None:
|
||||
"""A post-redemption crediting failure (token spent) is a non-retryable 500,
|
||||
not a 4xx that invites a retry."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routstr.wallet import TokenConsumedError
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(side_effect=TokenConsumedError("credit failed")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == (
|
||||
"Token was redeemed but could not be credited; do not retry"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_status", "expected_detail"),
|
||||
[
|
||||
(
|
||||
ValueError(
|
||||
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||
),
|
||||
422,
|
||||
"Token value is too small to cover swap fees",
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Token amount (5 sat) is insufficient to cover melt fees."
|
||||
),
|
||||
422,
|
||||
"Token value is too small to cover swap fees",
|
||||
),
|
||||
(
|
||||
ValueError("Failed to melt token from foreign mint http://m: boom"),
|
||||
422,
|
||||
"Failed to swap token from foreign mint",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_topup_fee_and_swap_failures_return_422(
|
||||
error: Exception, expected_status: int, expected_detail: str
|
||||
) -> None:
|
||||
"""Fee/swap failures map to 422 (shared taxonomy), matching the bearer and
|
||||
X-Cashu paths — previously top-up flattened these to 400."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == expected_status
|
||||
assert exc_info.value.detail == expected_detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_unexpected_non_valueerror_returns_500() -> None:
|
||||
"""A non-ValueError, non-transport fault is an internal error (500), not a
|
||||
sanitized 400 — the merged except must preserve this."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
key = _make_api_key(balance=1000)
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||
patch(
|
||||
"routstr.balance.credit_balance",
|
||||
AsyncMock(side_effect=RuntimeError("db exploded")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await topup_wallet_endpoint(
|
||||
cashu_token="cashuAtoken", key=key, session=session
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.detail == "Internal server error"
|
||||
|
||||
@@ -404,9 +404,240 @@ async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
|
||||
assert result.cache_read_input_tokens == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Truly-empty response with a non-zero USD cost → full refund
|
||||
#
|
||||
# When an upstream reports a USD cost but the response carries NO tokens at all
|
||||
# (input, output, cache-read and cache-creation all zero), billing the
|
||||
# USD-derived cost charges the user for nothing. Refund in full. The gate is
|
||||
# tightened relative to PR #489: a cache-read/-creation-only turn legitimately
|
||||
# reports zero prompt/completion tokens with a real cost and must still bill.
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_truly_empty_usd_cost_response_is_refunded(
|
||||
mock_fixed_pricing: None,
|
||||
) -> None:
|
||||
"""0 input + 0 output + 0 cache tokens with a non-zero USD cost → refund."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_cost": 0.01, # non-zero USD cost despite no tokens
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.total_msats == 0 # full refund
|
||||
assert result.input_msats == 0
|
||||
assert result.output_msats == 0
|
||||
assert result.total_usd == 0.0
|
||||
assert result.input_tokens == 0
|
||||
assert result.output_tokens == 0
|
||||
assert result.cache_read_input_tokens == 0
|
||||
assert result.cache_creation_input_tokens == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_read_only_usd_cost_response_is_billed(
|
||||
mock_fixed_pricing: None,
|
||||
) -> None:
|
||||
"""Cache-read-only turn (0 prompt/completion, non-zero cost) still bills."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 1000, # real cached usage
|
||||
"cache_creation_input_tokens": 0,
|
||||
"total_cost": 0.01, # non-zero USD cost
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# NOT refunded — the USD cost is billed in full. Pinning the exact value
|
||||
# guards against any future regression that would over-refund a cache-only
|
||||
# turn (the bug in PR #489, which refunded whenever prompt+completion == 0).
|
||||
assert result.total_msats == 200000
|
||||
assert result.total_usd == 0.01
|
||||
assert result.cache_read_input_tokens == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("total_cost", "input_cost", "output_cost", "expected_msats"),
|
||||
[
|
||||
(0.000471, 0.00023451, 0.00023649, 9420),
|
||||
(0.00000004, 0.00000002, 0.00000002, 1),
|
||||
],
|
||||
)
|
||||
async def test_small_usd_cost_components_sum_to_rounded_total(
|
||||
total_cost: float,
|
||||
input_cost: float,
|
||||
output_cost: float,
|
||||
expected_msats: int,
|
||||
) -> None:
|
||||
"""Small USD component costs must retain every billed millisatoshi."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"cost_details": {
|
||||
"total_cost": total_cost,
|
||||
"input_cost": input_cost,
|
||||
"output_cost": output_cost,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.total_msats == expected_msats
|
||||
assert result.input_msats + result.output_msats == result.total_msats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_upstream_inference_cost_components_are_used() -> None:
|
||||
"""OpenRouter component aliases must determine the input/output split."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 375,
|
||||
"completion_tokens": 158,
|
||||
"total_tokens": 533,
|
||||
"cost": 0.00022354,
|
||||
"is_byok": False,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 286,
|
||||
"cache_write_tokens": 0,
|
||||
},
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.00022354,
|
||||
"upstream_inference_prompt_cost": 0.00004974,
|
||||
"upstream_inference_completions_cost": 0.0001738,
|
||||
},
|
||||
"completion_tokens_details": {"reasoning_tokens": 17},
|
||||
},
|
||||
}
|
||||
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_msats == 994
|
||||
assert result.output_msats == 3477
|
||||
assert result.input_msats + result.output_msats == result.total_msats == 4471
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PPQ.AI BYOK: upstream_inference_cost + BYOK fee billing
|
||||
#
|
||||
# PPQ.AI (bring-your-own-key) returns a small ~5 % BYOK routing fee in
|
||||
# ``usage.cost`` and the real inference cost in
|
||||
# ``cost_details.upstream_inference_cost``. The old code billed only the fee,
|
||||
# under-charging by ~20×. The fix bills ``upstream_inference_cost + byok_fee``
|
||||
# — what PPQ actually deducts from the balance.
|
||||
# Payload numbers are from a live ``glm-5.2-fast`` request (GitHub issue #615).
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_ppq_byok_bills_upstream_inference_cost_plus_fee() -> None:
|
||||
"""PPQ.AI BYOK must bill upstream_inference_cost + byok_fee, not just the
|
||||
fee. Mirrors the live request from GitHub issue #615."""
|
||||
response = {
|
||||
"model": "glm-5.2-fast",
|
||||
"usage": {
|
||||
"prompt_tokens": 164371, # includes 159301 cached
|
||||
"completion_tokens": 99,
|
||||
"cost": 0.002260057305, # ~5% BYOK routing fee
|
||||
"is_byok": True,
|
||||
"prompt_tokens_details": {"cached_tokens": 159301},
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": 0.04475361,
|
||||
"upstream_inference_prompt_cost": 0.04410021,
|
||||
"upstream_inference_completions_cost": 0.0006534,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# The fix bills upstream_inference_cost + byok_fee (~0.047 USD → ~940k
|
||||
# msats), not the fee alone (~0.0023 USD → ~45k msats). ~20× correction.
|
||||
assert result.total_msats == 940274
|
||||
assert result.input_msats + result.output_msats == result.total_msats
|
||||
assert result.input_msats == 926546
|
||||
assert result.output_msats == 13728
|
||||
assert result.total_usd == pytest.approx(0.047013667305)
|
||||
# Token normalisation (OpenAI dialect: cached included in prompt_tokens)
|
||||
assert result.input_tokens == 5070 # 164371 - 159301
|
||||
assert result.cache_read_input_tokens == 159301
|
||||
assert result.output_tokens == 99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ppq_byok_fee_only_would_undercharge() -> None:
|
||||
"""Sanity check: billing only usage.cost (the BYOK fee) under-charges by
|
||||
~20×. This documents the regression the fix prevents."""
|
||||
response = {
|
||||
"model": "glm-5.2-fast",
|
||||
"usage": {
|
||||
"prompt_tokens": 164371,
|
||||
"completion_tokens": 99,
|
||||
"cost": 0.002260057305, # BYOK fee only — no upstream_inference_cost
|
||||
"is_byok": True,
|
||||
"prompt_tokens_details": {"cached_tokens": 159301},
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# Without upstream_inference_cost, only the fee is billed — the old bug.
|
||||
assert result.total_msats == 45202
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 13: Missing Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_upstream_cost_uses_litellm_model_pricing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Token usage without an upstream cost is priced from LiteLLM."""
|
||||
monkeypatch.setattr(settings, "fixed_pricing", True)
|
||||
monkeypatch.setattr(settings, "fixed_per_1k_input_tokens", 0)
|
||||
monkeypatch.setattr(settings, "fixed_per_1k_output_tokens", 0)
|
||||
monkeypatch.setattr(
|
||||
"routstr.payment.models.litellm_cost_entry",
|
||||
lambda model: {
|
||||
"input_cost_per_token": 0.000001,
|
||||
"output_cost_per_token": 0.000002,
|
||||
},
|
||||
)
|
||||
response = {
|
||||
"model": "priced-by-litellm",
|
||||
"usage": {
|
||||
"prompt_tokens": 90,
|
||||
"completion_tokens": 80,
|
||||
"total_tokens": 170,
|
||||
"prompt_tokens_details": {"cached_tokens": 0},
|
||||
"completion_tokens_details": {"reasoning_tokens": 74},
|
||||
"prompt_cache_hit_tokens": 0,
|
||||
"prompt_cache_miss_tokens": 90,
|
||||
},
|
||||
}
|
||||
|
||||
result = await calculate_cost(response, max_cost=10000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert not isinstance(result, MaxCostData)
|
||||
assert result.input_msats == 1800
|
||||
assert result.output_msats == 3200
|
||||
assert result.input_msats + result.output_msats == result.total_msats == 5000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_usage_block(mock_fixed_pricing: None) -> None:
|
||||
"""When usage is missing, return MaxCostData with zero tokens."""
|
||||
|
||||
185
tests/unit/test_ehbp_finalize_payment.py
Normal file
185
tests/unit/test_ehbp_finalize_payment.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.upstream.ehbp import (
|
||||
finalize_ehbp_actual_cost_payment,
|
||||
finalize_ehbp_max_cost_payment,
|
||||
)
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[AsyncSession, None]:
|
||||
monkeypatch.setattr("routstr.upstream.ehbp.ROUTSTR_FEE_PERCENT", 0)
|
||||
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()
|
||||
|
||||
|
||||
async def _api_key(session: AsyncSession, hashed_key: str) -> ApiKey | None:
|
||||
return (
|
||||
await session.exec(select(ApiKey).where(ApiKey.hashed_key == hashed_key))
|
||||
).one_or_none()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_actual_cost_payment_updates_balance_and_releases_reserve(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
key = ApiKey(
|
||||
hashed_key="ehbp-actual",
|
||||
balance=10_000,
|
||||
reserved_balance=3_000,
|
||||
reserved_at=123,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
await finalize_ehbp_actual_cost_payment(
|
||||
key,
|
||||
session,
|
||||
reserved_cost_for_model=3_000,
|
||||
model_id="tinfoil/model",
|
||||
cost_info={
|
||||
"total_msats": 1_200,
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 20,
|
||||
"input_msats": 500,
|
||||
"output_msats": 700,
|
||||
},
|
||||
)
|
||||
|
||||
updated = await _api_key(session, "ehbp-actual")
|
||||
assert updated is not None
|
||||
assert updated.balance == 8_800
|
||||
assert updated.reserved_balance == 0
|
||||
assert updated.reserved_at is None
|
||||
assert updated.total_spent == 1_200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_max_cost_payment_updates_parent_and_child_spend(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
parent = ApiKey(
|
||||
hashed_key="ehbp-parent",
|
||||
balance=10_000,
|
||||
reserved_balance=3_000,
|
||||
reserved_at=123,
|
||||
)
|
||||
child = ApiKey(
|
||||
hashed_key="ehbp-child",
|
||||
balance=0,
|
||||
reserved_balance=3_000,
|
||||
reserved_at=123,
|
||||
parent_key_hash="ehbp-parent",
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
|
||||
await finalize_ehbp_max_cost_payment(
|
||||
child,
|
||||
session,
|
||||
max_cost_for_model=3_000,
|
||||
model_id="tinfoil/model",
|
||||
)
|
||||
|
||||
updated_parent = await _api_key(session, "ehbp-parent")
|
||||
updated_child = await _api_key(session, "ehbp-child")
|
||||
assert updated_parent is not None
|
||||
assert updated_child is not None
|
||||
assert updated_parent.balance == 7_000
|
||||
assert updated_parent.reserved_balance == 0
|
||||
assert updated_parent.reserved_at is None
|
||||
assert updated_parent.total_spent == 3_000
|
||||
assert updated_child.balance == 0
|
||||
assert updated_child.reserved_balance == 0
|
||||
assert updated_child.reserved_at is None
|
||||
assert updated_child.total_spent == 3_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_actual_cost_payment_rolls_back_when_parent_update_matches_no_rows(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
key = ApiKey(
|
||||
hashed_key="ehbp-missing-parent",
|
||||
balance=10_000,
|
||||
reserved_balance=3_000,
|
||||
reserved_at=123,
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await session.delete(key)
|
||||
await session.commit()
|
||||
|
||||
await finalize_ehbp_actual_cost_payment(
|
||||
key,
|
||||
session,
|
||||
reserved_cost_for_model=3_000,
|
||||
model_id="tinfoil/model",
|
||||
cost_info={"total_msats": 1_200},
|
||||
)
|
||||
|
||||
assert await _api_key(session, "ehbp-missing-parent") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_max_cost_payment_rolls_back_parent_when_child_update_matches_no_rows(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
parent = ApiKey(
|
||||
hashed_key="ehbp-rollback-parent",
|
||||
balance=10_000,
|
||||
reserved_balance=3_000,
|
||||
reserved_at=123,
|
||||
)
|
||||
child = ApiKey(
|
||||
hashed_key="ehbp-missing-child",
|
||||
balance=0,
|
||||
reserved_balance=3_000,
|
||||
reserved_at=123,
|
||||
parent_key_hash="ehbp-rollback-parent",
|
||||
)
|
||||
session.add(parent)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
await session.delete(child)
|
||||
await session.commit()
|
||||
|
||||
await finalize_ehbp_max_cost_payment(
|
||||
child,
|
||||
session,
|
||||
max_cost_for_model=3_000,
|
||||
model_id="tinfoil/model",
|
||||
)
|
||||
|
||||
updated_parent = await _api_key(session, "ehbp-rollback-parent")
|
||||
assert updated_parent is not None
|
||||
assert updated_parent.balance == 10_000
|
||||
assert updated_parent.reserved_balance == 3_000
|
||||
assert updated_parent.reserved_at == 123
|
||||
assert updated_parent.total_spent == 0
|
||||
assert await _api_key(session, "ehbp-missing-child") is None
|
||||
160
tests/unit/test_fee_payout_crash_safety.py
Normal file
160
tests/unit/test_fee_payout_crash_safety.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr import wallet
|
||||
from routstr.core import db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_context(session: Mock) -> AsyncIterator[Mock]:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_checkpoint_is_atomic_and_durable() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite://")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(db.RoutstrFee(id=1, accumulated_msats=5_000))
|
||||
await session.commit()
|
||||
|
||||
assert await db.reset_routstr_fee(session, 5_000) is True
|
||||
assert await db.reset_routstr_fee(session, 5_000) is False
|
||||
|
||||
fee = await db.get_routstr_fee(session)
|
||||
await session.refresh(fee)
|
||||
assert fee.accumulated_msats == 0
|
||||
assert fee.payout_in_progress_msats == 5_000
|
||||
assert fee.total_paid_msats == 0
|
||||
|
||||
assert await db.complete_routstr_fee_payout(session, 5_000) is True
|
||||
await session.refresh(fee)
|
||||
assert fee.payout_in_progress_msats == 0
|
||||
assert fee.total_paid_msats == 5_000
|
||||
assert fee.last_paid_at is not None
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_checkpoints_before_sending() -> None:
|
||||
session = Mock()
|
||||
fee = SimpleNamespace(
|
||||
accumulated_msats=5_000,
|
||||
payout_in_progress_msats=0,
|
||||
payout_started_at=None,
|
||||
)
|
||||
payout_wallet = Mock()
|
||||
events: list[str] = []
|
||||
|
||||
async def checkpoint(*_args: object) -> bool:
|
||||
events.append("checkpoint")
|
||||
return True
|
||||
|
||||
async def send(*_args: object, **_kwargs: object) -> int:
|
||||
events.append("send")
|
||||
return 5
|
||||
|
||||
async def complete(*_args: object) -> bool:
|
||||
events.append("complete")
|
||||
return True
|
||||
|
||||
with (
|
||||
patch("routstr.auth.ROUTSTR_FEE_DEFAULT_PAYOUT", 1),
|
||||
patch("routstr.auth.ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS", 1),
|
||||
patch("routstr.auth.ROUTSTR_LN_ADDRESS", "fees@example.com"),
|
||||
patch(
|
||||
"routstr.wallet.asyncio.sleep",
|
||||
AsyncMock(side_effect=[None, asyncio.CancelledError()]),
|
||||
),
|
||||
patch("routstr.wallet.db.create_session", return_value=_session_context(session)),
|
||||
patch("routstr.wallet.db.get_routstr_fee", AsyncMock(return_value=fee)),
|
||||
patch("routstr.wallet.db.reset_routstr_fee", side_effect=checkpoint),
|
||||
patch("routstr.wallet.db.complete_routstr_fee_payout", side_effect=complete),
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=payout_wallet)),
|
||||
patch("routstr.wallet.get_proofs_per_mint_and_unit", return_value=[]),
|
||||
patch("routstr.wallet.raw_send_to_lnurl", side_effect=send),
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await wallet.periodic_routstr_fee_payout()
|
||||
|
||||
assert events == ["checkpoint", "send", "complete"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_does_not_retry_an_unresolved_checkpoint() -> None:
|
||||
session = Mock()
|
||||
fee = SimpleNamespace(
|
||||
accumulated_msats=10_000,
|
||||
payout_in_progress_msats=5_000,
|
||||
payout_started_at=123,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("routstr.auth.ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS", 1),
|
||||
patch("routstr.auth.ROUTSTR_LN_ADDRESS", "fees@example.com"),
|
||||
patch(
|
||||
"routstr.wallet.asyncio.sleep",
|
||||
AsyncMock(side_effect=[None, asyncio.CancelledError()]),
|
||||
),
|
||||
patch("routstr.wallet.db.create_session", return_value=_session_context(session)),
|
||||
patch("routstr.wallet.db.get_routstr_fee", AsyncMock(return_value=fee)),
|
||||
patch("routstr.wallet.db.reset_routstr_fee", AsyncMock()) as checkpoint,
|
||||
patch("routstr.wallet.get_wallet", AsyncMock()) as get_wallet,
|
||||
patch("routstr.wallet.raw_send_to_lnurl", AsyncMock()) as send,
|
||||
patch("routstr.wallet.logger.critical") as critical,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await wallet.periodic_routstr_fee_payout()
|
||||
|
||||
checkpoint.assert_not_awaited()
|
||||
get_wallet.assert_not_awaited()
|
||||
send.assert_not_awaited()
|
||||
critical.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_keeps_checkpoint_when_send_outcome_is_unknown() -> None:
|
||||
session = Mock()
|
||||
fee = SimpleNamespace(
|
||||
accumulated_msats=5_000,
|
||||
payout_in_progress_msats=0,
|
||||
payout_started_at=None,
|
||||
)
|
||||
complete = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("routstr.auth.ROUTSTR_FEE_DEFAULT_PAYOUT", 1),
|
||||
patch("routstr.auth.ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS", 1),
|
||||
patch("routstr.auth.ROUTSTR_LN_ADDRESS", "fees@example.com"),
|
||||
patch(
|
||||
"routstr.wallet.asyncio.sleep",
|
||||
AsyncMock(side_effect=[None, asyncio.CancelledError()]),
|
||||
),
|
||||
patch("routstr.wallet.db.create_session", return_value=_session_context(session)),
|
||||
patch("routstr.wallet.db.get_routstr_fee", AsyncMock(return_value=fee)),
|
||||
patch("routstr.wallet.db.reset_routstr_fee", AsyncMock(return_value=True)),
|
||||
patch("routstr.wallet.db.complete_routstr_fee_payout", complete),
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=Mock())),
|
||||
patch("routstr.wallet.get_proofs_per_mint_and_unit", return_value=[]),
|
||||
patch(
|
||||
"routstr.wallet.raw_send_to_lnurl",
|
||||
AsyncMock(side_effect=TimeoutError("unknown outcome")),
|
||||
),
|
||||
patch("routstr.wallet.logger.critical") as critical,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await wallet.periodic_routstr_fee_payout()
|
||||
|
||||
complete.assert_not_awaited()
|
||||
critical.assert_called_once()
|
||||
46
tests/unit/test_fee_payout_migration.py
Normal file
46
tests/unit/test_fee_payout_migration.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _run_alembic(root: Path, database_url: str, revision: str) -> None:
|
||||
env = os.environ.copy()
|
||||
env["DATABASE_URL"] = database_url
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", revision],
|
||||
cwd=root,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_fee_payout_checkpoint_migration_preserves_existing_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
database_path = tmp_path / "migration.db"
|
||||
database_url = f"sqlite+aiosqlite:///{database_path}"
|
||||
_run_alembic(root, database_url, "c6d7e8f9a0b1")
|
||||
|
||||
with sqlite3.connect(database_path) as connection:
|
||||
result = connection.execute(
|
||||
"UPDATE routstr_fees SET accumulated_msats = 5000, "
|
||||
"total_paid_msats = 1000, last_paid_at = 123 WHERE id = 1"
|
||||
)
|
||||
assert result.rowcount == 1
|
||||
connection.commit()
|
||||
|
||||
_run_alembic(root, database_url, "head")
|
||||
|
||||
with sqlite3.connect(database_path) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT accumulated_msats, total_paid_msats, last_paid_at, "
|
||||
"payout_in_progress_msats, payout_started_at "
|
||||
"FROM routstr_fees WHERE id = 1"
|
||||
).fetchone()
|
||||
|
||||
assert row == (5000, 1000, 123, 0, None)
|
||||
74
tests/unit/test_lnurl_melt_timeout.py
Normal file
74
tests/unit/test_lnurl_melt_timeout.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""raw_send_to_lnurl() must not hang forever on an unresponsive mint.
|
||||
|
||||
The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung
|
||||
mint would block the melt (and the payout loop) indefinitely. raw_send_to_lnurl
|
||||
now wraps wallet.melt() in asyncio.wait_for(MELT_TIMEOUT_SECONDS) and surfaces a
|
||||
timeout as LNURLError instead of hanging.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.payment import lnurl
|
||||
from routstr.payment.lnurl import LNURLError, raw_send_to_lnurl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_send_to_lnurl_times_out_on_hung_melt() -> None:
|
||||
proofs = [MagicMock(amount=1000)]
|
||||
|
||||
wallet = MagicMock()
|
||||
wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q"))
|
||||
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
|
||||
|
||||
async def _hang(**kwargs: object) -> None:
|
||||
await asyncio.sleep(5) # far longer than the patched timeout
|
||||
|
||||
wallet.melt = AsyncMock(side_effect=_hang)
|
||||
|
||||
lnurl_data = {
|
||||
"callback_url": "https://ln.tld/cb",
|
||||
"min_sendable": 1_000,
|
||||
"max_sendable": 100_000_000,
|
||||
}
|
||||
|
||||
with patch.object(lnurl, "MELT_TIMEOUT_SECONDS", 0.05), patch(
|
||||
"routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=lnurl_data)
|
||||
), patch(
|
||||
"routstr.payment.lnurl.get_lnurl_invoice",
|
||||
AsyncMock(return_value=("lnbc1...", {})),
|
||||
):
|
||||
with pytest.raises(LNURLError, match="Melt timed out"):
|
||||
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_send_to_lnurl_succeeds_within_timeout() -> None:
|
||||
"""A prompt melt still returns the net amount, unaffected by the guard."""
|
||||
proofs = [MagicMock(amount=1000)]
|
||||
|
||||
wallet = MagicMock()
|
||||
wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q"))
|
||||
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
|
||||
wallet.melt = AsyncMock(return_value=MagicMock())
|
||||
|
||||
lnurl_data = {
|
||||
"callback_url": "https://ln.tld/cb",
|
||||
"min_sendable": 1_000,
|
||||
"max_sendable": 100_000_000,
|
||||
}
|
||||
|
||||
with patch.object(lnurl, "MELT_TIMEOUT_SECONDS", 5), patch(
|
||||
"routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=lnurl_data)
|
||||
), patch(
|
||||
"routstr.payment.lnurl.get_lnurl_invoice",
|
||||
AsyncMock(return_value=("lnbc1...", {})),
|
||||
):
|
||||
paid = await raw_send_to_lnurl(
|
||||
wallet, proofs, "owner@ln.tld", "sat", amount=1000
|
||||
)
|
||||
|
||||
assert paid > 0
|
||||
wallet.melt.assert_awaited_once()
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
from typing import Any, AsyncIterator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
@@ -21,6 +22,7 @@ from routstr.core.db import ApiKey # noqa: E402
|
||||
from routstr.payment.cost_calculation import CostData # noqa: E402
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||
from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
@@ -1308,3 +1310,320 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N
|
||||
"fireworks_ai/accounts/fireworks/models/glm-5"
|
||||
)
|
||||
assert captured_kwargs["api_base"] == "https://api.fireworks.ai/inference/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# X-Cashu redemption error taxonomy (unreachable mint + string codes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
httpx.ConnectError("All connection attempts failed"),
|
||||
MintConnectionError("Cashu mint is unreachable"),
|
||||
TimeoutError("timed out connecting to mint"),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_mint_unreachable_returns_503(
|
||||
handler_name: str, error: Exception
|
||||
) -> None:
|
||||
"""Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable,
|
||||
not a generic 400 cashu_error."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "mint_unreachable"
|
||||
assert body["error"]["message"] == "Cashu mint is unreachable"
|
||||
assert body["error"]["code"] == "cashu_mint_unreachable"
|
||||
if str(error) != body["error"]["message"]:
|
||||
assert str(error) not in body["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"error",
|
||||
"expected_status",
|
||||
"expected_type",
|
||||
"expected_message",
|
||||
"expected_code",
|
||||
),
|
||||
[
|
||||
(
|
||||
ValueError("Mint Error: Token already spent"),
|
||||
400,
|
||||
"token_already_spent",
|
||||
"Cashu token already spent",
|
||||
"cashu_token_already_spent",
|
||||
),
|
||||
(
|
||||
ValueError("invalid token: could not decode"),
|
||||
400,
|
||||
"invalid_token",
|
||||
"Invalid Cashu token",
|
||||
"invalid_cashu_token",
|
||||
),
|
||||
(
|
||||
# Fee/swap failures now map to a granular 422 on the X-Cashu path,
|
||||
# matching the bearer path (previously flattened to 400).
|
||||
ValueError(
|
||||
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||
),
|
||||
422,
|
||||
"mint_error",
|
||||
"Token value is too small to cover swap fees",
|
||||
"cashu_token_swap_fees_exceed_amount",
|
||||
),
|
||||
(
|
||||
ValueError("Failed to melt token from foreign mint http://m: boom"),
|
||||
422,
|
||||
"mint_error",
|
||||
"Failed to swap token from foreign mint",
|
||||
"cashu_foreign_mint_swap_failed",
|
||||
),
|
||||
(
|
||||
ValueError("some unexpected wallet condition"),
|
||||
400,
|
||||
"cashu_error",
|
||||
"Failed to redeem Cashu token",
|
||||
"cashu_token_redemption_failed",
|
||||
),
|
||||
(
|
||||
# Non-ValueError faults are internal errors (500), not token errors.
|
||||
RuntimeError("db exploded"),
|
||||
500,
|
||||
"api_error",
|
||||
"Internal error during token redemption",
|
||||
"internal_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_error_code_is_stable_string(
|
||||
handler_name: str,
|
||||
error: Exception,
|
||||
expected_status: int,
|
||||
expected_type: str,
|
||||
expected_message: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
"""X-Cashu emits a stable string ``code`` on every branch, matching the
|
||||
bearer path's taxonomy instead of an int HTTP status."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == expected_type
|
||||
assert body["error"]["message"] == expected_message
|
||||
assert body["error"]["code"] == expected_code
|
||||
assert isinstance(body["error"]["code"], str)
|
||||
if str(error) != expected_message:
|
||||
assert str(error) not in body["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "forward_attr"),
|
||||
[
|
||||
("handle_x_cashu", "forward_x_cashu_request"),
|
||||
("handle_x_cashu_responses", "forward_x_cashu_responses_request"),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_transport_error_after_redemption_is_not_retryable(
|
||||
handler_name: str, forward_attr: str
|
||||
) -> None:
|
||||
"""A transport failure while forwarding (after the token is spent) maps to
|
||||
502 upstream_error, never a retryable cashu_mint_unreachable."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.base.recieve_token",
|
||||
new=AsyncMock(return_value=(5_000, "sat", "https://mint")),
|
||||
),
|
||||
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
|
||||
patch.object(
|
||||
provider,
|
||||
forward_attr,
|
||||
new=AsyncMock(side_effect=httpx.ConnectError("upstream down")),
|
||||
),
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "upstream_error"
|
||||
assert body["error"]["code"] != "cashu_mint_unreachable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||
)
|
||||
async def test_x_cashu_token_consumed_returns_500_and_no_echo(
|
||||
handler_name: str,
|
||||
) -> None:
|
||||
"""A post-redemption failure (token spent, crediting/minting failed) is a
|
||||
non-retryable 500 token_consumed and must NOT echo the spent token back."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token",
|
||||
new=AsyncMock(side_effect=TokenConsumedError("credit failed")),
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "token_consumed"
|
||||
assert body["error"]["code"] == "cashu_token_consumed"
|
||||
assert "X-Cashu" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "error", "echoed"),
|
||||
[
|
||||
# Spent token: must NOT be echoed.
|
||||
("handle_x_cashu", ValueError("Token already spent"), False),
|
||||
("handle_x_cashu_responses", ValueError("Token already spent"), False),
|
||||
# Unspent but unreachable mint: echo so the client can retry the token.
|
||||
("handle_x_cashu", MintConnectionError("mint down"), True),
|
||||
("handle_x_cashu_responses", MintConnectionError("mint down"), True),
|
||||
# Consumed token (post-redemption): must NOT be echoed.
|
||||
("handle_x_cashu", TokenConsumedError("credit failed"), False),
|
||||
("handle_x_cashu_responses", TokenConsumedError("credit failed"), False),
|
||||
],
|
||||
)
|
||||
async def test_x_cashu_echoes_token_only_when_recoverable(
|
||||
handler_name: str, error: Exception, echoed: bool
|
||||
) -> None:
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with patch(
|
||||
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
if echoed:
|
||||
assert response.headers.get("X-Cashu") == "cashuAtoken"
|
||||
else:
|
||||
assert "X-Cashu" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "forward_attr"),
|
||||
[
|
||||
("handle_x_cashu", "forward_x_cashu_request"),
|
||||
("handle_x_cashu_responses", "forward_x_cashu_responses_request"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("amount", [0, -5])
|
||||
async def test_x_cashu_zero_value_rejected_not_forwarded(
|
||||
handler_name: str, forward_attr: str, amount: int
|
||||
) -> None:
|
||||
"""A token that redeems to <= 0 must be rejected as cashu_token_zero_value
|
||||
(400) and NEVER forwarded as a free request — the X-Cashu path lacked the
|
||||
guard that credit_balance has."""
|
||||
provider = _make_provider()
|
||||
model = _make_model()
|
||||
request = _make_request()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.base.recieve_token",
|
||||
new=AsyncMock(return_value=(amount, "sat", "https://mint")),
|
||||
),
|
||||
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
|
||||
patch.object(
|
||||
provider,
|
||||
forward_attr,
|
||||
new=AsyncMock(side_effect=AssertionError("must not forward a zero-value token")),
|
||||
),
|
||||
):
|
||||
handler = getattr(provider, handler_name)
|
||||
response = await handler(
|
||||
request=request,
|
||||
x_cashu_token="cashuAtoken",
|
||||
path="v1/chat/completions",
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
body = json.loads(bytes(response.body))
|
||||
assert body["error"]["type"] == "cashu_error"
|
||||
assert (
|
||||
body["error"]["message"]
|
||||
== "Failed to redeem Cashu token: token yielded no value"
|
||||
)
|
||||
assert body["error"]["code"] == "cashu_token_zero_value"
|
||||
# Spent-to-zero token must not be echoed back for retry.
|
||||
assert "X-Cashu" not in response.headers
|
||||
|
||||
154
tests/unit/test_periodic_payout.py
Normal file
154
tests/unit/test_periodic_payout.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tests for periodic_payout() resilience fixes.
|
||||
|
||||
Covers two regressions from the auto-payout / primary-mint audit
|
||||
(docs/auto-payout-primary-mint-failure-report.md):
|
||||
|
||||
1. periodic_payout() must include settings.primary_mint even when it is not
|
||||
listed in settings.cashu_mints, matching fetch_all_balances(); otherwise
|
||||
primary-mint funds never auto-payout.
|
||||
2. A failure on one mint/unit must not abort payout for the remaining
|
||||
mint/units in the same cycle (the try/except is now per mint/unit).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
# Sentinel interval used to break the otherwise-infinite payout loop after
|
||||
# exactly one full cycle.
|
||||
_INTERVAL = 987
|
||||
|
||||
|
||||
class _LoopBreak(Exception):
|
||||
"""Raised via the patched sleep to stop periodic_payout after one cycle."""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session(): # type: ignore[no-untyped-def]
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _one_cycle_sleep() -> Callable[[float], Coroutine[Any, Any, None]]:
|
||||
"""Return an async sleep stub that lets exactly one payout cycle run.
|
||||
|
||||
The top-of-loop sleep uses the sentinel interval; the second time it is
|
||||
seen (start of the second cycle) we raise to break out. The inner
|
||||
``asyncio.sleep(5)`` pass-through is ignored.
|
||||
"""
|
||||
seen = {"interval": 0}
|
||||
|
||||
async def _sleep(seconds: float) -> None:
|
||||
if seconds == _INTERVAL:
|
||||
seen["interval"] += 1
|
||||
if seen["interval"] >= 2:
|
||||
raise _LoopBreak()
|
||||
|
||||
return _sleep
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> None:
|
||||
"""primary_mint absent from cashu_mints is still paid out."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
get_wallet = AsyncMock(return_value=MagicMock())
|
||||
raw_send = AsyncMock(return_value=1000)
|
||||
|
||||
with patch.object(settings, "cashu_mints", []), patch.object(
|
||||
settings, "primary_mint", "http://primary:3338"
|
||||
), patch.object(settings, "receive_ln_address", "owner@ln.tld"), patch.object(
|
||||
settings, "payout_interval_seconds", _INTERVAL
|
||||
), patch.object(settings, "min_payout_sat", 10), patch(
|
||||
"routstr.wallet.asyncio.sleep", _one_cycle_sleep()
|
||||
), patch("routstr.wallet.db.create_session", _fake_session), patch(
|
||||
"routstr.wallet.get_wallet", get_wallet
|
||||
), patch(
|
||||
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||
MagicMock(return_value=[MagicMock(amount=100_000)]),
|
||||
), 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.raw_send_to_lnurl", raw_send):
|
||||
with pytest.raises(_LoopBreak):
|
||||
await periodic_payout()
|
||||
|
||||
processed = {call.args[0] for call in get_wallet.await_args_list}
|
||||
assert processed == {"http://primary:3338"}
|
||||
assert raw_send.await_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_payout_isolates_failing_mint() -> None:
|
||||
"""A failing mint does not prevent payout for the other mints."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
async def _get_wallet(mint_url: str, unit: str) -> MagicMock:
|
||||
if mint_url == "http://bad:3338":
|
||||
raise RuntimeError("mint unreachable")
|
||||
return MagicMock()
|
||||
|
||||
get_wallet = AsyncMock(side_effect=_get_wallet)
|
||||
raw_send = AsyncMock(return_value=1000)
|
||||
|
||||
with patch.object(
|
||||
settings, "cashu_mints", ["http://bad:3338", "http://good:3338"]
|
||||
), patch.object(settings, "primary_mint", "http://good:3338"), patch.object(
|
||||
settings, "receive_ln_address", "owner@ln.tld"
|
||||
), patch.object(settings, "payout_interval_seconds", _INTERVAL), patch.object(
|
||||
settings, "min_payout_sat", 10
|
||||
), patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()), patch(
|
||||
"routstr.wallet.db.create_session", _fake_session
|
||||
), patch("routstr.wallet.get_wallet", get_wallet), patch(
|
||||
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||
MagicMock(return_value=[MagicMock(amount=100_000)]),
|
||||
), 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.raw_send_to_lnurl", raw_send):
|
||||
with pytest.raises(_LoopBreak):
|
||||
await periodic_payout()
|
||||
|
||||
# The bad mint raised on get_wallet for both units, yet the good mint was
|
||||
# still reached and paid out for both units — failures are isolated.
|
||||
good_calls = [
|
||||
c for c in get_wallet.await_args_list if c.args[0] == "http://good:3338"
|
||||
]
|
||||
assert len(good_calls) == 2 # sat + msat
|
||||
assert raw_send.await_count == 2 # good mint paid for both units
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_payout_handles_session_creation_failure() -> None:
|
||||
"""A db.create_session failure is logged and the payout loop continues."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
create_session = MagicMock(side_effect=RuntimeError("db unavailable"))
|
||||
logger = MagicMock()
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]), patch.object(
|
||||
settings, "primary_mint", "http://mint:3338"
|
||||
), patch.object(settings, "receive_ln_address", "owner@ln.tld"), patch.object(
|
||||
settings, "payout_interval_seconds", _INTERVAL
|
||||
), patch(
|
||||
"routstr.wallet.asyncio.sleep", _one_cycle_sleep()
|
||||
), patch(
|
||||
"routstr.wallet.db.create_session", create_session
|
||||
), patch("routstr.wallet.logger", logger):
|
||||
with pytest.raises(_LoopBreak):
|
||||
await periodic_payout()
|
||||
|
||||
create_session.assert_called_once()
|
||||
logger.error.assert_called_once()
|
||||
message = logger.error.call_args.args[0]
|
||||
extra = logger.error.call_args.kwargs["extra"]
|
||||
assert message == "Error in periodic payout cycle: RuntimeError"
|
||||
assert extra == {"error": "db unavailable"}
|
||||
126
tests/unit/test_periodic_refund_sweep.py
Normal file
126
tests/unit/test_periodic_refund_sweep.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr import wallet
|
||||
from routstr.core.db import CashuTransaction
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def refund_db(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> AsyncGenerator[AsyncEngine, None]:
|
||||
engine = _make_engine()
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
monkeypatch.setattr(wallet.db, "create_session", create_session)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _store(
|
||||
engine: AsyncEngine,
|
||||
*,
|
||||
token: str,
|
||||
created_at: int,
|
||||
typ: str = "out",
|
||||
collected: bool = False,
|
||||
swept: bool = False,
|
||||
) -> None:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add(
|
||||
CashuTransaction(
|
||||
token=token,
|
||||
amount=10,
|
||||
unit="sat",
|
||||
type=typ,
|
||||
created_at=created_at,
|
||||
collected=collected,
|
||||
swept=swept,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _transactions(engine: AsyncEngine) -> dict[str, CashuTransaction]:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
results = await session.exec(select(CashuTransaction))
|
||||
return {transaction.token: transaction for transaction in results.all()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_sweep_only_processes_eligible_outbound_transactions(
|
||||
refund_db: AsyncEngine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cutoff = 1_000
|
||||
await _store(refund_db, token="eligible", created_at=cutoff - 1)
|
||||
await _store(refund_db, token="too-new", created_at=cutoff)
|
||||
await _store(
|
||||
refund_db, token="collected", created_at=cutoff - 1, collected=True
|
||||
)
|
||||
await _store(refund_db, token="swept", created_at=cutoff - 1, swept=True)
|
||||
await _store(refund_db, token="inbound", created_at=cutoff - 1, typ="in")
|
||||
receive_token = AsyncMock()
|
||||
monkeypatch.setattr(wallet, "recieve_token", receive_token)
|
||||
|
||||
await wallet._refund_sweep_once(cutoff)
|
||||
|
||||
receive_token.assert_awaited_once_with("eligible")
|
||||
transactions = await _transactions(refund_db)
|
||||
assert transactions["eligible"].swept is True
|
||||
assert transactions["too-new"].swept is False
|
||||
assert transactions["collected"].collected is True
|
||||
assert transactions["collected"].swept is False
|
||||
assert transactions["swept"].swept is True
|
||||
assert transactions["inbound"].swept is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_sweep_persists_success_and_isolates_token_failures(
|
||||
refund_db: AsyncEngine, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cutoff = 1_000
|
||||
for token in ("success", "already-spent", "temporary-failure"):
|
||||
await _store(refund_db, token=token, created_at=cutoff - 1)
|
||||
|
||||
async def receive_token(token: str) -> None:
|
||||
if token == "already-spent":
|
||||
raise RuntimeError("Token already spent")
|
||||
if token == "temporary-failure":
|
||||
raise RuntimeError("mint unavailable")
|
||||
|
||||
receive = AsyncMock(side_effect=receive_token)
|
||||
monkeypatch.setattr(wallet, "recieve_token", receive)
|
||||
|
||||
await wallet._refund_sweep_once(cutoff)
|
||||
|
||||
assert receive.await_count == 3
|
||||
transactions = await _transactions(refund_db)
|
||||
assert transactions["success"].swept is True
|
||||
assert transactions["success"].collected is False
|
||||
assert transactions["already-spent"].collected is True
|
||||
assert transactions["already-spent"].swept is False
|
||||
assert transactions["temporary-failure"].collected is False
|
||||
assert transactions["temporary-failure"].swept is False
|
||||
141
tests/unit/test_proxy_tinfoil_attestation_routing.py
Normal file
141
tests/unit/test_proxy_tinfoil_attestation_routing.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from routstr import proxy as proxy_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(proxy_module.proxy_router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attestation_get_routes_directly_to_tinfoil_provider(
|
||||
monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI
|
||||
) -> None:
|
||||
non_tinfoil = MagicMock()
|
||||
non_tinfoil.provider_type = "openai"
|
||||
non_tinfoil.prepare_headers = MagicMock(return_value={})
|
||||
non_tinfoil.forward_get_request = AsyncMock(
|
||||
return_value=Response(status_code=404, content=b"wrong upstream")
|
||||
)
|
||||
|
||||
tinfoil = MagicMock()
|
||||
tinfoil.provider_type = "tinfoil"
|
||||
tinfoil.prepare_headers = MagicMock(return_value={"accept": "application/json"})
|
||||
tinfoil.forward_get_request = AsyncMock(
|
||||
return_value=Response(status_code=200, content=b'{"attestation":true}')
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil])
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=proxy_app), # type: ignore[arg-type]
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.get("/attestation")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b'{"attestation":true}'
|
||||
non_tinfoil.forward_get_request.assert_not_called()
|
||||
tinfoil.forward_get_request.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tee_attestation_get_routes_directly_to_tinfoil_provider(
|
||||
monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI
|
||||
) -> None:
|
||||
non_tinfoil = MagicMock()
|
||||
non_tinfoil.provider_type = "openrouter"
|
||||
non_tinfoil.prepare_headers = MagicMock(return_value={})
|
||||
non_tinfoil.forward_get_request = AsyncMock(
|
||||
return_value=Response(status_code=404, content=b"wrong upstream")
|
||||
)
|
||||
|
||||
tinfoil = MagicMock()
|
||||
tinfoil.provider_type = "tinfoil"
|
||||
tinfoil.prepare_headers = MagicMock(return_value={"accept": "application/json"})
|
||||
tinfoil.forward_get_request = AsyncMock(
|
||||
return_value=Response(status_code=200, content=b'{"tee":true}')
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_module, "_upstreams", [non_tinfoil, tinfoil])
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=proxy_app), # type: ignore[arg-type]
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.get("/tee/attestation")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b'{"tee":true}'
|
||||
non_tinfoil.forward_get_request.assert_not_called()
|
||||
tinfoil.forward_get_request.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["attestation/", "tee/attestation/"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_attestation_trailing_slash_routes_directly_to_tinfoil(
|
||||
monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI, path: str
|
||||
) -> None:
|
||||
tinfoil = MagicMock()
|
||||
tinfoil.provider_type = "tinfoil"
|
||||
tinfoil.prepare_headers = MagicMock(return_value={})
|
||||
tinfoil.forward_get_request = AsyncMock(return_value=Response(status_code=200))
|
||||
monkeypatch.setattr(proxy_module, "_upstreams", [tinfoil])
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=proxy_app), # type: ignore[arg-type]
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.get(f"/{path}")
|
||||
|
||||
assert response.status_code == 200
|
||||
tinfoil.forward_get_request.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["attestation/foo", "attestationjunk"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_attestation_prefix_does_not_bypass_authentication(
|
||||
monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI, path: str
|
||||
) -> None:
|
||||
tinfoil = MagicMock()
|
||||
tinfoil.provider_type = "tinfoil"
|
||||
tinfoil.forward_get_request = AsyncMock()
|
||||
monkeypatch.setattr(proxy_module, "_upstreams", [tinfoil])
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=proxy_app), # type: ignore[arg-type]
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.get(f"/{path}")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["type"] == "invalid_model"
|
||||
tinfoil.forward_get_request.assert_not_awaited()
|
||||
|
||||
|
||||
def test_attestation_upstream_selection_is_tinfoil_only() -> None:
|
||||
non_tinfoil = MagicMock(provider_type="openai")
|
||||
tinfoil = MagicMock(provider_type="tinfoil")
|
||||
|
||||
assert proxy_module._select_unauthenticated_get_upstreams(
|
||||
"attestation", [non_tinfoil, tinfoil]
|
||||
) == [tinfoil]
|
||||
assert proxy_module._select_unauthenticated_get_upstreams(
|
||||
"tee/attestation", [non_tinfoil, tinfoil]
|
||||
) == [tinfoil]
|
||||
assert proxy_module._select_unauthenticated_get_upstreams(
|
||||
"attestation/", [non_tinfoil, tinfoil]
|
||||
) == [tinfoil]
|
||||
assert proxy_module._select_unauthenticated_get_upstreams(
|
||||
"attestationjunk", [non_tinfoil, tinfoil]
|
||||
) == [non_tinfoil, tinfoil]
|
||||
118
tests/unit/test_refund_sweep.py
Normal file
118
tests/unit/test_refund_sweep.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import CashuTransaction
|
||||
from routstr.wallet import refund_sweep_once
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory(
|
||||
tmp_path: Path,
|
||||
) -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'refunds.db'}")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _insert(
|
||||
factory: async_sessionmaker[AsyncSession], *rows: CashuTransaction
|
||||
) -> None:
|
||||
async with factory() as session:
|
||||
session.add_all(rows)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _load(
|
||||
factory: async_sessionmaker[AsyncSession],
|
||||
) -> dict[str, CashuTransaction]:
|
||||
async with factory() as session:
|
||||
result = await session.exec(select(CashuTransaction))
|
||||
return {row.token: row for row in result.all()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refund_sweep_only_processes_expired_eligible_outgoing_tokens(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
rows = [
|
||||
CashuTransaction(
|
||||
token="eligible", amount=1, unit="sat", type="out", created_at=800
|
||||
),
|
||||
CashuTransaction(
|
||||
token="fresh", amount=1, unit="sat", type="out", created_at=950
|
||||
),
|
||||
CashuTransaction(
|
||||
token="boundary", amount=1, unit="sat", type="out", created_at=900
|
||||
),
|
||||
CashuTransaction(
|
||||
token="collected",
|
||||
amount=1,
|
||||
unit="sat",
|
||||
type="out",
|
||||
created_at=800,
|
||||
collected=True,
|
||||
),
|
||||
CashuTransaction(
|
||||
token="swept", amount=1, unit="sat", type="out", created_at=800, swept=True
|
||||
),
|
||||
CashuTransaction(
|
||||
token="incoming", amount=1, unit="sat", type="in", created_at=800
|
||||
),
|
||||
]
|
||||
await _insert(session_factory, *rows)
|
||||
receive = AsyncMock()
|
||||
with (
|
||||
patch("routstr.wallet.db.create_session", side_effect=session_factory),
|
||||
patch("routstr.wallet.settings.refund_sweep_ttl_seconds", 100),
|
||||
patch("routstr.wallet.time.time", return_value=1000),
|
||||
patch("routstr.wallet.recieve_token", receive),
|
||||
):
|
||||
await refund_sweep_once()
|
||||
|
||||
receive.assert_awaited_once_with("eligible")
|
||||
loaded = await _load(session_factory)
|
||||
assert loaded["eligible"].swept is True
|
||||
assert loaded["fresh"].swept is False
|
||||
assert loaded["boundary"].swept is False
|
||||
assert loaded["collected"].swept is False
|
||||
assert loaded["swept"].swept is True
|
||||
assert loaded["incoming"].swept is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("error", "collected"),
|
||||
[
|
||||
(RuntimeError("token already spent"), True),
|
||||
(RuntimeError("mint unavailable"), False),
|
||||
],
|
||||
)
|
||||
async def test_refund_sweep_records_terminal_but_not_transient_failures(
|
||||
session_factory: async_sessionmaker[AsyncSession], error: Exception, collected: bool
|
||||
) -> None:
|
||||
await _insert(
|
||||
session_factory,
|
||||
CashuTransaction(
|
||||
token="refund", amount=1, unit="sat", type="out", created_at=800
|
||||
),
|
||||
)
|
||||
with (
|
||||
patch("routstr.wallet.db.create_session", side_effect=session_factory),
|
||||
patch("routstr.wallet.settings.refund_sweep_ttl_seconds", 100),
|
||||
patch("routstr.wallet.time.time", return_value=1000),
|
||||
patch("routstr.wallet.recieve_token", AsyncMock(side_effect=error)),
|
||||
):
|
||||
await refund_sweep_once()
|
||||
|
||||
refund = (await _load(session_factory))["refund"]
|
||||
assert refund.collected is collected
|
||||
assert refund.swept is False
|
||||
@@ -1,95 +0,0 @@
|
||||
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
|
||||
168
tests/unit/test_streaming_billing_finalization.py
Normal file
168
tests/unit/test_streaming_billing_finalization.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import get_reservation_snapshot, release_reservation
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_reservation_clears_reserved_balance() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite://")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
key = ApiKey(
|
||||
hashed_key="key", balance=1_000, reserved_balance=500, reserved_at=123
|
||||
)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
snapshot = await get_reservation_snapshot(key, session)
|
||||
assert await release_reservation(snapshot, session, 500) is True
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 0
|
||||
assert key.reserved_at is None
|
||||
assert await release_reservation(snapshot, session, 500) is True
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_reservation_preserves_other_concurrent_reservations() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite://")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
key = ApiKey(
|
||||
hashed_key="key", balance=1_000, reserved_balance=800, reserved_at=123
|
||||
)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
first_snapshot = await get_reservation_snapshot(key, session)
|
||||
second_snapshot = await get_reservation_snapshot(key, session)
|
||||
assert await release_reservation(first_snapshot, session, 400) is True
|
||||
assert await release_reservation(second_snapshot, session, 400) is True
|
||||
await session.refresh(key)
|
||||
assert key.reserved_balance == 0
|
||||
assert key.reserved_at is None
|
||||
assert await release_reservation(first_snapshot, session, 400) is True
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_reservation_updates_parent_and_child_atomically() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite://")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
parent = ApiKey(
|
||||
hashed_key="parent", balance=1_000, reserved_balance=500, reserved_at=123
|
||||
)
|
||||
child = ApiKey(
|
||||
hashed_key="child",
|
||||
parent_key_hash="parent",
|
||||
balance=0,
|
||||
reserved_balance=500,
|
||||
reserved_at=123,
|
||||
)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add_all([parent, child])
|
||||
await session.commit()
|
||||
|
||||
snapshot = await get_reservation_snapshot(child, session)
|
||||
assert await release_reservation(snapshot, session, 500) is True
|
||||
await session.refresh(parent)
|
||||
await session.refresh(child)
|
||||
assert (parent.reserved_balance, child.reserved_balance) == (0, 0)
|
||||
assert (parent.reserved_at, child.reserved_at) == (None, None)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_reservation_rolls_back_partial_parent_child_update() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite://")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
parent = ApiKey(hashed_key="parent", balance=1_000, reserved_balance=500)
|
||||
child = ApiKey(
|
||||
hashed_key="child",
|
||||
parent_key_hash="parent",
|
||||
balance=0,
|
||||
reserved_balance=100,
|
||||
)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add_all([parent, child])
|
||||
await session.commit()
|
||||
|
||||
snapshot = await get_reservation_snapshot(child, session)
|
||||
assert await release_reservation(snapshot, session, 500) is False
|
||||
await session.refresh(parent)
|
||||
await session.refresh(child)
|
||||
assert (parent.reserved_balance, child.reserved_balance) == (500, 100)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_billing_error_releases_reservation_and_propagates() -> None:
|
||||
provider = BaseUpstreamProvider(
|
||||
base_url="https://api.example.com", api_key="test-key"
|
||||
)
|
||||
|
||||
async def aiter_bytes() -> AsyncGenerator[bytes, None]:
|
||||
yield b"data: [DONE]\n\n"
|
||||
|
||||
upstream_response = MagicMock()
|
||||
upstream_response.status_code = 200
|
||||
upstream_response.headers = {"content-type": "text/event-stream"}
|
||||
upstream_response.aiter_bytes = aiter_bytes
|
||||
|
||||
key = MagicMock(spec=ApiKey)
|
||||
key.hashed_key = "test-key-hash"
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=key)
|
||||
session.rollback = AsyncMock()
|
||||
session_context = MagicMock()
|
||||
session_context.__aenter__ = AsyncMock(return_value=session)
|
||||
session_context.__aexit__ = AsyncMock(return_value=None)
|
||||
release = AsyncMock(return_value=True)
|
||||
reservation_snapshot = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.upstream.base.adjust_payment_for_tokens",
|
||||
AsyncMock(side_effect=SQLAlchemyError("database unavailable")),
|
||||
),
|
||||
patch(
|
||||
"routstr.upstream.base.get_reservation_snapshot",
|
||||
AsyncMock(return_value=reservation_snapshot),
|
||||
),
|
||||
patch("routstr.upstream.base.release_reservation", release),
|
||||
patch("routstr.upstream.base.create_session", return_value=session_context),
|
||||
):
|
||||
response = await provider.handle_streaming_chat_completion(
|
||||
response=upstream_response,
|
||||
key=key,
|
||||
max_cost_for_model=500,
|
||||
background_tasks=MagicMock(),
|
||||
)
|
||||
|
||||
with pytest.raises(SQLAlchemyError, match="database unavailable"):
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
|
||||
session.rollback.assert_awaited_once()
|
||||
release.assert_awaited_once_with(reservation_snapshot, session, 500)
|
||||
749
tests/unit/test_tinfoil_integration.py
Normal file
749
tests/unit/test_tinfoil_integration.py
Normal file
@@ -0,0 +1,749 @@
|
||||
"""Unit tests for Tinfoil direct integration.
|
||||
|
||||
Covers the EHBP usage-metrics header parser, the proxy header stripping, the
|
||||
enclave URL override, and the TinfoilUpstreamProvider model fetching/forwarding
|
||||
target logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.ehbp import (
|
||||
_PROXY_ONLY_HEADERS,
|
||||
_compute_ehbp_actual_cost,
|
||||
_prepare_ehbp_upstream_headers,
|
||||
_resolve_ehbp_target_url,
|
||||
_strip_proxy_headers,
|
||||
parse_tinfoil_usage_metrics,
|
||||
)
|
||||
from routstr.upstream.tinfoil import (
|
||||
TinfoilModel,
|
||||
TinfoilUpstreamProvider,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_tinfoil_usage_metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseTinfoilUsageMetrics:
|
||||
def test_full_header(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics("prompt=67,completion=42,total=109")
|
||||
assert result == {
|
||||
"prompt_tokens": 67,
|
||||
"completion_tokens": 42,
|
||||
"total_tokens": 109,
|
||||
}
|
||||
|
||||
def test_without_total(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics("prompt=10,completion=5")
|
||||
assert result == {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert parse_tinfoil_usage_metrics(None) is None
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert parse_tinfoil_usage_metrics("") is None
|
||||
|
||||
def test_malformed(self) -> None:
|
||||
assert parse_tinfoil_usage_metrics("garbage") is None
|
||||
|
||||
def test_missing_completion(self) -> None:
|
||||
assert parse_tinfoil_usage_metrics("prompt=10") is None
|
||||
|
||||
def test_extra_whitespace(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics(
|
||||
"prompt = 100 , completion = 200 , total = 300"
|
||||
)
|
||||
assert result == {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 200,
|
||||
"total_tokens": 300,
|
||||
}
|
||||
|
||||
def test_with_model_field(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics(
|
||||
"prompt=42,completion=10,total=52,model=llama3-3-70b"
|
||||
)
|
||||
assert result == {
|
||||
"prompt_tokens": 42,
|
||||
"completion_tokens": 10,
|
||||
"total_tokens": 52,
|
||||
"model": "llama3-3-70b",
|
||||
}
|
||||
|
||||
def test_with_model_no_total(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics(
|
||||
"prompt=67,completion=42,model=gpt-oss-120b"
|
||||
)
|
||||
assert result == {
|
||||
"prompt_tokens": 67,
|
||||
"completion_tokens": 42,
|
||||
"model": "gpt-oss-120b",
|
||||
}
|
||||
|
||||
def test_model_with_dashes_and_numbers(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics(
|
||||
"prompt=1,completion=1,total=2,model=kimi-k2-6"
|
||||
)
|
||||
assert result is not None
|
||||
assert result["model"] == "kimi-k2-6"
|
||||
|
||||
def test_model_with_extra_fields(self) -> None:
|
||||
result = parse_tinfoil_usage_metrics(
|
||||
"prompt=69,completion=20,total=89,"
|
||||
"cached_prompt_tokens=64,uncached_prompt_tokens=5,"
|
||||
"model=kimi-k2-6"
|
||||
)
|
||||
assert result is not None
|
||||
assert result["prompt_tokens"] == 69
|
||||
assert result["completion_tokens"] == 20
|
||||
assert result["total_tokens"] == 89
|
||||
assert result["model"] == "kimi-k2-6"
|
||||
|
||||
def test_old_format_still_works(self) -> None:
|
||||
"""Headers without the model field (pre-PR #385) still parse."""
|
||||
result = parse_tinfoil_usage_metrics(
|
||||
"prompt=67,completion=42,total=109"
|
||||
)
|
||||
assert result == {
|
||||
"prompt_tokens": 67,
|
||||
"completion_tokens": 42,
|
||||
"total_tokens": 109,
|
||||
}
|
||||
assert "model" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_proxy_headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripProxyHeaders:
|
||||
def test_strips_all_proxy_only(self) -> None:
|
||||
headers = {
|
||||
"x-routstr-model": "tinfoil-llama3-3-70b",
|
||||
"X-Tinfoil-Enclave-Url": "https://inference.tinfoil.sh",
|
||||
"X-Tinfoil-Request-Usage-Metrics": "true",
|
||||
"Authorization": "Bearer secret",
|
||||
"Ehbp-Encapsulated-Key": "abc123",
|
||||
}
|
||||
clean = _strip_proxy_headers(headers)
|
||||
assert "x-routstr-model" not in clean
|
||||
assert "X-Tinfoil-Enclave-Url" not in clean
|
||||
assert "X-Tinfoil-Request-Usage-Metrics" not in clean
|
||||
assert clean["Authorization"] == "Bearer secret"
|
||||
assert clean["Ehbp-Encapsulated-Key"] == "abc123"
|
||||
|
||||
def test_all_proxy_only_headers_covered(self) -> None:
|
||||
assert _PROXY_ONLY_HEADERS == {
|
||||
"x-routstr-model",
|
||||
"x-tinfoil-enclave-url",
|
||||
"x-tinfoil-request-usage-metrics",
|
||||
}
|
||||
|
||||
|
||||
class TestPrepareEHBPUpstreamHeaders:
|
||||
def test_strips_client_proxy_headers_before_merging_target_headers(self) -> None:
|
||||
headers = {
|
||||
"x-routstr-model": "tinfoil-llama3-3-70b",
|
||||
"X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh",
|
||||
"X-Tinfoil-Request-Usage-Metrics": "false",
|
||||
"Authorization": "Bearer upstream-key",
|
||||
"Ehbp-Encapsulated-Key": "abc123",
|
||||
}
|
||||
target_headers = {"X-Tinfoil-Request-Usage-Metrics": "true"}
|
||||
|
||||
clean = _prepare_ehbp_upstream_headers(headers, target_headers)
|
||||
|
||||
assert "x-routstr-model" not in clean
|
||||
assert "X-Tinfoil-Enclave-Url" not in clean
|
||||
assert clean["Authorization"] == "Bearer upstream-key"
|
||||
assert clean["Ehbp-Encapsulated-Key"] == "abc123"
|
||||
assert clean["X-Tinfoil-Request-Usage-Metrics"] == "true"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_ehbp_target_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveEhbpTargetUrl:
|
||||
def test_override_with_enclave_url_for_tinfoil(self) -> None:
|
||||
result = _resolve_ehbp_target_url(
|
||||
"https://default.example.com/v1/chat/completions",
|
||||
"v1/chat/completions",
|
||||
{"X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh"},
|
||||
"tinfoil",
|
||||
)
|
||||
assert result == "https://enclave.tinfoil.sh/v1/chat/completions"
|
||||
|
||||
def test_override_lowercase_header_for_tinfoil(self) -> None:
|
||||
result = _resolve_ehbp_target_url(
|
||||
"https://default.example.com/v1/chat/completions",
|
||||
"v1/chat/completions",
|
||||
{"x-tinfoil-enclave-url": "https://enclave.tinfoil.sh"},
|
||||
"tinfoil",
|
||||
)
|
||||
assert result == "https://enclave.tinfoil.sh/v1/chat/completions"
|
||||
|
||||
def test_no_override(self) -> None:
|
||||
default = "https://inference.tinfoil.sh/v1/chat/completions"
|
||||
result = _resolve_ehbp_target_url(
|
||||
default,
|
||||
"v1/chat/completions",
|
||||
{},
|
||||
"tinfoil",
|
||||
)
|
||||
assert result == default
|
||||
|
||||
def test_non_tinfoil_provider_ignores_enclave_url(self) -> None:
|
||||
default = "https://api.ppq.ai/private/v1/chat/completions"
|
||||
result = _resolve_ehbp_target_url(
|
||||
default,
|
||||
"v1/chat/completions",
|
||||
{"X-Tinfoil-Enclave-Url": "https://enclave.tinfoil.sh"},
|
||||
"ppqai",
|
||||
)
|
||||
assert result == default
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url",
|
||||
[
|
||||
"http://enclave.tinfoil.sh",
|
||||
"https://attacker.example",
|
||||
"https://tinfoil.sh.attacker.example",
|
||||
"https://127.0.0.1",
|
||||
"https://enclave.tinfoil.sh:8443",
|
||||
"https://user:pass@enclave.tinfoil.sh",
|
||||
],
|
||||
)
|
||||
def test_tinfoil_rejects_unsafe_enclave_url(self, bad_url: str) -> None:
|
||||
from routstr.core.exceptions import UpstreamError
|
||||
|
||||
with pytest.raises(UpstreamError):
|
||||
_resolve_ehbp_target_url(
|
||||
"https://default.example.com/v1/chat/completions",
|
||||
"v1/chat/completions",
|
||||
{"X-Tinfoil-Enclave-Url": bad_url},
|
||||
"tinfoil",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_ehbp_actual_cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeEhbpActualCost:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_usage_falls_back_to_max_cost(self) -> None:
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "llama3-3-70b"
|
||||
model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
result = await _compute_ehbp_actual_cost(None, model_obj, 100_000)
|
||||
assert result["total_msats"] == 100_000
|
||||
assert result["input_tokens"] == 0
|
||||
assert result["output_tokens"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_parsed_and_clamped(self) -> None:
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "llama3-3-70b"
|
||||
model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
# The actual cost from calculate_cost will be small; we just verify
|
||||
# it's clamped to min_request_msat at minimum.
|
||||
with patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=10,
|
||||
output_msats=20,
|
||||
total_msats=30,
|
||||
total_usd=0.0001,
|
||||
input_tokens=67,
|
||||
output_tokens=42,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=67,completion=42,total=109",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert result["total_msats"] == 30
|
||||
assert result["total_msats"] <= 100_000
|
||||
assert result["input_tokens"] == 67
|
||||
assert result["output_tokens"] == 42
|
||||
assert result["total_tokens"] == 109
|
||||
assert result["input_msats"] == 10
|
||||
assert result["output_msats"] == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_cost_data_falls_back(self) -> None:
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "llama3-3-70b"
|
||||
model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
with patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import MaxCostData
|
||||
|
||||
mock_calc.return_value = MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=0,completion=0",
|
||||
model_obj,
|
||||
50_000,
|
||||
)
|
||||
assert result["total_msats"] == 50_000
|
||||
assert result["input_tokens"] == 0
|
||||
assert result["output_tokens"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_match_no_actual_model_key(self) -> None:
|
||||
"""When the served model matches the requested one, no actual_model key."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "llama3-3-70b"
|
||||
model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
with patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=llama3-3-70b",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert "actual_model" not in result
|
||||
# calculate_cost called with requested model
|
||||
call_args = mock_calc.call_args
|
||||
assert call_args[0][0]["model"] == "llama3-3-70b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_match_no_actual_model_key(self) -> None:
|
||||
"""When the served upstream model matches forwarded_model_id through
|
||||
a client-facing alias, no actual_model key is set."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "tinfoil-glm-5-2" # client-facing alias
|
||||
model_obj.forwarded_model_id = "glm-5-2" # actual upstream ID
|
||||
with patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
# Tinfoil header returns the actual upstream model ID
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=glm-5-2",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert "actual_model" not in result
|
||||
# calculate_cost called with the client-facing model ID (whose
|
||||
# pricing includes the correct upstream rates)
|
||||
call_args = mock_calc.call_args
|
||||
assert call_args[0][0]["model"] == "tinfoil-glm-5-2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_mismatch_uses_actual_model_for_pricing(self) -> None:
|
||||
"""When the served model differs from the expected upstream model,
|
||||
the actual model's pricing is used."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "tinfoil-gpt-oss-120b" # client-facing alias
|
||||
model_obj.forwarded_model_id = "gpt-oss-120b" # expected upstream
|
||||
|
||||
actual_model_obj = MagicMock()
|
||||
actual_model_obj.id = "tinfoil-llama3-3-70b" # client-facing of actual
|
||||
actual_model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
|
||||
with patch(
|
||||
"routstr.proxy.get_model_instance",
|
||||
return_value=actual_model_obj,
|
||||
), patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=20,
|
||||
output_msats=40,
|
||||
total_msats=60,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
# Tinfoil served llama3-3-70b instead of gpt-oss-120b
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=llama3-3-70b",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert result["actual_model"] == "llama3-3-70b"
|
||||
assert result["total_msats"] == 60
|
||||
# calculate_cost called with the actual model's client-facing ID
|
||||
call_args = mock_calc.call_args
|
||||
assert call_args[0][0]["model"] == "tinfoil-llama3-3-70b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_mismatch_unknown_model_falls_back(self) -> None:
|
||||
"""When the served model is not in the registry, use requested model."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "gpt-oss-120b"
|
||||
model_obj.forwarded_model_id = "gpt-oss-120b"
|
||||
|
||||
with patch(
|
||||
"routstr.proxy.get_model_instance",
|
||||
return_value=None,
|
||||
), patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=nonexistent",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert "actual_model" not in result
|
||||
# calculate_cost called with the requested model (fallback)
|
||||
call_args = mock_calc.call_args
|
||||
assert call_args[0][0]["model"] == "gpt-oss-120b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_format_no_model_uses_requested(self) -> None:
|
||||
"""Old format without model field uses requested model for pricing."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "llama3-3-70b"
|
||||
model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
with patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=67,
|
||||
output_tokens=42,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=67,completion=42,total=109",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert "actual_model" not in result
|
||||
call_args = mock_calc.call_args
|
||||
assert call_args[0][0]["model"] == "llama3-3-70b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_model_match(self) -> None:
|
||||
"""Casing differences between the header and forwarded_model_id
|
||||
should not trigger a spurious mismatch."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "tinfoil-glm-5-2"
|
||||
model_obj.forwarded_model_id = "glm-5-2" # lowercase
|
||||
with patch(
|
||||
"routstr.proxy.get_model_instance"
|
||||
) as mock_get_model, patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
# Header returns uppercase — same model, different casing
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=GLM-5-2",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
assert "actual_model" not in result
|
||||
# No mismatch: requested model pricing used
|
||||
call_args = mock_calc.call_args
|
||||
assert call_args[0][0]["model"] == "tinfoil-glm-5-2"
|
||||
mock_get_model.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_date_versioned_alias_resolves_to_requested(self) -> None:
|
||||
"""When the served model is a date-versioned alias that resolves back
|
||||
to the requested model, no mismatch is propagated."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "tinfoil-glm-5-2"
|
||||
model_obj.forwarded_model_id = "glm-5-2"
|
||||
|
||||
resolved_model_obj = MagicMock()
|
||||
resolved_model_obj.id = "other-provider-glm-5-2"
|
||||
resolved_model_obj.forwarded_model_id = "glm-5-2"
|
||||
|
||||
with patch(
|
||||
"routstr.proxy.get_model_instance",
|
||||
return_value=resolved_model_obj,
|
||||
) as mock_get_model, patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
# Tinfoil returns a date-versioned ID with different casing.
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=GLM-5-2-20260415",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
# Registry resolution, rather than unconditional suffix removal,
|
||||
# establishes that this alias represents the expected model.
|
||||
assert "actual_model" not in result
|
||||
assert mock_calc.call_args[0][0]["model"] == "tinfoil-glm-5-2"
|
||||
mock_get_model.assert_called_once_with("GLM-5-2-20260415")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_date_version_is_preserved_as_identity(self) -> None:
|
||||
"""A date suffix in forwarded_model_id is meaningful and preserved."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "tinfoil-glm-5-2-20260415"
|
||||
model_obj.forwarded_model_id = "glm-5-2-20260415"
|
||||
|
||||
with patch(
|
||||
"routstr.proxy.get_model_instance"
|
||||
) as mock_get_model, patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=GLM-5-2-20260415",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
|
||||
assert "actual_model" not in result
|
||||
assert (
|
||||
mock_calc.call_args[0][0]["model"]
|
||||
== "tinfoil-glm-5-2-20260415"
|
||||
)
|
||||
mock_get_model.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_client_alias_same_upstream_identity(self) -> None:
|
||||
"""A global alias winner from another provider is not a failover when
|
||||
its forwarded model ID matches the requested upstream identity."""
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "tinfoil-glm-5-2"
|
||||
model_obj.forwarded_model_id = "glm-5-2"
|
||||
|
||||
resolved_model_obj = MagicMock()
|
||||
resolved_model_obj.id = "other-provider-glm-5-2"
|
||||
resolved_model_obj.forwarded_model_id = "GLM-5-2"
|
||||
|
||||
with patch(
|
||||
"routstr.proxy.get_model_instance",
|
||||
return_value=resolved_model_obj,
|
||||
) as mock_get_model, patch(
|
||||
"routstr.upstream.ehbp.calculate_cost",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_calc:
|
||||
from routstr.payment.cost_calculation import CostData
|
||||
|
||||
mock_calc.return_value = CostData(
|
||||
base_msats=0,
|
||||
input_msats=5,
|
||||
output_msats=10,
|
||||
total_msats=15,
|
||||
total_usd=0.0,
|
||||
input_tokens=42,
|
||||
output_tokens=10,
|
||||
)
|
||||
result = await _compute_ehbp_actual_cost(
|
||||
"prompt=42,completion=10,total=52,model=provider-alias",
|
||||
model_obj,
|
||||
100_000,
|
||||
)
|
||||
|
||||
mock_get_model.assert_called_once_with("provider-alias")
|
||||
assert "actual_model" not in result
|
||||
assert mock_calc.call_args[0][0]["model"] == "tinfoil-glm-5-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TinfoilUpstreamProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTinfoilUpstreamProvider:
|
||||
def test_provider_type_and_defaults(self) -> None:
|
||||
assert TinfoilUpstreamProvider.provider_type == "tinfoil"
|
||||
assert (
|
||||
TinfoilUpstreamProvider.default_base_url
|
||||
== "https://inference.tinfoil.sh"
|
||||
)
|
||||
assert TinfoilUpstreamProvider.supports_ehbp is True
|
||||
|
||||
def test_transform_model_name(self) -> None:
|
||||
provider = TinfoilUpstreamProvider(api_key="test")
|
||||
assert provider.transform_model_name("tinfoil/llama3-3-70b") == "llama3-3-70b"
|
||||
assert provider.transform_model_name("llama3-3-70b") == "llama3-3-70b"
|
||||
|
||||
def test_get_ehbp_forwarding_target_includes_usage_header(self) -> None:
|
||||
provider = TinfoilUpstreamProvider(api_key="test")
|
||||
model_obj = MagicMock()
|
||||
model_obj.id = "llama3-3-70b"
|
||||
model_obj.forwarded_model_id = "llama3-3-70b"
|
||||
target = provider.get_ehbp_forwarding_target("v1/chat/completions", model_obj)
|
||||
assert (
|
||||
target.headers["X-Tinfoil-Request-Usage-Metrics"] == "true"
|
||||
)
|
||||
assert "v1/chat/completions" in target.url
|
||||
|
||||
def test_get_provider_metadata(self) -> None:
|
||||
meta = TinfoilUpstreamProvider.get_provider_metadata()
|
||||
assert meta["id"] == "tinfoil"
|
||||
assert meta["name"] == "Tinfoil"
|
||||
assert meta["fixed_base_url"] is True
|
||||
|
||||
def test_tinfoil_model_pricing_parses(self) -> None:
|
||||
data = {
|
||||
"id": "llama3-3-70b",
|
||||
"context_window": 128000,
|
||||
"created": 1721764788,
|
||||
"pricing": {
|
||||
"inputTokenPricePer1M": 1.75,
|
||||
"outputTokenPricePer1M": 2.75,
|
||||
"requestPrice": 0,
|
||||
},
|
||||
"endpoints": ["/v1/chat/completions"],
|
||||
"type": "chat",
|
||||
}
|
||||
tf = TinfoilModel.parse_obj(data)
|
||||
assert tf.id == "llama3-3-70b"
|
||||
assert tf.pricing.inputTokenPricePer1M == 1.75
|
||||
assert tf.pricing.outputTokenPricePer1M == 2.75
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_parses_response(self) -> None:
|
||||
provider = TinfoilUpstreamProvider(api_key="test")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"id": "llama3-3-70b",
|
||||
"context_window": 128000,
|
||||
"created": 1721764788,
|
||||
"multimodal": False,
|
||||
"pricing": {
|
||||
"inputTokenPricePer1M": 1.75,
|
||||
"outputTokenPricePer1M": 2.75,
|
||||
"requestPrice": 0,
|
||||
},
|
||||
"endpoints": ["/v1/chat/completions"],
|
||||
"type": "chat",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("routstr.upstream.tinfoil.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
models = await provider.fetch_models()
|
||||
|
||||
assert len(models) == 1
|
||||
assert models[0].id == "llama3-3-70b"
|
||||
assert models[0].pricing.prompt == 1.75 / 1_000_000
|
||||
assert models[0].pricing.completion == 2.75 / 1_000_000
|
||||
assert models[0].context_length == 128000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_handles_error(self) -> None:
|
||||
provider = TinfoilUpstreamProvider(api_key="test")
|
||||
with patch("routstr.upstream.tinfoil.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(side_effect=Exception("network error"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
models = await provider.fetch_models()
|
||||
|
||||
assert models == []
|
||||
138
tests/unit/test_tinfoil_trailer.py
Normal file
138
tests/unit/test_tinfoil_trailer.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.tinfoil_trailer import forward_with_trailer
|
||||
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
if self._chunks:
|
||||
return self._chunks.pop(0)
|
||||
return b""
|
||||
|
||||
|
||||
class FakeWriter:
|
||||
def __init__(self) -> None:
|
||||
self.written = b""
|
||||
self.drain = AsyncMock()
|
||||
self.wait_closed = AsyncMock()
|
||||
self.close = MagicMock()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self.written += data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_with_trailer_captures_usage_trailer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Transfer-Encoding: chunked\r\n"
|
||||
b"Trailer: X-Tinfoil-Usage-Metrics\r\n"
|
||||
b"\r\n"
|
||||
b"5\r\nhello\r\n"
|
||||
b"0\r\n"
|
||||
b"X-Tinfoil-Usage-Metrics: prompt=1,completion=2,total=3\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
reader = FakeReader([response])
|
||||
writer = FakeWriter()
|
||||
open_connection = AsyncMock(return_value=(reader, writer))
|
||||
monkeypatch.setattr(
|
||||
"routstr.upstream.tinfoil_trailer.asyncio.open_connection", open_connection
|
||||
)
|
||||
|
||||
result = await forward_with_trailer(
|
||||
method="POST",
|
||||
url="https://enclave.tinfoil.sh/v1/chat/completions?stream=true",
|
||||
headers={"Authorization": "Bearer upstream"},
|
||||
body=b"opaque",
|
||||
)
|
||||
|
||||
assert result.status_code == 200
|
||||
assert result.body == b"hello"
|
||||
assert result.trailers == [
|
||||
("x-tinfoil-usage-metrics", "prompt=1,completion=2,total=3")
|
||||
]
|
||||
assert b"Connection: close" in writer.written
|
||||
writer.close.assert_called_once()
|
||||
writer.wait_closed.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_with_trailer_strips_hop_by_hop_headers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"
|
||||
reader = FakeReader([response])
|
||||
writer = FakeWriter()
|
||||
monkeypatch.setattr(
|
||||
"routstr.upstream.tinfoil_trailer.asyncio.open_connection",
|
||||
AsyncMock(return_value=(reader, writer)),
|
||||
)
|
||||
|
||||
await forward_with_trailer(
|
||||
method="POST",
|
||||
url="https://enclave.tinfoil.sh/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": "Bearer upstream",
|
||||
"Connection": "keep-alive, X-Client-Hop",
|
||||
"Keep-Alive": "timeout=5",
|
||||
"Proxy-Authenticate": "Basic",
|
||||
"Proxy-Authorization": "Basic secret",
|
||||
"TE": "trailers",
|
||||
"Trailer": "X-Usage",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"Upgrade": "websocket",
|
||||
"X-Client-Hop": "remove-me",
|
||||
"X-End-To-End": "preserve-me",
|
||||
},
|
||||
body=b"opaque",
|
||||
)
|
||||
|
||||
serialized_headers = writer.written.split(b"\r\n\r\n", 1)[0].lower()
|
||||
for name in (
|
||||
b"keep-alive",
|
||||
b"proxy-authenticate",
|
||||
b"proxy-authorization",
|
||||
b"te:",
|
||||
b"trailer:",
|
||||
b"transfer-encoding",
|
||||
b"upgrade:",
|
||||
b"x-client-hop",
|
||||
):
|
||||
assert name not in serialized_headers
|
||||
assert b"connection: close" in serialized_headers
|
||||
assert b"content-length: 6" in serialized_headers
|
||||
assert b"x-end-to-end: preserve-me" in serialized_headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_with_trailer_enforces_response_size_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"
|
||||
reader = FakeReader([response])
|
||||
writer = FakeWriter()
|
||||
monkeypatch.setattr(
|
||||
"routstr.upstream.tinfoil_trailer.asyncio.open_connection",
|
||||
AsyncMock(return_value=(reader, writer)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="EHBP response exceeded"):
|
||||
await forward_with_trailer(
|
||||
method="POST",
|
||||
url="https://enclave.tinfoil.sh/v1/chat/completions",
|
||||
headers={},
|
||||
body=b"opaque",
|
||||
max_response_bytes=4,
|
||||
)
|
||||
|
||||
writer.close.assert_called_once()
|
||||
524
tests/unit/test_upstream_generic.py
Normal file
524
tests/unit/test_upstream_generic.py
Normal file
@@ -0,0 +1,524 @@
|
||||
"""Unit tests for ``GenericUpstreamProvider.fetch_models`` price/metadata resolution.
|
||||
|
||||
A generic upstream is any OpenAI-compatible API. Most (DeepSeek, OpenAI,
|
||||
Groq, ...) answer ``/models`` with bare ``{id, object, owned_by}`` entries that
|
||||
carry *no* pricing. The provider must not fabricate a price for those: it
|
||||
resolves through native ``model_spec`` (Venice's bespoke schema) → litellm's
|
||||
bundled cost map → the OpenRouter feed, and only when every source misses does
|
||||
it import the model **disabled** with a warning rather than invent a number.
|
||||
|
||||
These tests drive that behaviour through the public ``fetch_models`` API. The
|
||||
``/models`` HTTP call is faked at ``httpx.AsyncClient``; the OpenRouter feed is
|
||||
patched at its source (``routstr.payment.models.async_fetch_openrouter_models``)
|
||||
so the resolver's lazy import picks up the stub. litellm's real bundled cost map
|
||||
is used unmocked — the DeepSeek rates it ships are the assertion's ground truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.upstream.generic import GenericUpstreamProvider
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Stand-in for ``httpx.AsyncClient`` returning a canned ``/models`` body."""
|
||||
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self._payload = payload
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
async def get(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
|
||||
return _FakeResponse(self._payload)
|
||||
|
||||
|
||||
def _patch_models_endpoint(payload: dict[str, Any]) -> Any:
|
||||
"""Patch the provider's ``httpx.AsyncClient`` to serve ``payload``."""
|
||||
return patch(
|
||||
"routstr.upstream.generic.httpx.AsyncClient",
|
||||
lambda *args, **kwargs: _FakeAsyncClient(payload),
|
||||
)
|
||||
|
||||
|
||||
def _model_by_id(models: list[Any], model_id: str) -> Any:
|
||||
return next(m for m in models if m.id == model_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# native model_spec (Venice) — must keep resolving, and capture its metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_model_spec_resolves_and_captures_metadata() -> None:
|
||||
"""A Venice-shaped ``model_spec`` is authoritative: prices/context come
|
||||
straight from it and vision capability becomes an image input modality."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "venice-llama",
|
||||
"owned_by": "venice",
|
||||
"model_spec": {
|
||||
"name": "Venice Llama",
|
||||
"availableContextTokens": 65536,
|
||||
"pricing": {
|
||||
"input": {"usd": 0.5},
|
||||
"output": {"usd": 1.5},
|
||||
},
|
||||
"capabilities": {"supportsVision": True},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "venice-llama")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(0.5 / 1_000_000)
|
||||
assert model.pricing.completion == pytest.approx(1.5 / 1_000_000)
|
||||
assert model.context_length == 65536
|
||||
assert "image" in model.architecture.input_modalities
|
||||
# Vision capability must be reflected in the combined modality string, not
|
||||
# flattened to "text->text".
|
||||
assert model.architecture.modality == "text+image->text"
|
||||
# A native price never needs the OpenRouter feed.
|
||||
or_feed.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# native model_spec validation — a bogus native price is not authoritative
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_both_zero_price_falls_through_to_litellm() -> None:
|
||||
"""A native ``model_spec`` that prices both tokens at 0 is not a real price
|
||||
(the same free-tier trap the litellm/OpenRouter rungs already reject). It
|
||||
must not be treated as authoritative and served free; the resolver falls
|
||||
through, so a litellm-known model lands on litellm's real rate instead."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "deepseek-chat",
|
||||
"owned_by": "deepseek",
|
||||
"model_spec": {
|
||||
"pricing": {"input": {"usd": 0}, "output": {"usd": 0}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "deepseek-chat")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(2.8e-07)
|
||||
assert model.pricing.completion == pytest.approx(4.2e-07)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_negative_price_falls_through_to_litellm() -> None:
|
||||
"""A negative native price would credit the caller's balance on every
|
||||
request (a fund drain, not a discount). Reject it like any other unusable
|
||||
price and fall through to the chain."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "deepseek-chat",
|
||||
"owned_by": "deepseek",
|
||||
"model_spec": {
|
||||
"pricing": {"input": {"usd": -0.5}, "output": {"usd": -1.5}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "deepseek-chat")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(2.8e-07)
|
||||
assert model.pricing.completion == pytest.approx(4.2e-07)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_non_numeric_price_does_not_break_catalog() -> None:
|
||||
"""A non-numeric native price (``"free"``) must not raise while parsing —
|
||||
an unguarded ``"free" / 1_000_000`` throws and the outer catch drops the
|
||||
*entire* provider catalog. It has to fail closed for that one model while
|
||||
every other model in the same response still resolves."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "broken-price",
|
||||
"owned_by": "mystery",
|
||||
"model_spec": {
|
||||
"pricing": {"input": {"usd": "free"}, "output": {"usd": "free"}},
|
||||
},
|
||||
},
|
||||
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
# One malformed entry must not empty the catalog.
|
||||
assert {m.id for m in models} == {"broken-price", "deepseek-chat"}
|
||||
broken = _model_by_id(models, "broken-price")
|
||||
assert broken.enabled is False
|
||||
healthy = _model_by_id(models, "deepseek-chat")
|
||||
assert healthy.enabled is True
|
||||
assert healthy.pricing.prompt == pytest.approx(2.8e-07)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# litellm rescue — the money-critical case (DeepSeek bare /models)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_deepseek_resolves_via_litellm() -> None:
|
||||
"""DeepSeek's ``/models`` carries no pricing. The old code fabricated
|
||||
``$0.001`` + ctx 4096; the resolver must instead pull DeepSeek's real
|
||||
rates from litellm's bundled cost map (``$0.28``/``$0.42`` per 1M, ctx
|
||||
131072) and keep the model enabled."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "deepseek-chat")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(2.8e-07)
|
||||
assert model.pricing.completion == pytest.approx(4.2e-07)
|
||||
assert model.context_length == 131072
|
||||
# Richer metadata than the two base prices is captured too.
|
||||
assert model.pricing.input_cache_read == pytest.approx(2.8e-08)
|
||||
assert model.top_provider is not None
|
||||
assert model.top_provider.max_completion_tokens == 8192
|
||||
# litellm answered, so the OpenRouter feed is never consulted.
|
||||
or_feed.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_zero_price_entry_fails_closed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A litellm entry that lists a model but prices it at 0/0 (free-tier
|
||||
moderation/rerank models do this) is not a real price — treating it as one
|
||||
would silently serve the model for free. The resolver must reject a both-zero
|
||||
litellm hit and fall through, so the model imports disabled, not at $0."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "omni-moderation-latest", "object": "model", "owned_by": "openai"},
|
||||
]
|
||||
}
|
||||
|
||||
gen_logger = logging.getLogger("routstr.upstream.generic")
|
||||
gen_logger.addHandler(caplog.handler)
|
||||
try:
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(
|
||||
base_url="http://x"
|
||||
).fetch_models()
|
||||
finally:
|
||||
gen_logger.removeHandler(caplog.handler)
|
||||
|
||||
model = _model_by_id(models, "omni-moderation-latest")
|
||||
assert model.enabled is False
|
||||
assert model.pricing.prompt == 0.0
|
||||
assert model.pricing.completion == 0.0
|
||||
assert any(
|
||||
"omni-moderation-latest" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
if rec.levelno >= logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_output_cap_not_used_as_context() -> None:
|
||||
"""litellm's ``max_tokens`` is the completion cap, not the context window
|
||||
(it tracks ``max_output_tokens`` for ~94% of models). When a model reports
|
||||
no ``max_input_tokens``, the resolver must not smuggle the output cap in as
|
||||
the context window; it falls back to the id-based estimate instead, while
|
||||
``max_tokens`` still feeds the completion limit."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "gemini/gemini-gemma-2-9b-it",
|
||||
"object": "model",
|
||||
"owned_by": "google",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "gemini/gemini-gemma-2-9b-it")
|
||||
assert model.enabled is True
|
||||
# litellm gives this model max_input_tokens=None, max_tokens=8192 (an output
|
||||
# cap). Context must come from the estimate (4096), never the 8192 cap.
|
||||
assert model.context_length == 4096
|
||||
# The 8192 output cap still lands where it belongs: the completion limit.
|
||||
assert model.top_provider is not None
|
||||
assert model.top_provider.max_completion_tokens == 8192
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenRouter fallback — litellm misses, OR carries a full payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_to_litellm_resolves_via_openrouter() -> None:
|
||||
"""A model litellm has never heard of still resolves if the OpenRouter
|
||||
feed lists it, pulling price + context from that entry."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "exotic/model-9000", "object": "model", "owned_by": "exotic"},
|
||||
]
|
||||
}
|
||||
or_entry = {
|
||||
"id": "exotic/model-9000",
|
||||
"name": "Exotic 9000",
|
||||
"context_length": 65536,
|
||||
"architecture": {
|
||||
"modality": "text+image->text",
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"],
|
||||
"tokenizer": "Other",
|
||||
"instruct_type": None,
|
||||
},
|
||||
"pricing": {"prompt": "0.000005", "completion": "0.000010"},
|
||||
"top_provider": {
|
||||
"context_length": 65536,
|
||||
"max_completion_tokens": 4096,
|
||||
"is_moderated": False,
|
||||
},
|
||||
}
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[or_entry])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "exotic/model-9000")
|
||||
assert model.enabled is True
|
||||
assert model.pricing.prompt == pytest.approx(5e-06)
|
||||
assert model.pricing.completion == pytest.approx(1e-05)
|
||||
assert model.context_length == 65536
|
||||
# The feed's own modality string is carried through verbatim, not recomputed.
|
||||
assert model.architecture.modality == "text+image->text"
|
||||
or_feed.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_bare_tail_collision_picks_highest_price() -> None:
|
||||
"""When a bare model id matches several OpenRouter entries by tail
|
||||
(``model`` ↔ ``a/model``, ``b/model``), the match must be deterministic and
|
||||
money-safe: pick the highest-priced candidate regardless of feed order, so
|
||||
ordering can never leave the node charging below true cost. (The live feed
|
||||
has zero such collisions today; this guards the latent case.)"""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "zzz-phantom-model", "object": "model", "owned_by": "mystery"},
|
||||
]
|
||||
}
|
||||
# Same bare tail, different resellers; the pricier one is listed *second*
|
||||
# so a first-wins match would pick the cheaper (undercharging) entry.
|
||||
or_feed = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "cheapco/zzz-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
|
||||
},
|
||||
{
|
||||
"id": "premiumco/zzz-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000009", "completion": "0.000010"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "zzz-phantom-model")
|
||||
assert model.pricing.prompt == pytest.approx(9e-06)
|
||||
assert model.pricing.completion == pytest.approx(1e-05)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_bare_tail_tie_breaks_on_combined_cost() -> None:
|
||||
"""The bare-tail tie-break must weigh *both* rates, not prompt alone.
|
||||
Given two colliding entries where one is cheaper on prompt but far dearer
|
||||
on completion, ranking by prompt would pick the entry that undercharges
|
||||
output-heavy traffic. Pick the highest *combined* per-token cost so the
|
||||
money-safe choice holds whichever way the traffic leans."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "yyy-phantom-model", "object": "model", "owned_by": "mystery"},
|
||||
]
|
||||
}
|
||||
# dear-overall is listed first with the *lower* prompt, so a prompt-only max
|
||||
# would wrongly pick the second (cheaper-overall) entry.
|
||||
or_feed = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "dearco/yyy-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000100"},
|
||||
},
|
||||
{
|
||||
"id": "cheapco/yyy-phantom-model",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000009", "completion": "0.000002"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
model = _model_by_id(models, "yyy-phantom-model")
|
||||
assert model.pricing.prompt == pytest.approx(1e-06)
|
||||
assert model.pricing.completion == pytest.approx(1e-04)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_feed_fetched_once_per_discovery() -> None:
|
||||
"""Two models both missing litellm must share a single OpenRouter fetch —
|
||||
the feed is not re-downloaded per model."""
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "exotic/model-a", "object": "model", "owned_by": "exotic"},
|
||||
{"id": "exotic/model-b", "object": "model", "owned_by": "exotic"},
|
||||
]
|
||||
}
|
||||
or_feed = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "exotic/model-a",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
|
||||
},
|
||||
{
|
||||
"id": "exotic/model-b",
|
||||
"context_length": 8192,
|
||||
"pricing": {"prompt": "0.000003", "completion": "0.000004"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with _patch_models_endpoint(payload):
|
||||
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
|
||||
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
|
||||
|
||||
assert {m.id for m in models} == {"exotic/model-a", "exotic/model-b"}
|
||||
assert or_feed.await_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fail closed — no source resolves → disabled + warned, never fabricated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolvable_model_fails_closed(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""When native, litellm and OpenRouter all miss, the model is imported
|
||||
disabled with a warning naming it — and no price is invented (the old
|
||||
``$0.001`` placeholder is gone)."""
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "nobody-has-priced-this-xyz",
|
||||
"object": "model",
|
||||
"owned_by": "mystery",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# routstr loggers set propagate=False, so caplog's root handler misses
|
||||
# them; attach its handler to the provider logger directly.
|
||||
gen_logger = logging.getLogger("routstr.upstream.generic")
|
||||
gen_logger.addHandler(caplog.handler)
|
||||
try:
|
||||
with _patch_models_endpoint(payload):
|
||||
or_feed = AsyncMock(return_value=[])
|
||||
with patch(
|
||||
"routstr.payment.models.async_fetch_openrouter_models", or_feed
|
||||
):
|
||||
models = await GenericUpstreamProvider(
|
||||
base_url="http://x"
|
||||
).fetch_models()
|
||||
finally:
|
||||
gen_logger.removeHandler(caplog.handler)
|
||||
|
||||
model = _model_by_id(models, "nobody-has-priced-this-xyz")
|
||||
assert model.enabled is False
|
||||
assert model.pricing.prompt == 0.0
|
||||
assert model.pricing.completion == 0.0
|
||||
assert any(
|
||||
"nobody-has-priced-this-xyz" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
if rec.levelno >= logging.WARNING
|
||||
)
|
||||
@@ -1,11 +1,22 @@
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
|
||||
from routstr.wallet import (
|
||||
MintConnectionError,
|
||||
TokenConsumedError,
|
||||
classify_redemption_error,
|
||||
credit_balance,
|
||||
get_balance,
|
||||
is_mint_connection_error,
|
||||
recieve_token,
|
||||
send_token,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -231,9 +242,15 @@ async def test_credit_balance_rejects_missing_key() -> None:
|
||||
"routstr.wallet.recieve_token",
|
||||
return_value=(1000, "sat", "http://mint:3338"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="disappeared"):
|
||||
# Post-redemption: token already spent, so a vanished key is a
|
||||
# non-retryable TokenConsumedError, not a generic token error.
|
||||
with pytest.raises(TokenConsumedError, match="disappeared") as exc_info:
|
||||
await credit_balance(token_str, mock_key, mock_session)
|
||||
|
||||
classified = classify_redemption_error(exc_info.value)
|
||||
assert classified is not None
|
||||
_type, status, _msg, code = classified
|
||||
assert (status, code) == (500, "cashu_token_consumed")
|
||||
# UPDATE matched nothing; committing would hide the failed credit.
|
||||
assert mock_session.exec.called
|
||||
assert not mock_session.commit.called
|
||||
@@ -902,9 +919,10 @@ def _with_recovery_mocks(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_mint_failure_propagates_unwrapped() -> None:
|
||||
"""A non-recoverable mint failure after melt propagates as-is (a 500, not a
|
||||
client error): the melt already spent the foreign proofs."""
|
||||
async def test_swap_mint_failure_after_melt_is_token_consumed() -> None:
|
||||
"""A non-recoverable mint failure after melt is a non-retryable
|
||||
TokenConsumedError (the melt already spent the foreign proofs), with the
|
||||
original error preserved in the cause chain."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||
@@ -919,10 +937,10 @@ async def test_swap_mint_failure_propagates_unwrapped() -> None:
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(Exception, match="Quote is expired") as exc_info:
|
||||
with pytest.raises(TokenConsumedError) as exc_info:
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
assert type(exc_info.value) is Exception # original error, not wrapped
|
||||
assert "Quote is expired" in str(exc_info.value.__cause__)
|
||||
assert mock_token_wallet.melt.call_count == 1
|
||||
mock_primary_wallet.restore_tokens_for_keyset.assert_not_called()
|
||||
|
||||
@@ -977,7 +995,7 @@ async def test_swap_recovery_shortfall_refuses_credit() -> None:
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(ValueError, match="Swap recovery failed"):
|
||||
with pytest.raises(TokenConsumedError, match="Swap recovery failed"):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
|
||||
@@ -1004,7 +1022,7 @@ async def test_swap_recovery_failure_wrapped() -> None:
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(ValueError, match="recovery unsuccessful"):
|
||||
with pytest.raises(TokenConsumedError, match="recovery unsuccessful"):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
|
||||
@@ -1092,3 +1110,214 @@ async def test_swap_does_not_retry_on_payment_failure() -> None:
|
||||
|
||||
assert mock_token_wallet.melt.call_count == 1
|
||||
assert mock_primary_wallet.request_mint.call_count == 2
|
||||
|
||||
|
||||
# --- Mint-unreachable classification (is_mint_connection_error) ---------------
|
||||
|
||||
|
||||
def _chain(outer: BaseException, cause: BaseException) -> BaseException:
|
||||
"""Attach ``cause`` as the ``__cause__`` of ``outer`` (as ``raise X from Y``
|
||||
would) and return ``outer``."""
|
||||
outer.__cause__ = cause
|
||||
return outer
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
httpx.ConnectError("connection refused"),
|
||||
httpx.ConnectTimeout("timed out"),
|
||||
httpx.ReadTimeout("read timed out"), # subclass of TimeoutException
|
||||
httpx.PoolTimeout("pool timed out"),
|
||||
httpx.WriteError("write failed"), # subclass of NetworkError
|
||||
ConnectionRefusedError("refused"), # subclass of ConnectionError
|
||||
ConnectionResetError("reset"),
|
||||
socket.gaierror("Name or service not known"),
|
||||
TimeoutError("timed out"), # asyncio.TimeoutError alias on 3.11+
|
||||
MintConnectionError("mint down"),
|
||||
# Wrapped: the real transport error survives in the __cause__ chain.
|
||||
_chain(ValueError("Failed to estimate fees: boom"), httpx.ConnectError("x")),
|
||||
# Two levels deep.
|
||||
_chain(
|
||||
RuntimeError("outer"),
|
||||
_chain(ValueError("mid"), httpx.ConnectTimeout("deep")),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_mint_connection_error_detects_transport_failures(
|
||||
error: BaseException,
|
||||
) -> None:
|
||||
assert is_mint_connection_error(error) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ValueError("token already spent"),
|
||||
ValueError("Mint unreachable: all connection attempts failed"), # text only
|
||||
ValueError("Invalid Cashu token"),
|
||||
# Mint answered with an error status — reachable, so NOT a connection error.
|
||||
httpx.HTTPStatusError(
|
||||
"500", request=httpx.Request("POST", "http://m"), response=httpx.Response(500)
|
||||
),
|
||||
RuntimeError("some internal fault"),
|
||||
],
|
||||
)
|
||||
def test_is_mint_connection_error_ignores_non_transport(error: BaseException) -> None:
|
||||
assert is_mint_connection_error(error) is False
|
||||
|
||||
|
||||
def test_is_mint_connection_error_survives_reference_cycle() -> None:
|
||||
"""A pathological cause/context cycle must not hang the classifier."""
|
||||
a = ValueError("a")
|
||||
b = ValueError("b")
|
||||
a.__cause__ = b
|
||||
b.__context__ = a
|
||||
assert is_mint_connection_error(a) is False
|
||||
|
||||
|
||||
def test_token_consumed_seals_transport_cause() -> None:
|
||||
"""A transport error wrapped in TokenConsumedError is NOT retryable — the
|
||||
token is spent, so the seal wins over the httpx cause underneath."""
|
||||
try:
|
||||
raise httpx.ConnectError("mint down")
|
||||
except httpx.ConnectError as exc:
|
||||
consumed = TokenConsumedError("credit failed")
|
||||
consumed.__cause__ = exc
|
||||
|
||||
assert is_mint_connection_error(consumed) is False
|
||||
classified = classify_redemption_error(consumed)
|
||||
assert classified is not None
|
||||
type_, status, _msg, code = classified
|
||||
assert (type_, status, code) == ("token_consumed", 500, "cashu_token_consumed")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
# The message credit_balance raises for a dust/zero redemption.
|
||||
ValueError("Redeemed token amount must be positive, got 0 msats"),
|
||||
ValueError("Redeemed token amount must be positive, got -5 msats"),
|
||||
ValueError("Failed to redeem Cashu token: token yielded no value"),
|
||||
],
|
||||
)
|
||||
def test_classify_zero_value(error: ValueError) -> None:
|
||||
"""A zero/negative redemption gets its own documented code, not the generic
|
||||
cashu_token_redemption_failed bucket."""
|
||||
classified = classify_redemption_error(error)
|
||||
assert classified is not None
|
||||
type_, status, _msg, code = classified
|
||||
assert (type_, status, code) == ("cashu_error", 400, "cashu_token_zero_value")
|
||||
|
||||
|
||||
def test_classify_generic_valueerror_is_not_zero_value() -> None:
|
||||
"""A generic wallet ValueError still falls to the generic bucket — the
|
||||
zero-value match must not over-trigger."""
|
||||
classified = classify_redemption_error(ValueError("some unexpected wallet condition"))
|
||||
assert classified is not None
|
||||
type_, status, _msg, code = classified
|
||||
assert (type_, status, code) == (
|
||||
"cashu_error",
|
||||
400,
|
||||
"cashu_token_redemption_failed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_mint_transport_error_after_melt_is_not_retryable() -> None:
|
||||
"""A transport error minting on the primary mint (after the foreign melt
|
||||
already spent the proofs) classifies as a non-retryable token_consumed 500,
|
||||
never a retryable mint_unreachable 503."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||
1000, fee_reserves=[10, 10]
|
||||
)
|
||||
# Melt succeeds (proofs spent); minting on primary hits a transport error.
|
||||
mock_primary_wallet.mint = AsyncMock(
|
||||
side_effect=httpx.ConnectError("primary mint down")
|
||||
)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(TokenConsumedError) as exc_info:
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
classified = classify_redemption_error(exc_info.value)
|
||||
assert classified is not None
|
||||
_type, status, _msg, code = classified
|
||||
assert status == 500
|
||||
assert code == "cashu_token_consumed"
|
||||
assert is_mint_connection_error(exc_info.value) is False
|
||||
assert mock_token_wallet.melt.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credit_balance_db_transport_error_is_token_consumed() -> None:
|
||||
"""A transport-like DB failure after the token is redeemed must be
|
||||
non-retryable (token_consumed), not a retryable mint_unreachable."""
|
||||
mock_key = Mock()
|
||||
mock_key.balance = 1000
|
||||
mock_key.hashed_key = "test_hash"
|
||||
mock_session = AsyncMock()
|
||||
mock_session.exec = AsyncMock(side_effect=ConnectionError("db connection reset"))
|
||||
|
||||
with patch(
|
||||
"routstr.wallet.recieve_token",
|
||||
return_value=(100, "sat", "https://mint.example"),
|
||||
):
|
||||
with pytest.raises(TokenConsumedError) as exc_info:
|
||||
await credit_balance("cashuAtoken", mock_key, mock_session)
|
||||
|
||||
assert is_mint_connection_error(exc_info.value) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_fee_estimation_transport_error_raises_mint_connection_error() -> None:
|
||||
"""A transport failure while estimating fees is surfaced as
|
||||
MintConnectionError (→ 503), not a generic fee ValueError (→ 422)."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||
1000, fee_reserves=[10]
|
||||
)
|
||||
mock_primary_wallet.request_mint = AsyncMock(
|
||||
side_effect=httpx.ConnectError("All connection attempts failed")
|
||||
)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(MintConnectionError):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
mock_token_wallet.melt.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_melt_transport_error_raises_mint_connection_error() -> None:
|
||||
"""A transport failure during melt is surfaced as MintConnectionError and
|
||||
is NOT retried — the mint is down, not demanding higher fees."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||
1000, fee_reserves=[10, 10]
|
||||
)
|
||||
mock_token_wallet.melt = AsyncMock(
|
||||
side_effect=httpx.ConnectTimeout("timed out")
|
||||
)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
with pytest.raises(MintConnectionError):
|
||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
assert mock_token_wallet.melt.call_count == 1
|
||||
|
||||
@@ -4,15 +4,15 @@ FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -6,14 +6,14 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the UI
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export function AddModelForm({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[600px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Plus className='h-5 w-5' />
|
||||
|
||||
@@ -438,7 +438,7 @@ export function AddProviderModelDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[720px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Plus className='h-4 w-4' />
|
||||
|
||||
@@ -177,7 +177,7 @@ export function CollectModelsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[80vh] sm:max-w-[700px]'>
|
||||
<DialogContent className='max-h-[80dvh] sm:max-w-[700px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Download className='h-5 w-5' />
|
||||
|
||||
@@ -25,7 +25,7 @@ export function CostCalculatorDialog({
|
||||
}: CostCalculatorDialogProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[700px]'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[700px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Calculator className='h-5 w-5' />
|
||||
|
||||
@@ -100,7 +100,7 @@ export function EditGroupForm({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[700px]'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[700px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Users className='h-5 w-5' />
|
||||
|
||||
@@ -213,7 +213,7 @@ export function ProviderCard({
|
||||
</CardHeader>
|
||||
|
||||
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{provider.api_key
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ProviderFormDialogContent({
|
||||
availableMints,
|
||||
}: ProviderFormDialogContentProps) {
|
||||
return (
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ProviderBalance({
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-md'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Top Up Balance</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -239,7 +239,7 @@ export function RoutstrProviderCard({
|
||||
</Card>
|
||||
|
||||
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
|
||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasApiKey ? 'Create New Key on Upstream Node' : 'Create API Key'}
|
||||
|
||||
@@ -626,7 +626,7 @@ export function TopModelsUsageChart({
|
||||
className={cn(
|
||||
'aspect-auto w-full',
|
||||
isFullscreen
|
||||
? 'h-[calc(100vh-220px)] min-h-[340px] sm:h-[calc(100vh-260px)] sm:min-h-[420px]'
|
||||
? 'h-[calc(100dvh-220px)] min-h-[340px] sm:h-[calc(100dvh-260px)] sm:min-h-[420px]'
|
||||
: 'h-[260px] sm:h-[340px]'
|
||||
)}
|
||||
onMouseLeave={() => {
|
||||
|
||||
@@ -130,7 +130,7 @@ function DialogContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot='dialog-content'
|
||||
className={cn(
|
||||
'border-border bg-card data-closed:animate-out data-open:animate-in data-closed:slide-out-to-bottom data-open:slide-in-from-bottom fixed inset-x-0 bottom-0 z-50 grid max-h-[90vh] w-full gap-4 rounded-t-xl border-t p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] shadow-lg',
|
||||
'border-border bg-card data-closed:animate-out data-open:animate-in data-closed:slide-out-to-bottom data-open:slide-in-from-bottom fixed inset-x-0 bottom-0 z-50 grid max-h-[90dvh] w-full gap-4 rounded-t-xl border-t p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...(props as React.ComponentProps<typeof DrawerPrimitive.Content>)}
|
||||
|
||||
@@ -56,7 +56,7 @@ function DrawerContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot='drawer-content'
|
||||
className={cn(
|
||||
'bg-background group/drawer-content fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=bottom]:pb-[calc(0.5rem+env(safe-area-inset-bottom))] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'bg-background group/drawer-content fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80dvh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=bottom]:pb-[calc(0.5rem+env(safe-area-inset-bottom))] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80dvh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -291,7 +291,7 @@ export function UsageMetricsChart({
|
||||
className={cn(
|
||||
'aspect-auto w-full',
|
||||
isFullscreen
|
||||
? 'h-[calc(100vh-220px)] min-h-[340px] sm:h-[calc(100vh-260px)] sm:min-h-[420px]'
|
||||
? 'h-[calc(100dvh-220px)] min-h-[340px] sm:h-[calc(100dvh-260px)] sm:min-h-[420px]'
|
||||
: 'h-[260px] sm:h-[340px]'
|
||||
)}
|
||||
config={chartConfig}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -54,7 +54,7 @@
|
||||
"geist": "^1.7.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
@@ -89,11 +89,5 @@
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
403
ui/pnpm-lock.yaml
generated
403
ui/pnpm-lock.yaml
generated
@@ -4,6 +4,20 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@babel/core': 7.29.6
|
||||
flatted: 3.4.2
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.6
|
||||
ajv@6: 6.14.0
|
||||
brace-expansion@1: 1.1.13
|
||||
brace-expansion@5: 5.0.6
|
||||
js-yaml: 4.2.0
|
||||
minimatch@3: 3.1.4
|
||||
picomatch@2: 2.3.2
|
||||
picomatch@4: 4.0.4
|
||||
postcss: 8.5.10
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -105,8 +119,8 @@ importers:
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
axios:
|
||||
specifier: ^1.13.5
|
||||
version: 1.13.6
|
||||
specifier: ^1.16.0
|
||||
version: 1.18.1
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -124,7 +138,7 @@ importers:
|
||||
version: 8.6.0(react@19.2.4)
|
||||
geist:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
version: 1.7.0(next@16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -132,8 +146,8 @@ importers:
|
||||
specifier: ^0.575.0
|
||||
version: 0.575.0(react@19.2.4)
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
specifier: 16.2.6
|
||||
version: 16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -208,8 +222,8 @@ importers:
|
||||
specifier: ^9.7.0
|
||||
version: 9.38.0(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 16.2.6
|
||||
version: 16.2.6(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@9.38.0(jiti@2.6.1))
|
||||
@@ -242,16 +256,20 @@ packages:
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/compat-data@7.29.0':
|
||||
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/core@7.29.0':
|
||||
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
|
||||
'@babel/core@7.29.6':
|
||||
resolution: {integrity: sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
|
||||
'@babel/generator@7.29.7':
|
||||
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-compilation-targets@7.28.6':
|
||||
@@ -270,22 +288,30 @@ packages:
|
||||
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
'@babel/core': 7.29.6
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1':
|
||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helpers@7.28.6':
|
||||
resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
|
||||
'@babel/helpers@7.29.7':
|
||||
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.0':
|
||||
@@ -293,6 +319,11 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -301,6 +332,10 @@ packages:
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -309,6 +344,10 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.2.0':
|
||||
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -600,56 +639,56 @@ packages:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@next/env@16.1.6':
|
||||
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
|
||||
'@next/env@16.2.6':
|
||||
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
|
||||
|
||||
'@next/eslint-plugin-next@16.1.6':
|
||||
resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==}
|
||||
'@next/eslint-plugin-next@16.2.6':
|
||||
resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.1.6':
|
||||
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
|
||||
'@next/swc-darwin-arm64@16.2.6':
|
||||
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.1.6':
|
||||
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
|
||||
'@next/swc-darwin-x64@16.2.6':
|
||||
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
|
||||
'@next/swc-linux-arm64-gnu@16.2.6':
|
||||
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||
'@next/swc-linux-arm64-musl@16.2.6':
|
||||
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||
'@next/swc-linux-x64-gnu@16.2.6':
|
||||
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||
'@next/swc-linux-x64-musl@16.2.6':
|
||||
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||
'@next/swc-win32-arm64-msvc@16.2.6':
|
||||
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
|
||||
'@next/swc-win32-x64-msvc@16.2.6':
|
||||
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1810,8 +1849,12 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1882,8 +1925,8 @@ packages:
|
||||
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
axios@1.13.6:
|
||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||
axios@1.18.1:
|
||||
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
|
||||
|
||||
axobject-query@4.1.0:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
@@ -1901,11 +1944,11 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
brace-expansion@1.1.13:
|
||||
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
|
||||
|
||||
brace-expansion@5.0.4:
|
||||
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -2182,8 +2225,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-next@16.1.6:
|
||||
resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==}
|
||||
eslint-config-next@16.2.6:
|
||||
resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@@ -2348,7 +2391,7 @@ packages:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
picomatch: 4.0.4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
@@ -2373,11 +2416,11 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
@@ -2389,8 +2432,8 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
function-bind@1.1.2:
|
||||
@@ -2493,12 +2536,20 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -2659,8 +2710,8 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
js-yaml@4.2.0:
|
||||
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
@@ -2824,11 +2875,8 @@ packages:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
minimatch@3.1.4:
|
||||
resolution: {integrity: sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
@@ -2855,8 +2903,8 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@16.1.6:
|
||||
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
||||
next@16.2.6:
|
||||
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2957,12 +3005,12 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
picomatch@2.3.2:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pngjs@5.0.0:
|
||||
@@ -2973,12 +3021,8 @@ packages:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
postcss@8.5.10:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -3052,8 +3096,9 @@ packages:
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
@@ -3580,16 +3625,22 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/compat-data@7.29.0': {}
|
||||
|
||||
'@babel/core@7.29.0':
|
||||
'@babel/core@7.29.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-compilation-targets': 7.28.6
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helpers': 7.28.6
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6)
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
@@ -3602,10 +3653,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
'@babel/generator@7.29.7':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
@@ -3627,9 +3678,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
|
||||
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/core': 7.29.6
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.29.0
|
||||
@@ -3638,33 +3689,47 @@ snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1': {}
|
||||
|
||||
'@babel/helpers@7.28.6':
|
||||
'@babel/helpers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/parser@7.29.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
@@ -3676,6 +3741,11 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
@@ -3761,7 +3831,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.5
|
||||
minimatch: 3.1.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3775,14 +3845,14 @@ snapshots:
|
||||
|
||||
'@eslint/eslintrc@3.3.3':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
debug: 4.4.3
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.2
|
||||
js-yaml: 4.2.0
|
||||
minimatch: 3.1.4
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -3952,34 +4022,34 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@next/env@16.1.6': {}
|
||||
'@next/env@16.2.6': {}
|
||||
|
||||
'@next/eslint-plugin-next@16.1.6':
|
||||
'@next/eslint-plugin-next@16.2.6':
|
||||
dependencies:
|
||||
fast-glob: 3.3.1
|
||||
|
||||
'@next/swc-darwin-arm64@16.1.6':
|
||||
'@next/swc-darwin-arm64@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.1.6':
|
||||
'@next/swc-darwin-x64@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.1.6':
|
||||
'@next/swc-linux-arm64-gnu@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
'@next/swc-linux-arm64-musl@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
'@next/swc-linux-x64-gnu@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
'@next/swc-linux-x64-musl@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
'@next/swc-win32-arm64-msvc@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.1.6':
|
||||
'@next/swc-win32-x64-msvc@16.2.6':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -4898,7 +4968,7 @@ snapshots:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.1
|
||||
'@tailwindcss/oxide': 4.2.1
|
||||
postcss: 8.5.6
|
||||
postcss: 8.5.10
|
||||
tailwindcss: 4.2.1
|
||||
|
||||
'@tanstack/query-core@5.90.20': {}
|
||||
@@ -5133,7 +5203,13 @@ snapshots:
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
ajv@6.12.6:
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
@@ -5233,13 +5309,15 @@ snapshots:
|
||||
|
||||
axe-core@4.11.1: {}
|
||||
|
||||
axios@1.13.6:
|
||||
axios@1.18.1:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
@@ -5249,12 +5327,12 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
brace-expansion@1.1.13:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@5.0.4:
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -5639,9 +5717,9 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@16.1.6(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3):
|
||||
eslint-config-next@16.2.6(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next': 16.1.6
|
||||
'@next/eslint-plugin-next': 16.2.6
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.38.0(jiti@2.6.1))
|
||||
@@ -5712,7 +5790,7 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.5
|
||||
minimatch: 3.1.4
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.1
|
||||
@@ -5740,7 +5818,7 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
minimatch: 3.1.5
|
||||
minimatch: 3.1.4
|
||||
object.fromentries: 2.0.8
|
||||
safe-regex-test: 1.1.0
|
||||
string.prototype.includes: 2.0.1
|
||||
@@ -5756,7 +5834,7 @@ snapshots:
|
||||
|
||||
eslint-plugin-react-hooks@7.0.1(eslint@9.38.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/core': 7.29.6
|
||||
'@babel/parser': 7.29.0
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
@@ -5777,7 +5855,7 @@ snapshots:
|
||||
estraverse: 5.3.0
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.4
|
||||
object.entries: 1.1.9
|
||||
object.fromentries: 2.0.8
|
||||
object.values: 1.2.1
|
||||
@@ -5812,7 +5890,7 @@ snapshots:
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
@@ -5831,7 +5909,7 @@ snapshots:
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.5
|
||||
minimatch: 3.1.4
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
@@ -5879,9 +5957,9 @@ snapshots:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
picomatch: 4.0.4
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
@@ -5903,23 +5981,23 @@ snapshots:
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.3.3
|
||||
flatted: 3.4.2
|
||||
keyv: 4.5.4
|
||||
|
||||
flatted@3.3.3: {}
|
||||
flatted@3.4.2: {}
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
form-data@4.0.5:
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
@@ -5935,9 +6013,9 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
geist@1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
|
||||
geist@1.7.0(next@16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
|
||||
dependencies:
|
||||
next: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
@@ -6018,12 +6096,23 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@@ -6183,7 +6272,7 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
js-yaml@4.2.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -6305,7 +6394,7 @@ snapshots:
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
picomatch: 2.3.2
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
@@ -6315,15 +6404,11 @@ snapshots:
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.4
|
||||
brace-expansion: 5.0.6
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@3.1.4:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
brace-expansion: 1.1.13
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -6340,25 +6425,25 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@next/env': 16.2.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.0
|
||||
caniuse-lite: 1.0.30001776
|
||||
postcss: 8.4.31
|
||||
postcss: 8.5.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@next/swc-darwin-arm64': 16.2.6
|
||||
'@next/swc-darwin-x64': 16.2.6
|
||||
'@next/swc-linux-arm64-gnu': 16.2.6
|
||||
'@next/swc-linux-arm64-musl': 16.2.6
|
||||
'@next/swc-linux-x64-gnu': 16.2.6
|
||||
'@next/swc-linux-x64-musl': 16.2.6
|
||||
'@next/swc-win32-arm64-msvc': 16.2.6
|
||||
'@next/swc-win32-x64-msvc': 16.2.6
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -6453,21 +6538,15 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.6:
|
||||
postcss@8.5.10:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
@@ -6491,7 +6570,7 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
@@ -6901,12 +6980,12 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
|
||||
styled-jsx@5.1.6(@babel/core@7.29.6)(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/core': 7.29.6
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
@@ -6930,8 +7009,8 @@ snapshots:
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
|
||||
17
ui/pnpm-workspace.yaml
Normal file
17
ui/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
overrides:
|
||||
'@babel/core': 7.29.6
|
||||
flatted: 3.4.2
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.6
|
||||
ajv@6: 6.14.0
|
||||
brace-expansion@1: 1.1.13
|
||||
brace-expansion@5: 5.0.6
|
||||
js-yaml: 4.2.0
|
||||
minimatch@3: 3.1.4
|
||||
picomatch@2: 2.3.2
|
||||
picomatch@4: 4.0.4
|
||||
postcss: 8.5.10
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
4
uv.lock
generated
4
uv.lock
generated
@@ -2384,7 +2384,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "routstr"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -2392,6 +2392,7 @@ dependencies = [
|
||||
{ name = "cashu" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "greenlet" },
|
||||
{ name = "h11" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "litellm" },
|
||||
{ name = "marshmallow" },
|
||||
@@ -2426,6 +2427,7 @@ requires-dist = [
|
||||
{ name = "cashu", specifier = ">=0.20" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115" },
|
||||
{ name = "greenlet", specifier = ">=3.2.1" },
|
||||
{ name = "h11", specifier = ">=0.14" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.25.2" },
|
||||
{ name = "litellm", specifier = ">=1.55.0" },
|
||||
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
|
||||
|
||||
Reference in New Issue
Block a user