new tasks, devnet topup, cli, new model support

This commit is contained in:
Dobromir Popov
2026-07-06 14:17:36 +03:00
parent f841dfaeed
commit b547034741
24 changed files with 1298 additions and 63 deletions

View File

@@ -0,0 +1,88 @@
# US-036 — Streamed chat completions over the relay RPC path
Status: planned
Priority: Critical (blocks public friends-test deployment)
Stage: Designed
## Context
With the tracker deployed on a public VPS (`cloud.neuron.d-popov.com`), every node is
behind NAT, so **every** chat request is proxied tracker → relay → head node via
`_relay_http_request` (`packages/tracker/meshnet_tracker/server.py`). That function does a
single blocking `ws.recv()` and the node's `RelayHttpBridge._handle_request` does a single
`resp.read()`. Two consequences for `stream: true` requests:
1. **No live streaming** — the client sees nothing until generation completes, then
receives the entire SSE body at once with a `Content-Length` header.
2. **Zero billing** — the tracker runs `_billable_non_stream_tokens(json.loads(body))`
on the buffered body; SSE text is not JSON, the parse fails, and the request is
billed/credited as 0 tokens. Off-LAN this silently zeroes out *all* streamed-request
accounting.
Decision (grilled 2026-07-06): implement **true multi-frame streaming** over the relay
RPC WebSocket, scoped to the tracker → head-node leg. Rejected alternatives:
billing-only SSE parse (fragile heuristic, blank-screen UX stays) and forcing
`stream:false` over the relay (exact billing but still no live tokens for testers).
Streaming through the existing SSE accounting loop fixes both symptoms with one
mechanism. Inter-node `/forward` activation hops stay single-frame (ADR-0014) — they
are one-tensor-in/one-tensor-out and gain nothing from chunking.
## Protocol
A relayed response becomes a sequence of `relay-http-response` envelopes sharing one
`request_id`:
```json
// first frame — status + headers, opens the stream
{"request_id": "<id>", "status": 200, "headers": {"Content-Type": "text/event-stream"},
"stream": true, "chunk": "data: {...}\n\n", "done": false}
// zero or more continuation frames
{"request_id": "<id>", "stream": true, "chunk": "data: {...}\n\n", "done": false}
// terminal frame
{"request_id": "<id>", "stream": true, "done": true}
```
**Backward compatibility:** a frame with no `stream` key is a complete single response
(today's format, still used for `/forward` hops, non-SSE responses, and older nodes).
The relay and tracker treat it as terminal.
### Per component
- **Node bridge** (`packages/node/meshnet_node/relay_bridge.py`): when the local
response `Content-Type` is `text/event-stream`, read line-by-line and emit chunk
frames as lines arrive; otherwise keep the existing single-frame path (including
`body_base64` for binary). Frame sends go through the bridge's WS send lock
(US-037) so frames from concurrent requests interleave whole, never torn.
- **Relay server** (`packages/relay/meshnet_relay/server.py`): `_handle_rpc` replaces
the single `asyncio.Future` in `_pending_rpc` with a per-request `asyncio.Queue`.
Frames arriving on the target peer's gossip connection are routed by `request_id`
and forwarded to the requester WS until `done` (or a terminal legacy frame).
Timeouts: keep the 310 s overall cap; add a 120 s per-frame idle timeout so a dead
node doesn't pin the queue.
- **Tracker** (`packages/tracker/meshnet_tracker/server.py`): `_relay_http_request`
grows a streaming mode — loop `ws.recv()`; on the first frame send status/headers to
the client; write each `chunk` to the client immediately and feed it through the
same SSE token-accounting used by the direct-proxy stream loop
(`reported_stream_tokens` / `_record_observed_throughput` / `_bill_completed`), so
relayed streams bill identically to direct streams. Non-stream frames keep the
current buffered handling.
### Known limitation (accepted for alpha)
If the client disconnects mid-stream, the relay drops undeliverable frames but the
node keeps generating until completion — wasted compute bounded by one generation.
Cancellation propagation is future work.
## Acceptance criteria
- `stream: true` chat request via relay delivers SSE chunks to the client
incrementally (test observes ≥2 distinct frame arrivals before `[DONE]`)
- Relayed streamed request records nonzero billed tokens and node work credit
- Non-streamed relayed requests and `/forward` binary hops behave exactly as before
(single frame, `body_base64` round-trip intact)
- Legacy single-frame response from an old node is accepted as terminal
- Idle stream (no frame for 120 s) returns 504 to the client and cleans up the
relay-side queue
- Extend `tests/test_gossip_and_relay.py` alongside
`test_relay_rpc_round_trips_http_request_to_peer`
- `python -m pytest` passes from repo root

View File

@@ -0,0 +1,51 @@
# US-037 — Concurrent request handling in the node relay bridge
Status: planned
Priority: Critical (blocks public friends-test deployment)
Stage: Designed
## Context
`RelayHttpBridge._run` (`packages/node/meshnet_node/relay_bridge.py`) handles
`relay-http-request` envelopes serially inside its recv loop: `_handle_request` blocks
on `urllib.request.urlopen(..., timeout=300)` before the next envelope is read.
Off-LAN this is a correctness bug, not just a throughput limit: a node can be the
**head** of one inference route and a **downstream hop** of another at the same time.
While the head request occupies the bridge (up to 300 s of generation), the other
route's per-token `/forward` calls sit unread in the WebSocket buffer — overlapping
routes through a shared node are effectively broken.
Decision (grilled 2026-07-06): dispatch requests on a **bounded worker pool, default
8, configurable**. Rejected alternatives: unbounded thread-per-request (a public
deployment exposes volunteer machines to request stampedes) and reject-when-full
(bouncing `/forward` hops kills other routes' in-flight sessions; queueing beyond the
cap is today's behavior, just 8-wide).
## Design
- Recv loop only parses envelopes and submits to a
`ThreadPoolExecutor(max_workers=N)`; workers run `_handle_request` and send the
response frame(s).
- All sends on the single relay WebSocket go through a `threading.Lock`. With US-036
a response may be multiple frames — the lock is held **per frame**, so streams from
concurrent requests interleave frame-atomically (receivers demux by `request_id`).
- `N` defaults to 8; configurable via `meshnet-node start --relay-concurrency N`
(env `MESHNET_RELAY_CONCURRENCY`). Requests beyond `N` queue in the executor.
- On reconnect (`_run`'s outer loop), in-flight workers from the dead connection may
still try to send; the send helper swallows failures on a closed socket, and the
relay side times the orphaned request out (US-036 idle timeout / existing 310 s cap).
- `stop()` shuts the executor down without waiting for stragglers (daemon threads,
same as today's bridge thread).
## Acceptance criteria
- While one relayed request is in flight (slow local handler), a second
`relay-http-request` to the same node completes without waiting for the first
- Responses are correctly matched by `request_id` when they complete out of order
- More than `N` simultaneous requests queue and all eventually complete; thread count
never exceeds `N` workers
- Bridge survives a relay reconnect with workers still in flight (no crash, no
deadlock; orphaned responses dropped)
- Extend `tests/test_gossip_and_relay.py`
- `python -m pytest` passes from repo root

View File

@@ -0,0 +1,62 @@
# US-038 — Tracker cluster join via a single seed peer
Status: planned
Priority: High (blocks adding trackers without restarting the fleet)
Stage: Proposed
## Context
Today the tracker cluster is fully static. `RaftNode` and `NodeGossip`
(`packages/tracker/meshnet_tracker/raft.py`, `gossip.py`) take a peer list at
construction and never change it; `/v1/raft/status` does not expose membership.
Consequences of starting a new tracker with only one existing peer in
`--cluster-peers`:
- Existing trackers never learn about the newcomer — they keep heartbeating and
replicating to their original list only.
- Quorum math (`(len(self.peers) + 1) / 2`) differs per tracker, so vote counts
and commit decisions disagree — the cluster silently splits rather than erroring.
Required behavior: a joining tracker is configured with **any one** live tracker
(a seed). It announces itself, the membership change replicates through the Raft
log, and every tracker — including the newcomer — converges on the same full peer
list. Removing the need to restart existing trackers when the hive grows.
## Design
1. **Expose membership.** `GET /v1/cluster/peers` returns
`{"self": <url>, "peers": [<url>, ...]}` (admin-safe: URLs only).
2. **Join handshake.** On startup, the joiner calls the seed's
`POST /v1/cluster/join` with its public self-url. The request is
hive-HMAC-signed (`MESHNET_HIVE_SECRET`, same fail-closed rule as gossip —
an unauthenticated join on a public tracker would let anyone enter the hive).
A non-leader seed forwards to the current leader (same pattern as
`/v1/nodes/register` forwarding).
3. **Membership through the log.** The leader appends a
`cluster-membership` entry (single-server change: add one peer per entry).
`RaftNode` gains `apply`-side handling that swaps `self.peers` — quorum is
recomputed from the applied membership, never from local config. Gossip peer
list updates from the same apply hook.
4. **Joiner bootstrap.** After a successful join response (which includes the
current peer list), the joiner sets its own peers and starts Raft as a
follower; it catches up via normal append-entries.
5. **Persistence.** Applied membership is written to the stats/state sqlite so a
restarted tracker rejoins with the last known list even if its seed is down.
6. **Config semantics.** `--cluster-peers` becomes "seed list": tried in order
until one join succeeds. A tracker with no seeds and no persisted membership
runs standalone (current single-tracker behavior unchanged).
Out of scope: peer removal/eviction (leave via operator restart for now),
joint consensus for multi-server changes, automatic seed retry after startup.
## Acceptance criteria
- Start trackers A+B as a cluster; start C with only A as seed → within one
election timeout, A, B, and C all report the same 3-peer membership on
`GET /v1/cluster/peers`, and a value proposed on C commits on A and B
- Join without a valid hive signature is rejected with 403; join to a follower
is forwarded to the leader transparently
- Restarting C with its seed offline rejoins from persisted membership
- Standalone tracker (no seeds) behaves exactly as today
- `python -m pytest` passes from repo root

View File

@@ -0,0 +1,60 @@
# US-039 — Caller Credit granted once per account; chat requires account keys
Status: planned
Priority: Critical (blocks friends-test inference — every request 402s)
Stage: Designed
## Context
`DEFAULT_STARTING_CREDIT = 0.0` and nothing can raise it: no CLI flag, no admin
credit endpoint. The only funding path is the on-chain USDT deposit watcher —
so on a fresh public tracker every inference request returns
`402 insufficient balance`, including keys created through the working
account flow (`/v1/auth/register``/v1/account/keys`, also in the dashboard).
Separately, the billing gate accepts **any** bearer string (only revoked keys
are rejected), so wiring starter credit into `BillingLedger.ensure_client`
would let anyone mint unlimited free credit by inventing keys.
Decision (grilled 2026-07-06): **Caller Credit is granted once per account**,
when the account creates its first API key; chat requests on an
accounts-enabled tracker must present a real active `sk-mesh-` key.
Rejected: credit-any-bearer (free-inference farming against volunteers' GPUs
on the open internet) and manual-admin-credit-only (operator toil, no
self-serve testing).
## Design
1. **`BillingLedger.grant_caller_credit(api_key, account_id, amount) -> bool`**
Appends a credit event with the deterministic id
`caller-credit-{account_id}` and note `caller-credit`. Already-seen id →
no-op returning False. The deterministic id makes the once-per-account rule
idempotent locally *and* across hive gossip (event dedupe is by id), so a
second key, a retried request, or a replicated event can never double-grant.
2. **`AccountStore.is_active_key(api_key) -> bool`.** The chat billing gate
(`_handle_proxy_chat`) rejects keys that are not active account keys with
`401 invalid_api_key` — but only when an accounts store is configured, so
embedded/test trackers without accounts keep today's any-key behavior.
3. **`--starting-credit N`** (USDT) on `meshnet-tracker start`. Default is the
single constant `DEFAULT_CALLER_CREDIT_USDT = 1.0` in `server.py`
(devnet-friendly alpha, revised 2026-07-06; set 0 on mainnet). Threaded
through `TrackerServer``_TrackerHTTPServer`; registration and
`_handle_account_key_create` call `grant_caller_credit` when billing is on
and the amount is positive. The ledger's own `_starting_credit` stays 0 —
implicit per-key grants in `ensure_client`/`charge_request` remain dead
code paths.
4. **Stack variable `STARTING_CREDIT`** in
`deploy/portainer/meshnet-tracker-nobuild-stack.yml` (default 1).
5. **Docs.** QUICKSTART gains "Get an API key" (register/login/create key via
dashboard or curl, what 402 means); DEPLOY_PORTAINER's stale "any key is
accepted and starts with the configured starter credit" paragraph replaced.
## Acceptance criteria
- Fresh account → first key → key has `--starting-credit` balance; chat succeeds
- Second key on the same account → no additional credit
- Revoke-and-recreate keys → still no additional credit (deterministic event id)
- Random bearer string on an accounts-enabled tracker → 401, never 402/free work
- Tracker without accounts store: gate behavior unchanged
- `--starting-credit 0` disables the grant entirely (mainnet posture)
- `python -m pytest` passes from repo root

View File

@@ -0,0 +1,38 @@
# US-040 — Devnet top-up button on the dashboard
Status: planned
Priority: High (friends-test convenience — refill without on-chain deposits)
Stage: Designed
## Context
Once Caller Credit (US-039) is spent, the only refill path is a real USDT
deposit through the treasury watcher — devnet Solana plumbing friends won't
have. For test deployments the dashboard needs a "add $10" style button.
This is a faucet: 0 disables it, and mainnet deployments must set 0.
## Design
1. **`--devnet-topup N`** (USDT per click) on `meshnet-tracker start`; stack
variable `DEVNET_TOPUP`. Default is the single constant
`DEFAULT_DEVNET_TOPUP_USDT = 1.0` in `server.py` (devnet-friendly alpha,
revised 2026-07-06 — the alpha's public trackers are devnet-only).
2. **`POST /v1/account/topup`** — session-authenticated (same as key
management). Body: `{"api_key": "sk-mesh-..."}`. Rules:
- 404 when the feature is disabled (flag absent/0)
- key must belong to the logged-in account (403 otherwise)
- credits exactly the configured amount (client cannot choose it),
note `devnet-topup`; returns the new balance
3. **Dashboard**: `/v1/account` response gains `topup_amount` (0 when
disabled); when positive, each key row renders a `+$N (devnet)` button
calling the endpoint.
4. **Abuse bound**: per-click amount is operator-set and requires a registered
session; acceptable for a friends-test faucet. Mainnet deployments simply
never set the flag. (Rate limiting is deliberately out of scope.)
## Acceptance criteria
- Flag off: endpoint 404s, dashboard shows no top-up button
- Flag on: logged-in user tops up own key, balance rises by exactly N
- Topping up another account's key → 403
- `python -m pytest` passes from repo root

View File

@@ -0,0 +1,65 @@
# US-041 — Account wallet: browser-extension signing, in-browser generation, export-only
Status: planned
Priority: Medium (not needed for devnet friends test; needed before mainnet)
Stage: Designed
## Context
Registration accepts an optional `wallet` field (a pubkey string) and the
dashboard never shows it. Users need a visible wallet address on the account
panel and a path to real Solana later, without the tracker ever holding user
private keys.
Today wallets matter in two places: client deposit attribution
(`bind_wallet` → deposit watcher) and node payout identity (Pending Balance
keyed by wallet). Accounts themselves hold no keypair — and will stay that way.
## Decision (2026-07-06)
**Non-custodial.** The tracker stores public keys only; private keys never
leave the browser.
1. **External wallets via browser extension.** The dashboard integrates the
standard Solana wallet-adapter flow (Phantom, Solflare, …): connect the
extension, prove ownership by signing a nonce, and the verified pubkey is
stored as the account wallet. All future transaction authorization
(deposits; later, anything requiring a user signature) happens in the
extension — the tracker only ever sees signatures and pubkeys.
2. **New wallets generated in-browser.** For users without an extension the
dashboard generates a keypair client-side (dashboard JS, `Ed25519` via
WebCrypto or bundled tweetnacl — CSP-safe, no CDN). The pubkey is uploaded;
the private key is shown once for export (base58, plaintext acceptable for
devnet) with a "save this now" warning, and optionally kept in
localStorage for convenience on devnet.
3. **No private-key import.** Users cannot paste an existing private key into
the dashboard — accepting foreign private keys is a liability the project
refuses. Users with existing wallets use the extension path (which imports
nothing; it signs in place).
## Scope
- Account panel shows the account wallet address (copy button) or the two
attach flows (connect extension / generate in browser)
- `POST /v1/account/wallet` — session-authenticated; body carries pubkey and,
for the extension flow, the signed-nonce ownership proof (reuse
`wallet_proof.py` if it fits); binds the wallet to the account's keys for
deposit attribution (existing `bind_wallet` path)
- Wallet is per-account; the tracker fans the binding out to the account's
API keys internally
- Mainnet cutover remains config-only (same boundary as Mock USDT, ADR-0015)
Out of scope: password-wrapped keystore export, hardware wallets, changing
the node/miner wallet flow.
## Acceptance criteria
- Connect-extension flow stores a verified pubkey (rejects unsigned/mismatched
nonce proofs)
- Generate flow: pubkey lands on the account; private key is never sent to the
tracker (assert no such field in the request), export works
- No endpoint or UI accepts a private key
- Deposits to the shown address credit the account's keys via the existing
watcher
- Address visible on the account panel after either flow
- `python -m pytest` passes from repo root