4 Commits

Author SHA1 Message Date
Dobromir Popov
7f67e29d76 Merge branch 'master' of https://git.d-popov.com/popov/neuron-tai 2026-07-06 15:51:55 +03:00
Dobromir Popov
ee2711a38a new model support 2026-07-06 15:51:51 +03:00
Dobromir Popov
cdc9f11128 node configs 2026-07-06 14:45:08 +03:00
Dobromir Popov
b547034741 new tasks, devnet topup, cli, new model support 2026-07-06 14:17:36 +03:00
28 changed files with 1380 additions and 64 deletions

6
.gitignore vendored
View File

@@ -11,3 +11,9 @@ dist/
# Ralph local runtime state # Ralph local runtime state
.ralph-tui/ .ralph-tui/
.env
.env.*
!.env.example
!.env.testnet

View File

@@ -54,6 +54,7 @@ on the host firewall if other machines will join:
```bash ```bash
.venv/bin/meshnet-tracker start --host 0.0.0.0 --port 8080 .venv/bin/meshnet-tracker start --host 0.0.0.0 --port 8080
# --starting-credit 1 --devnet-topup 10
``` ```
Verify from the tracker host: Verify from the tracker host:
@@ -514,6 +515,7 @@ Send inference through the tracker (which picks the head node and injects the ro
```bash ```bash
curl -s https://ai.neuron.d-popov.com/v1/chat/completions \ curl -s https://ai.neuron.d-popov.com/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer sk-mesh-<your-key>" \
-d '{ -d '{
"model": "Qwen/Qwen2.5-0.5B-Instruct", "model": "Qwen/Qwen2.5-0.5B-Instruct",
"messages": [{"role": "user", "content": "What is 7 times 8?"}], "messages": [{"role": "user", "content": "What is 7 times 8?"}],
@@ -529,6 +531,47 @@ curl -s http://localhost:7001/v1/chat/completions \
-d '{"model": "Qwen/Qwen2.5-0.5B-Instruct", "messages": [{"role": "user", "content": "Hi"}]}' -d '{"model": "Qwen/Qwen2.5-0.5B-Instruct", "messages": [{"role": "user", "content": "Hi"}]}'
``` ```
## Accounts, API keys, and credit (billing-enabled trackers)
Public trackers run with billing on: `/v1/chat/completions` requires a real
API key from a registered account. Unknown bearer strings get `401`; a key
with no balance gets `402 insufficient balance`.
**Dashboard flow (easiest):** open `https://<tracker>/dashboard`, register with
an email + password, then click **+ new key**. The key (`sk-mesh-…`) shows its
balance next to it. If the tracker was started with `--starting-credit`, your
first key arrives pre-funded (Caller Credit, once per account). If it was
started with `--devnet-topup`, every key row has a **+$N (devnet)** button to
refill during testing.
**Curl flow:**
```bash
# 1. Register (once)
curl -s https://<tracker>/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "hunter22-or-better"}'
# → {"session_token": "...", ...}
# 2. Create an API key (session token from step 1)
curl -s https://<tracker>/v1/account/keys -X POST \
-H "Authorization: Bearer <session_token>"
# → {"api_key": "sk-mesh-...", "caller_credit_granted": true}
# 3. Check balance / usage
curl -s https://<tracker>/v1/account -H "Authorization: Bearer <session_token>"
# 4. (devnet trackers only) top up a key
curl -s https://<tracker>/v1/account/topup -X POST \
-H "Authorization: Bearer <session_token>" \
-H "Content-Type: application/json" \
-d '{"api_key": "sk-mesh-..."}'
```
Operator side: both features default to 1 USDT (`--starting-credit` /
`--devnet-topup`). Set both to 0 on mainnet deployments — real deposits flow
through the on-chain USDT treasury watcher instead.
--- ---
## Step 1 — Start the tracker (Terminal 1) ## Step 1 — Start the tracker (Terminal 1)

BIN
accounts.sqlite Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -28,6 +28,9 @@ services:
PUBLIC_RELAY_URL: ${PUBLIC_RELAY_URL:-} PUBLIC_RELAY_URL: ${PUBLIC_RELAY_URL:-}
HEARTBEAT_TIMEOUT: ${HEARTBEAT_TIMEOUT:-30} HEARTBEAT_TIMEOUT: ${HEARTBEAT_TIMEOUT:-30}
ENABLE_BILLING_DB: ${ENABLE_BILLING_DB:-1} ENABLE_BILLING_DB: ${ENABLE_BILLING_DB:-1}
# Devnet-friendly defaults (US-039/040): set both to 0 on mainnet.
STARTING_CREDIT: ${STARTING_CREDIT:-1}
DEVNET_TOPUP: ${DEVNET_TOPUP:-1}
SOLANA_RPC_URL: ${SOLANA_RPC_URL:-} SOLANA_RPC_URL: ${SOLANA_RPC_URL:-}
USDT_MINT: ${USDT_MINT:-} USDT_MINT: ${USDT_MINT:-}
MESHNET_TREASURY_KEYPAIR_B64: ${MESHNET_TREASURY_KEYPAIR_B64:-} MESHNET_TREASURY_KEYPAIR_B64: ${MESHNET_TREASURY_KEYPAIR_B64:-}
@@ -87,6 +90,8 @@ services:
--relay-url "$${RELAY_URL}" \ --relay-url "$${RELAY_URL}" \
--stats-db /var/lib/meshnet/tracker-stats.sqlite \ --stats-db /var/lib/meshnet/tracker-stats.sqlite \
--accounts-db /var/lib/meshnet/accounts.sqlite \ --accounts-db /var/lib/meshnet/accounts.sqlite \
--starting-credit "$${STARTING_CREDIT:-1}" \
--devnet-topup "$${DEVNET_TOPUP:-1}" \
$${BILLING_ARGS} \ $${BILLING_ARGS} \
$${TREASURY_ARGS} \ $${TREASURY_ARGS} \
$${PEER_ARGS} $${PEER_ARGS}

View File

@@ -43,13 +43,27 @@ ENABLE_BILLING_DB=1
``` ```
For first cloud-only test, use `CLUSTER_PEERS=`. Click **Deploy the stack**. For first cloud-only test, use `CLUSTER_PEERS=`. Click **Deploy the stack**.
(`ai.neuron.d-popov.com` is publicly reachable, so two-tracker sync works over the
internet — but add it only after the cloud-only friends test proves out; two-peer
Raft adds moving parts without fault tolerance.)
`ENABLE_BILLING_DB=1` makes billing public behavior active: `/v1/chat/completions` `ENABLE_BILLING_DB=1` makes billing public behavior active: `/v1/chat/completions`
requires `Authorization: Bearer <api-key>`. Any key is accepted and starts with requires `Authorization: Bearer <sk-mesh-...>` — a real API key created through
the configured starter credit; calls return `402` after that balance is an account (register on `/dashboard`, then "+ new key"); arbitrary bearer
strings are rejected with `401`. Calls return `402` once the key's balance is
exhausted. Set `ENABLE_BILLING_DB=0` if existing unauthenticated clients must exhausted. Set `ENABLE_BILLING_DB=0` if existing unauthenticated clients must
keep working during the first redeploy. keep working during the first redeploy.
Credit variables (US-039/US-040):
```text
STARTING_CREDIT=1 # one-time Caller Credit (USDT) on an account's first key
DEVNET_TOPUP=1 # dashboard "+$N (devnet)" faucet button; 0 disables
```
Both default to **1** (devnet-friendly alpha). On any deployment holding a
mainnet treasury set both to 0 — the faucet mints client balance for free.
Optional Solana treasury settlement variables: Optional Solana treasury settlement variables:
```text ```text
@@ -94,7 +108,9 @@ Custom locations on the same proxy host:
Leave sub-folder forwarding empty. Leave sub-folder forwarding empty.
If WebSockets fail, Advanced: **Required:** NPM custom locations do NOT inherit the proxy host's "Websockets
Support" toggle. Paste this into the **Advanced** box of *each* custom location
(`/ws` and `/rpc`), or every WebSocket handshake to the relay dies at nginx:
```nginx ```nginx
proxy_http_version 1.1; proxy_http_version 1.1;

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

BIN
meshnet_registry.sqlite3 Normal file

Binary file not shown.

View File

@@ -3,12 +3,45 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import socket import socket
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
def _load_env_file(path: Path) -> None:
"""Load simple KEY=VALUE pairs from an env file without overriding env vars."""
if not path.exists():
return
try:
lines = path.read_text().splitlines()
except OSError:
return
for line in lines:
text = line.strip()
if not text or text.startswith("#"):
continue
if text.startswith("export "):
text = text[len("export "):].strip()
if "=" not in text:
continue
key, value = text.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
os.environ[key] = value
def _load_env_defaults() -> None:
"""Load local and user-level node env defaults before config defaults are imported."""
_load_env_file(Path.cwd() / ".env")
_load_env_file(Path.home() / ".config" / "meshnet" / "secrets.env")
def _run_node(cfg: dict) -> None: def _run_node(cfg: dict) -> None:
"""Start the node and hand off to the live dashboard. Blocks until Ctrl-C.""" """Start the node and hand off to the live dashboard. Blocks until Ctrl-C."""
from .startup import run_startup from .startup import run_startup
@@ -224,6 +257,8 @@ def _cmd_start(args) -> int:
def main() -> None: def main() -> None:
_load_env_defaults()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="meshnet-node", prog="meshnet-node",
description="Distributed AI Inference — Node Client", description="Distributed AI Inference — Node Client",

View File

@@ -9,7 +9,11 @@ from pathlib import Path
_DEFAULT_CONFIG_DIR = Path.home() / ".config" / "meshnet" _DEFAULT_CONFIG_DIR = Path.home() / ".config" / "meshnet"
_DEFAULT_CONFIG_FILE = _DEFAULT_CONFIG_DIR / "config.json" _DEFAULT_CONFIG_FILE = _DEFAULT_CONFIG_DIR / "config.json"
_DEFAULT_DOWNLOAD_DIR = Path.home() / ".meshnet" / "models" # MESHNET_DOWNLOAD_DIR overrides the model store for every node on this
# machine (all CLI flows fall back to this default; --download-dir still wins).
_DEFAULT_DOWNLOAD_DIR = Path(
os.environ.get("MESHNET_DOWNLOAD_DIR", str(Path.home() / ".meshnet" / "models"))
)
_DEFAULT_TRACKER_URL = "http://localhost:8080" _DEFAULT_TRACKER_URL = "http://localhost:8080"
_DEFAULT_WALLET_PATH = str(Path.home() / ".config" / "meshnet" / "wallet.json") _DEFAULT_WALLET_PATH = str(Path.home() / ".config" / "meshnet" / "wallet.json")
_DEFAULT_QUANTIZATION = "nf4" _DEFAULT_QUANTIZATION = "nf4"

View File

@@ -159,6 +159,30 @@ CURATED_MODELS: list[ModelPreset] = [
] ]
def layers_from_config(cfg) -> int | None:
"""Extract the transformer layer count from a HuggingFace config object.
Composite configs (vision-language, some MoE) nest the decoder settings in
``text_config`` — e.g. Qwen3.5-MoE has no top-level ``num_hidden_layers``.
"""
candidates = [cfg]
get_text_config = getattr(cfg, "get_text_config", None)
if callable(get_text_config):
try:
candidates.append(get_text_config())
except Exception:
pass
nested = getattr(cfg, "text_config", None)
if nested is not None:
candidates.append(nested)
for candidate in candidates:
for attr in ("num_hidden_layers", "num_layers", "n_layer", "n_layers"):
value = getattr(candidate, attr, None)
if value is not None:
return int(value)
return None
def detect_num_layers(hf_repo: str) -> int | None: def detect_num_layers(hf_repo: str) -> int | None:
"""Return num_hidden_layers from HuggingFace config.json (downloads ~1 KB only).""" """Return num_hidden_layers from HuggingFace config.json (downloads ~1 KB only)."""
# Check curated list first (no network call) # Check curated list first (no network call)
@@ -168,7 +192,7 @@ def detect_num_layers(hf_repo: str) -> int | None:
try: try:
from transformers import AutoConfig # type: ignore[import] from transformers import AutoConfig # type: ignore[import]
cfg = AutoConfig.from_pretrained(hf_repo) cfg = AutoConfig.from_pretrained(hf_repo)
return int(cfg.num_hidden_layers) return layers_from_config(cfg)
except Exception: except Exception:
return None return None
@@ -195,6 +219,8 @@ def model_metadata_for(
hf_repo, hf_repo,
cache_dir=str(cache_dir) if cache_dir is not None else None, cache_dir=str(cache_dir) if cache_dir is not None else None,
) )
# Composite configs (VLM/MoE) nest decoder fields in text_config.
text_cfg = getattr(cfg, "text_config", None) or cfg
for attr, key in ( for attr, key in (
("model_type", "architecture"), ("model_type", "architecture"),
("num_hidden_layers", "num_layers"), ("num_hidden_layers", "num_layers"),
@@ -204,8 +230,14 @@ def model_metadata_for(
("max_position_embeddings", "context_length"), ("max_position_embeddings", "context_length"),
): ):
value = getattr(cfg, attr, None) value = getattr(cfg, attr, None)
if value is None:
value = getattr(text_cfg, attr, None)
if value is not None: if value is not None:
metadata[key] = value metadata[key] = value
if "num_layers" not in metadata:
layers = layers_from_config(cfg)
if layers is not None:
metadata["num_layers"] = layers
except Exception: except Exception:
pass pass
return metadata return metadata

View File

@@ -5,14 +5,18 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging import logging
import os
import threading import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
DEFAULT_MAX_CONCURRENCY = 8
@dataclass(frozen=True) @dataclass(frozen=True)
class RelayBridgeInfo: class RelayBridgeInfo:
@@ -32,8 +36,23 @@ def _make_envelope(topic: str, payload: dict, peer_id: str) -> dict:
} }
def _max_concurrency_from_env() -> int:
try:
value = int(os.environ.get("MESHNET_RELAY_CONCURRENCY", DEFAULT_MAX_CONCURRENCY))
except (TypeError, ValueError):
return DEFAULT_MAX_CONCURRENCY
return max(1, value)
class RelayHttpBridge: class RelayHttpBridge:
"""Connect outbound to a relay and proxy relay HTTP requests to localhost.""" """Connect outbound to a relay and proxy relay HTTP requests to localhost.
Requests are dispatched on a bounded worker pool (US-037) so a node that is
the head of one route can still serve per-token ``/forward`` hops of another.
Streaming responses (``text/event-stream``) are forwarded as multiple chunk
frames sharing one ``request_id`` (US-036); everything else stays a single
frame for backward compatibility.
"""
def __init__( def __init__(
self, self,
@@ -42,15 +61,20 @@ class RelayHttpBridge:
local_base_url: str, local_base_url: str,
advertised_addr: str, advertised_addr: str,
reconnect_interval: float = 3.0, reconnect_interval: float = 3.0,
max_concurrency: int | None = None,
) -> None: ) -> None:
self.relay_url = relay_url.rstrip("/") self.relay_url = relay_url.rstrip("/")
self.peer_id = peer_id self.peer_id = peer_id
self.local_base_url = local_base_url.rstrip("/") self.local_base_url = local_base_url.rstrip("/")
self.advertised_addr = advertised_addr self.advertised_addr = advertised_addr
self.reconnect_interval = reconnect_interval self.reconnect_interval = reconnect_interval
self.max_concurrency = max(1, max_concurrency) if max_concurrency else _max_concurrency_from_env()
self._running = False self._running = False
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._connected = threading.Event() self._connected = threading.Event()
self._executor: ThreadPoolExecutor | None = None
self._send_lock = threading.Lock()
self._ws = None
@property @property
def relay_addr(self) -> str: def relay_addr(self) -> str:
@@ -61,6 +85,9 @@ class RelayHttpBridge:
def start(self) -> RelayBridgeInfo: def start(self) -> RelayBridgeInfo:
self._running = True self._running = True
self._executor = ThreadPoolExecutor(
max_workers=self.max_concurrency, thread_name_prefix="relay-http-worker"
)
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge") self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
self._thread.start() self._thread.start()
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr) return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
@@ -72,6 +99,8 @@ class RelayHttpBridge:
self._running = False self._running = False
if self._thread: if self._thread:
self._thread.join(timeout=3.0) self._thread.join(timeout=3.0)
if self._executor is not None:
self._executor.shutdown(wait=False)
def _run(self) -> None: def _run(self) -> None:
import websockets.sync.client as wsc # type: ignore[import] import websockets.sync.client as wsc # type: ignore[import]
@@ -79,6 +108,7 @@ class RelayHttpBridge:
while self._running: while self._running:
try: try:
with wsc.connect(self.relay_url, open_timeout=5) as ws: with wsc.connect(self.relay_url, open_timeout=5) as ws:
self._ws = ws
self._connected.set() self._connected.set()
ws.send(json.dumps(_make_envelope( ws.send(json.dumps(_make_envelope(
"peer-register", "peer-register",
@@ -99,19 +129,36 @@ class RelayHttpBridge:
payload = envelope.get("payload", {}) payload = envelope.get("payload", {})
if payload.get("target_peer") not in {None, self.peer_id}: if payload.get("target_peer") not in {None, self.peer_id}:
continue continue
response = self._handle_request(payload) if self._executor is None:
ws.send(json.dumps(_make_envelope( break
"relay-http-response", self._executor.submit(self._process_request, payload)
response,
self.peer_id,
)))
except Exception as exc: except Exception as exc:
self._connected.clear() self._connected.clear()
self._ws = None
if self._running: if self._running:
log.debug("relay bridge disconnected: %s", exc) log.debug("relay bridge disconnected: %s", exc)
time.sleep(self.reconnect_interval) time.sleep(self.reconnect_interval)
self._ws = None
def _handle_request(self, payload: dict) -> dict: def _send_response_frame(self, payload: dict) -> bool:
"""Send one relay-http-response frame; False if the socket is gone.
The lock is held per frame so concurrent workers interleave whole
frames on the shared WebSocket, never torn ones.
"""
ws = self._ws
if ws is None:
return False
message = json.dumps(_make_envelope("relay-http-response", payload, self.peer_id))
try:
with self._send_lock:
ws.send(message)
return True
except Exception as exc:
log.debug("relay bridge send failed (request orphaned): %s", exc)
return False
def _process_request(self, payload: dict) -> None:
request_id = str(payload.get("request_id") or "") request_id = str(payload.get("request_id") or "")
method = str(payload.get("method") or "POST").upper() method = str(payload.get("method") or "POST").upper()
path = str(payload.get("path") or "/") path = str(payload.get("path") or "/")
@@ -130,10 +177,14 @@ class RelayHttpBridge:
req = urllib.request.Request(url, data=data, headers=headers, method=method) req = urllib.request.Request(url, data=data, headers=headers, method=method)
try: try:
with urllib.request.urlopen(req, timeout=300.0) as resp: with urllib.request.urlopen(req, timeout=300.0) as resp:
resp_bytes = resp.read()
resp_headers = dict(resp.headers) resp_headers = dict(resp.headers)
content_type = resp.headers.get("Content-Type", "")
if "text/event-stream" in content_type:
self._stream_response(request_id, resp, resp_headers)
return
resp_bytes = resp.read()
# Forward all X-Meshnet-* headers so the caller can reconstruct the activation. # Forward all X-Meshnet-* headers so the caller can reconstruct the activation.
is_binary = "octet-stream" in resp.headers.get("Content-Type", "") is_binary = "octet-stream" in content_type
result: dict = { result: dict = {
"request_id": request_id, "request_id": request_id,
"status": resp.status, "status": resp.status,
@@ -143,21 +194,66 @@ class RelayHttpBridge:
result["body_base64"] = base64.b64encode(resp_bytes).decode() result["body_base64"] = base64.b64encode(resp_bytes).decode()
else: else:
result["body"] = resp_bytes.decode(errors="replace") result["body"] = resp_bytes.decode(errors="replace")
return result self._send_response_frame(result)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return { self._send_response_frame({
"request_id": request_id, "request_id": request_id,
"status": exc.code, "status": exc.code,
"headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")}, "headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")},
"body": exc.read().decode(errors="replace"), "body": exc.read().decode(errors="replace"),
} })
except Exception as exc: except Exception as exc:
return { self._send_response_frame({
"request_id": request_id, "request_id": request_id,
"status": 503, "status": 503,
"headers": {"Content-Type": "application/json"}, "headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}), "body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
} })
def _stream_response(self, request_id: str, resp, resp_headers: dict) -> None:
"""Forward an SSE response as chunk frames, one per complete SSE event.
Frame order: header frame (status + headers), chunk frames, done frame.
A receiver that sees no ``stream`` key treats the frame as a complete
legacy response, so non-streaming peers are unaffected.
"""
sent = self._send_response_frame({
"request_id": request_id,
"status": resp.status,
"headers": resp_headers,
"stream": True,
"done": False,
})
if not sent:
return
event_lines: list[str] = []
for raw_line in resp:
line = raw_line.decode(errors="replace")
event_lines.append(line)
if line.strip():
continue
# Blank line terminates one SSE event — flush it as a frame.
if not self._send_response_frame({
"request_id": request_id,
"stream": True,
"chunk": "".join(event_lines),
"done": False,
}):
return
event_lines = []
if event_lines:
if not self._send_response_frame({
"request_id": request_id,
"stream": True,
"chunk": "".join(event_lines),
"done": False,
}):
return
self._send_response_frame({
"request_id": request_id,
"stream": True,
"done": True,
})
def peer_id_from_wallet(wallet_address: str) -> str: def peer_id_from_wallet(wallet_address: str) -> str:

View File

@@ -734,11 +734,20 @@ def _detect_num_layers(model_id: str, cache_dir: Path | None = None) -> int | No
"""Fetch num_hidden_layers from HuggingFace model config (downloads ~1 KB config.json only).""" """Fetch num_hidden_layers from HuggingFace model config (downloads ~1 KB config.json only)."""
try: try:
from transformers import AutoConfig # type: ignore[import] from transformers import AutoConfig # type: ignore[import]
from .model_catalog import layers_from_config
cfg = AutoConfig.from_pretrained( cfg = AutoConfig.from_pretrained(
model_id, model_id,
cache_dir=str(cache_dir) if cache_dir is not None else None, cache_dir=str(cache_dir) if cache_dir is not None else None,
) )
return int(cfg.num_hidden_layers) layers = layers_from_config(cfg)
if layers is None:
print(
f" Warning: no layer count in {model_id} config "
"(checked top level and text_config)", flush=True,
)
return layers
except Exception as exc: except Exception as exc:
print(f" Warning: could not read model config from HF: {exc}", flush=True) print(f" Warning: could not read model config from HF: {exc}", flush=True)
return None return None

View File

@@ -19,6 +19,7 @@ dependencies = [
"transformers>=4.39", "transformers>=4.39",
"websockets>=13", "websockets>=13",
"zstandard>=0.22", "zstandard>=0.22",
"kernels>=0.11.1,<0.16",
] ]
[project.scripts] [project.scripts]

View File

@@ -57,7 +57,9 @@ class RelayServer:
self._ready = threading.Event() self._ready = threading.Event()
self._running = False self._running = False
self._stop_event: asyncio.Event | None = None self._stop_event: asyncio.Event | None = None
self._pending_rpc: dict[str, asyncio.Future] = {} # request_id → queue of relay-http-response frames (US-036: a streamed
# response is a sequence of frames; a frame without "stream" is terminal).
self._pending_rpc: dict[str, asyncio.Queue] = {}
@property @property
def registry(self) -> PeerRegistry: def registry(self) -> PeerRegistry:
@@ -172,9 +174,9 @@ class RelayServer:
if topic == "relay-http-response": if topic == "relay-http-response":
payload = envelope.get("payload", {}) payload = envelope.get("payload", {})
request_id = payload.get("request_id") request_id = payload.get("request_id")
fut = self._pending_rpc.pop(request_id, None) queue = self._pending_rpc.get(request_id)
if fut is not None and not fut.done(): if queue is not None:
fut.set_result(payload) queue.put_nowait(payload)
continue continue
# Fan out to all other registered peers # Fan out to all other registered peers
@@ -240,8 +242,12 @@ class RelayServer:
request_id = str(payload.get("request_id") or uuid.uuid4()) request_id = str(payload.get("request_id") or uuid.uuid4())
payload["request_id"] = request_id payload["request_id"] = request_id
payload["target_peer"] = target_peer_id payload["target_peer"] = target_peer_id
fut = self._loop.create_future() if self._loop is not None else asyncio.get_running_loop().create_future() queue: asyncio.Queue = asyncio.Queue()
self._pending_rpc[request_id] = fut self._pending_rpc[request_id] = queue
overall_timeout = 310.0
idle_timeout = 120.0
loop = asyncio.get_running_loop()
deadline = loop.time() + overall_timeout
try: try:
await target.ws.send(json.dumps({ await target.ws.send(json.dumps({
"topic": "relay-http-request", "topic": "relay-http-request",
@@ -249,8 +255,19 @@ class RelayServer:
"from_peer": "relay", "from_peer": "relay",
"payload": payload, "payload": payload,
})) }))
response = await asyncio.wait_for(fut, timeout=310.0) # Forward frames until a terminal one: streamed responses (US-036)
await ws_requester.send(json.dumps(response)) # end with {"stream": true, "done": true}; a frame without "stream"
# is a complete legacy single response.
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise asyncio.TimeoutError
frame = await asyncio.wait_for(
queue.get(), timeout=min(idle_timeout, remaining)
)
await ws_requester.send(json.dumps(frame))
if not frame.get("stream") or frame.get("done"):
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
await ws_requester.send(json.dumps({ await ws_requester.send(json.dumps({
"request_id": request_id, "request_id": request_id,

View File

@@ -176,6 +176,14 @@ class AccountStore:
with self._lock: with self._lock:
return api_key in self._revoked_keys return api_key in self._revoked_keys
def is_active_key(self, api_key: str) -> bool:
with self._lock:
return api_key in self._active_keys
def owner_of_key(self, api_key: str) -> str | None:
with self._lock:
return self._active_keys.get(api_key)
# ---- views ---- # ---- views ----
def _public_view(self, record: dict) -> dict: def _public_view(self, record: dict) -> dict:

View File

@@ -87,6 +87,29 @@ class BillingLedger:
def has_funds(self, api_key: str) -> bool: def has_funds(self, api_key: str) -> bool:
return self.ensure_client(api_key) > 0.0 return self.ensure_client(api_key) > 0.0
def grant_caller_credit(self, api_key: str, account_id: str, amount: float) -> bool:
"""Grant the one-time Caller Credit for an account (US-039).
The event id is derived from the account, not the key, so the grant is
idempotent per account — across retries, additional keys, and hive
gossip replication alike. Returns True only when credit was applied.
"""
if amount <= 0:
return False
event_id = f"caller-credit-{account_id}"
with self._lock:
if event_id in self._seen_event_ids:
return False
self._apply_locked({
"id": event_id,
"type": "credit",
"api_key": api_key,
"amount": amount,
"ts": time.time(),
"note": "caller-credit",
})
return True
def credit_client(self, api_key: str, amount: float, *, note: str = "deposit") -> float: def credit_client(self, api_key: str, amount: float, *, note: str = "deposit") -> float:
if amount <= 0: if amount <= 0:
raise ValueError("credit amount must be positive") raise ValueError("credit amount must be positive")

View File

@@ -7,7 +7,12 @@ import time
from .accounts import DEFAULT_ACCOUNTS_DB_PATH from .accounts import DEFAULT_ACCOUNTS_DB_PATH
from .billing import DEFAULT_BILLING_DB_PATH from .billing import DEFAULT_BILLING_DB_PATH
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH
from .server import TrackerServer, derive_relay_url_from_public_tracker_url from .server import (
DEFAULT_CALLER_CREDIT_USDT,
DEFAULT_DEVNET_TOPUP_USDT,
TrackerServer,
derive_relay_url_from_public_tracker_url,
)
DEFAULT_REGISTRY_DB_PATH = "meshnet_registry.sqlite3" DEFAULT_REGISTRY_DB_PATH = "meshnet_registry.sqlite3"
@@ -66,6 +71,28 @@ def main() -> None:
"token bound would cost more than this many USDT" "token bound would cost more than this many USDT"
), ),
) )
common.add_argument(
"--starting-credit",
type=float,
default=DEFAULT_CALLER_CREDIT_USDT,
metavar="USDT",
help=(
"One-time Caller Credit granted when an account creates its first "
f"API key (default: {DEFAULT_CALLER_CREDIT_USDT}; set 0 to require "
"deposits before inference)"
),
)
common.add_argument(
"--devnet-topup",
type=float,
default=DEFAULT_DEVNET_TOPUP_USDT,
metavar="USDT",
help=(
"Dashboard devnet top-up faucet: each click credits this many USDT "
f"to one of the account's keys (default: {DEFAULT_DEVNET_TOPUP_USDT}; "
"MUST be 0 on mainnet deployments)"
),
)
common.add_argument( common.add_argument(
"--registry-db", "--registry-db",
default=DEFAULT_REGISTRY_DB_PATH, default=DEFAULT_REGISTRY_DB_PATH,
@@ -231,6 +258,8 @@ def main() -> None:
enable_billing=not args.no_billing, enable_billing=not args.no_billing,
billing_db=None if args.no_billing else args.billing_db, billing_db=None if args.no_billing else args.billing_db,
max_charge_per_request=args.max_charge_per_request, max_charge_per_request=args.max_charge_per_request,
starting_credit=args.starting_credit,
devnet_topup_amount=args.devnet_topup,
contracts=contracts, contracts=contracts,
accounts_db=None if args.no_accounts else args.accounts_db, accounts_db=None if args.no_accounts else args.accounts_db,
treasury=treasury, treasury=treasury,

View File

@@ -297,6 +297,12 @@ async function revokeKey(key) {
await renderAccountPanel(); await renderAccountPanel();
} }
async function topupKey(key) {
const r = await apiCall("/v1/account/topup", "POST", { api_key: key });
if (!r.ok) alert(r.data.error || "top-up failed");
await renderAccountPanel();
}
async function renderAccountPanel() { async function renderAccountPanel() {
const r = await apiCall("/v1/account"); const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker if (r.status === 404) { // accounts disabled on this tracker
@@ -305,7 +311,7 @@ async function renderAccountPanel() {
return; return;
} }
if (!r.ok) { setSession(null); renderAuthForms(); return; } if (!r.ok) { setSession(null); renderAuthForms(); return; }
const { account, api_keys, balances, total_balance, usage } = r.data; const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
const who = account.email || account.wallet || account.account_id; const who = account.email || account.wallet || account.account_id;
let html = let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` + `<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
@@ -319,6 +325,9 @@ async function renderAccountPanel() {
for (const key of api_keys) { for (const key of api_keys) {
html += `<div class="keybox">${esc(key)}` + html += `<div class="keybox">${esc(key)}` +
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` + ` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
(topup_amount > 0
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
: "") +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`; ` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
} }
} else { } else {

View File

@@ -102,6 +102,11 @@ DEFAULT_VRAM_BYTES = 8 * 1024 * 1024 * 1024
DEFAULT_RAM_BYTES = 16 * 1024 * 1024 * 1024 DEFAULT_RAM_BYTES = 16 * 1024 * 1024 * 1024
DEFAULT_QUANTIZATIONS = ["bfloat16"] DEFAULT_QUANTIZATIONS = ["bfloat16"]
DEFAULT_BENCHMARK_TOKENS_PER_SEC = 1.0 DEFAULT_BENCHMARK_TOKENS_PER_SEC = 1.0
# US-039/US-040 — single source of truth for the credit defaults (referenced by
# TrackerServer, _TrackerHTTPServer, and the CLI). Alpha runs devnet-friendly;
# flip both to 0 before any deployment holding a mainnet treasury.
DEFAULT_CALLER_CREDIT_USDT = 1.0
DEFAULT_DEVNET_TOPUP_USDT = 1.0
def _model_aliases(model: str | None) -> set[str]: def _model_aliases(model: str | None) -> set[str]:
@@ -909,18 +914,29 @@ def _coverage_gaps(coverage: list[dict]) -> list[tuple[int, int]]:
] ]
def _relay_http_request( def _relay_http_request_frames(
relay_addr: str, relay_addr: str,
path: str, path: str,
body: bytes, body: bytes,
headers: dict[str, str], headers: dict[str, str],
timeout: float = 310.0, timeout: float = 310.0,
) -> dict | None: idle_timeout: float = 120.0,
"""Send an HTTP-shaped request through a relay RPC WebSocket.""" ):
"""Send an HTTP-shaped request through a relay RPC WebSocket, yielding
response frames until a terminal one (US-036).
A frame with ``stream: true`` is part of a chunked SSE response ending with
``done: true``; a frame without ``stream`` is a complete single response.
Yields nothing when the relay is unreachable; stops silently on idle or
overall timeout (the caller bills whatever was observed).
"""
try: try:
import websockets.sync.client as wsc # type: ignore[import] import websockets.sync.client as wsc # type: ignore[import]
except Exception:
request_id = str(uuid.uuid4()) return
request_id = str(uuid.uuid4())
deadline = time.monotonic() + timeout
try:
with wsc.connect(relay_addr, open_timeout=10, close_timeout=5) as ws: with wsc.connect(relay_addr, open_timeout=10, close_timeout=5) as ws:
ws.send(json.dumps({ ws.send(json.dumps({
"request_id": request_id, "request_id": request_id,
@@ -929,13 +945,63 @@ def _relay_http_request(
"headers": headers, "headers": headers,
"body": body.decode(errors="replace"), "body": body.decode(errors="replace"),
})) }))
raw = ws.recv(timeout=timeout) while True:
response = json.loads(raw) remaining = deadline - time.monotonic()
if response.get("request_id") not in {None, request_id}: if remaining <= 0:
return None return
return response raw = ws.recv(timeout=min(idle_timeout, remaining))
frame = json.loads(raw)
if frame.get("request_id") not in {None, request_id}:
continue
yield frame
if not frame.get("stream") or frame.get("done"):
return
except Exception: except Exception:
return
def _relay_http_request(
relay_addr: str,
path: str,
body: bytes,
headers: dict[str, str],
timeout: float = 310.0,
) -> dict | None:
"""Send an HTTP-shaped request through a relay and buffer the response.
Streamed frame sequences are collapsed into one response dict whose body is
the concatenated SSE text — used by non-chat callers and kept for
backward compatibility; the chat proxy streams frames directly.
"""
frames = _relay_http_request_frames(relay_addr, path, body, headers, timeout=timeout)
first = next(frames, None)
if first is None:
return None return None
if not first.get("stream"):
return first
chunks = [first.get("chunk") or ""]
for frame in frames:
chunks.append(frame.get("chunk") or "")
return {
"request_id": first.get("request_id"),
"status": first.get("status", 200),
"headers": first.get("headers") or {},
"body": "".join(chunks),
}
def _stream_line_tokens(line: bytes) -> tuple[int, int | None]:
"""Token accounting for one SSE line: (observed delta, reported total or None)."""
if not line.startswith(b"data:"):
return 0, None
payload = line[5:].strip()
if not payload or payload == b"[DONE]":
return 0, None
try:
chunk_payload = json.loads(payload)
except json.JSONDecodeError:
return 1, None
return _observed_stream_tokens(chunk_payload), _usage_total_tokens(chunk_payload)
def _find_pinned_route( def _find_pinned_route(
@@ -1507,6 +1573,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
validator_service_token: str | None = None, validator_service_token: str | None = None,
hive_secret: str | None = None, hive_secret: str | None = None,
max_charge_per_request: float | None = None, max_charge_per_request: float | None = None,
starting_credit: float = DEFAULT_CALLER_CREDIT_USDT,
devnet_topup_amount: float = DEFAULT_DEVNET_TOPUP_USDT,
toploc_calibration: "ToplocCalibrationStore | None" = None, toploc_calibration: "ToplocCalibrationStore | None" = None,
toploc_reference_node_url: str | None = None, toploc_reference_node_url: str | None = None,
toploc_calibration_gate_min_hardware_profiles: int = 1, toploc_calibration_gate_min_hardware_profiles: int = 1,
@@ -1533,6 +1601,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
self.validator_service_token = validator_service_token self.validator_service_token = validator_service_token
self.hive_secret = hive_secret self.hive_secret = hive_secret
self.max_charge_per_request = max_charge_per_request self.max_charge_per_request = max_charge_per_request
self.starting_credit = starting_credit
self.devnet_topup_amount = devnet_topup_amount
self.toploc_calibration: ToplocCalibrationStore | None = toploc_calibration self.toploc_calibration: ToplocCalibrationStore | None = toploc_calibration
self.toploc_reference_node_url = ( self.toploc_reference_node_url = (
toploc_reference_node_url.rstrip("/") if toploc_reference_node_url else None toploc_reference_node_url.rstrip("/") if toploc_reference_node_url else None
@@ -1672,6 +1742,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
if self.path == "/v1/account/keys/revoke": if self.path == "/v1/account/keys/revoke":
self._handle_account_key_revoke() self._handle_account_key_revoke()
return return
if self.path == "/v1/account/topup":
self._handle_account_topup()
return
if self.path == "/v1/accounts/gossip": if self.path == "/v1/accounts/gossip":
self._handle_accounts_gossip() self._handle_accounts_gossip()
return return
@@ -2008,6 +2081,15 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"code": "invalid_api_key", "code": "invalid_api_key",
}}) }})
return return
# US-039: with accounts enabled, only real account keys may spend —
# arbitrary bearer strings must never become billable clients.
if server.accounts is not None and not server.accounts.is_active_key(api_key):
self._send_json(401, {"error": {
"message": "unknown API key: create one at /dashboard (register, then + new key)",
"type": "invalid_request_error",
"code": "invalid_api_key",
}})
return
if not server.billing.has_funds(api_key): if not server.billing.has_funds(api_key):
self._send_json(402, {"error": { self._send_json(402, {"error": {
"message": "insufficient balance: deposit USDT to continue", "message": "insufficient balance: deposit USDT to continue",
@@ -2181,17 +2263,26 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
flush=True, flush=True,
) )
started = time.monotonic() started = time.monotonic()
relayed = _relay_http_request( frames = _relay_http_request_frames(
node.relay_addr, node.relay_addr,
path="/v1/chat/completions", path="/v1/chat/completions",
body=raw_body, body=raw_body,
headers=relay_headers, headers=relay_headers,
) )
elapsed = time.monotonic() - started first = next(frames, None)
if relayed is not None: if first is not None and first.get("stream"):
self._send_relayed_response(relayed) # Streamed response (US-036): forward SSE chunks as they arrive
if int(relayed.get("status", 503)) < 400: # and run the same token accounting as the direct stream path.
body_text = relayed.get("body") or "" self._stream_relayed_frames(
first, frames, started,
model, route_model, route_nodes, api_key, node_work,
)
return
if first is not None:
elapsed = time.monotonic() - started
self._send_relayed_response(first)
if int(first.get("status", 503)) < 400:
body_text = first.get("body") or ""
try: try:
tokens = _billable_non_stream_tokens(json.loads(body_text), body) tokens = _billable_non_stream_tokens(json.loads(body_text), body)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
@@ -2254,18 +2345,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
break break
self.wfile.write(line) self.wfile.write(line)
self.wfile.flush() self.wfile.flush()
if line.startswith(b"data:"): observed, reported = _stream_line_tokens(line)
payload = line[5:].strip() observed_stream_tokens += observed
if payload and payload != b"[DONE]": if reported is not None:
try: reported_stream_tokens = reported
chunk_payload = json.loads(payload)
except json.JSONDecodeError:
observed_stream_tokens += 1
continue
observed_stream_tokens += _observed_stream_tokens(chunk_payload)
found = _usage_total_tokens(chunk_payload)
if found is not None:
reported_stream_tokens = found
except BrokenPipeError: except BrokenPipeError:
pass pass
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -2410,6 +2493,49 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
except Exception as exc: except Exception as exc:
print(f"[tracker] billing failed for model={model!r}: {exc}", flush=True) print(f"[tracker] billing failed for model={model!r}: {exc}", flush=True)
def _stream_relayed_frames(
self,
first: dict,
frames,
started: float,
model: str,
route_model: str,
route_nodes: list,
api_key: str | None,
node_work: list,
) -> None:
"""Forward a streamed relay response (US-036) to the client as SSE,
billing with the same accounting as the direct stream path."""
headers = first.get("headers") if isinstance(first.get("headers"), dict) else {}
self.send_response(int(first.get("status", 200)))
self.send_header("Content-Type", headers.get("Content-Type", "text/event-stream; charset=utf-8"))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
reported_stream_tokens: int | None = None
observed_stream_tokens = 0
client_gone = False
for frame in itertools.chain([first], frames):
chunk = frame.get("chunk") or ""
if not chunk:
continue
data = chunk.encode()
if not client_gone:
try:
self.wfile.write(data)
self.wfile.flush()
except BrokenPipeError:
# Keep draining frames — the nodes did the work; bill it.
client_gone = True
for line in data.splitlines():
observed, reported = _stream_line_tokens(line)
observed_stream_tokens += observed
if reported is not None:
reported_stream_tokens = reported
elapsed = time.monotonic() - started
observed_tokens = _billable_stream_tokens(observed_stream_tokens, reported_stream_tokens)
self._record_observed_throughput(model, route_model, observed_tokens, elapsed, route_nodes)
self._bill_completed(api_key, model, observed_tokens, node_work)
def _send_relayed_response(self, response: dict) -> None: def _send_relayed_response(self, response: dict) -> None:
status = int(response.get("status", 503)) status = int(response.get("status", 503))
headers = response.get("headers") if isinstance(response.get("headers"), dict) else {} headers = response.get("headers") if isinstance(response.get("headers"), dict) else {}
@@ -2909,6 +3035,12 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
api_key = accounts.create_api_key(account["account_id"]) api_key = accounts.create_api_key(account["account_id"])
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.billing is not None: if server.billing is not None:
# US-039: registration creates the account's first key, so Caller
# Credit lands here; the account-derived event id keeps later
# grants (e.g. via /v1/account/keys) no-ops.
server.billing.grant_caller_credit(
api_key, account["account_id"], server.starting_credit
)
server.billing.ensure_client(api_key) server.billing.ensure_client(api_key)
print( print(
f"[tracker] account registered: {account.get('email') or account.get('wallet')} " f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
@@ -2959,6 +3091,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"balances": balances, "balances": balances,
"total_balance": sum(balances.values()), "total_balance": sum(balances.values()),
"usage": usage, "usage": usage,
"topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0,
}) })
def _handle_account_key_create(self): def _handle_account_key_create(self):
@@ -2971,9 +3104,48 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
return return
api_key = accounts.create_api_key(account["account_id"]) api_key = accounts.create_api_key(account["account_id"])
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
credited = False
if server.billing is not None: if server.billing is not None:
# US-039: Caller Credit lands with the account's first key; the
# account-derived event id keeps it once-per-account forever.
credited = server.billing.grant_caller_credit(
api_key, account["account_id"], server.starting_credit
)
server.billing.ensure_client(api_key) server.billing.ensure_client(api_key)
self._send_json(200, {"api_key": api_key}) self._send_json(200, {"api_key": api_key, "caller_credit_granted": credited})
def _handle_account_topup(self):
"""Devnet faucet (US-040): credit the configured amount to one of the
logged-in account's keys. 404 unless the operator enabled it."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
accounts = self._require_accounts()
if accounts is None:
return
if server.billing is None or server.devnet_topup_amount <= 0:
self._send_json(404, {"error": "top-up is not enabled on this tracker"})
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
body = self._read_json_body()
if body is None:
return
api_key = body.get("api_key")
if not api_key or not isinstance(api_key, str):
self._send_json(400, {"error": "api_key is required"})
return
if accounts.owner_of_key(api_key) != account["account_id"]:
self._send_json(403, {"error": "key not found on this account"})
return
balance = server.billing.credit_client(
api_key, server.devnet_topup_amount, note="devnet-topup"
)
self._send_json(200, {
"api_key": api_key,
"credited": server.devnet_topup_amount,
"balance": balance,
})
def _handle_account_key_revoke(self): def _handle_account_key_revoke(self):
accounts = self._require_accounts() accounts = self._require_accounts()
@@ -3851,6 +4023,8 @@ class TrackerServer:
validator_service_token: str | None = None, validator_service_token: str | None = None,
hive_secret: str | None = None, hive_secret: str | None = None,
max_charge_per_request: float | None = None, max_charge_per_request: float | None = None,
starting_credit: float = DEFAULT_CALLER_CREDIT_USDT,
devnet_topup_amount: float = DEFAULT_DEVNET_TOPUP_USDT,
toploc_calibration: ToplocCalibrationStore | None = None, toploc_calibration: ToplocCalibrationStore | None = None,
toploc_calibration_db: str | None = None, toploc_calibration_db: str | None = None,
toploc_reference_node_url: str | None = None, toploc_reference_node_url: str | None = None,
@@ -3923,6 +4097,8 @@ class TrackerServer:
if max_charge_per_request is not None and max_charge_per_request <= 0.0: if max_charge_per_request is not None and max_charge_per_request <= 0.0:
raise ValueError("max_charge_per_request must be positive") raise ValueError("max_charge_per_request must be positive")
self._max_charge_per_request = max_charge_per_request self._max_charge_per_request = max_charge_per_request
self._starting_credit = max(0.0, starting_credit)
self._devnet_topup_amount = max(0.0, devnet_topup_amount)
self._validator_service_token = ( self._validator_service_token = (
validator_service_token validator_service_token
if validator_service_token is not None if validator_service_token is not None
@@ -3976,6 +4152,8 @@ class TrackerServer:
validator_service_token=self._validator_service_token, validator_service_token=self._validator_service_token,
hive_secret=self._hive_secret, hive_secret=self._hive_secret,
max_charge_per_request=self._max_charge_per_request, max_charge_per_request=self._max_charge_per_request,
starting_credit=self._starting_credit,
devnet_topup_amount=self._devnet_topup_amount,
toploc_calibration=self._toploc_calibration, toploc_calibration=self._toploc_calibration,
toploc_reference_node_url=self._toploc_reference_node_url, toploc_reference_node_url=self._toploc_reference_node_url,
toploc_calibration_gate_min_hardware_profiles=self._toploc_calibration_gate_min_hardware_profiles, toploc_calibration_gate_min_hardware_profiles=self._toploc_calibration_gate_min_hardware_profiles,

View File

@@ -124,9 +124,16 @@ def _call(url, method="GET", body=None, token=None):
@pytest.fixture @pytest.fixture
def account_tracker(): def account_tracker():
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02) """Tracker with credit features pinned OFF (defaults are devnet-friendly 1.0)."""
tracker = TrackerServer(billing=ledger, accounts=AccountStore(), hive_secret=HIVE_SECRET) ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
tracker = TrackerServer(
billing=ledger,
accounts=AccountStore(),
hive_secret=HIVE_SECRET,
starting_credit=0.0,
devnet_topup_amount=0.0,
)
port = tracker.start() port = tracker.start()
yield f"http://127.0.0.1:{port}", ledger yield f"http://127.0.0.1:{port}", ledger
tracker.stop() tracker.stop()
@@ -145,7 +152,7 @@ def test_register_login_and_account_view(account_tracker):
me = _call(f"{url}/v1/account", token=login["session_token"]) me = _call(f"{url}/v1/account", token=login["session_token"])
assert me["account"]["email"] == "admin@example.com" assert me["account"]["email"] == "admin@example.com"
assert me["api_keys"] == [reg["api_key"]] assert me["api_keys"] == [reg["api_key"]]
assert me["total_balance"] == pytest.approx(0.0) assert me["total_balance"] == pytest.approx(0.0)
assert me["usage"]["requests"] == 0 assert me["usage"]["requests"] == 0
@@ -228,3 +235,85 @@ def test_accounts_endpoints_404_when_disabled():
assert exc_info.value.code == 404 assert exc_info.value.code == 404
finally: finally:
tracker.stop() tracker.stop()
# ------------------------------------------- US-039/US-040: credit and top-up
@pytest.fixture
def funded_tracker():
"""Tracker with Caller Credit and the devnet top-up faucet enabled."""
ledger = BillingLedger(starting_credit=0.0, default_price_per_1k=0.02)
tracker = TrackerServer(
billing=ledger,
accounts=AccountStore(),
hive_secret=HIVE_SECRET,
starting_credit=1.0,
devnet_topup_amount=10.0,
)
port = tracker.start()
yield f"http://127.0.0.1:{port}", ledger
tracker.stop()
def test_caller_credit_granted_once_per_account(funded_tracker):
url, ledger = funded_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "c@example.com", "password": "secret-123"})
token = reg["session_token"]
first_key = reg["api_key"]
assert ledger.get_client_balance(first_key) == pytest.approx(1.0)
# A second key never re-grants — not even after revoking the first.
second = _call(f"{url}/v1/account/keys", "POST", {}, token=token)
assert second["caller_credit_granted"] is False
assert ledger.get_client_balance(second["api_key"]) == pytest.approx(0.0)
_call(f"{url}/v1/account/keys/revoke", "POST", {"api_key": first_key}, token=token)
third = _call(f"{url}/v1/account/keys", "POST", {}, token=token)
assert third["caller_credit_granted"] is False
assert ledger.get_client_balance(third["api_key"]) == pytest.approx(0.0)
def test_unknown_bearer_key_rejected_by_proxy(funded_tracker):
url, ledger = funded_tracker
with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/chat/completions", "POST",
{"model": "any", "messages": []}, token="sk-mesh-made-up-key")
assert exc_info.value.code == 401
assert "unknown API key" in exc_info.value.read().decode()
# The invented key must not have become a billable client.
assert ledger.get_client_balance("sk-mesh-made-up-key") == pytest.approx(0.0)
def test_devnet_topup_credits_own_key_only(funded_tracker):
url, ledger = funded_tracker
owner = _call(f"{url}/v1/auth/register", "POST",
{"email": "own@example.com", "password": "secret-123"})
other = _call(f"{url}/v1/auth/register", "POST",
{"email": "oth@example.com", "password": "secret-123"})
me = _call(f"{url}/v1/account", token=owner["session_token"])
assert me["topup_amount"] == pytest.approx(10.0)
result = _call(f"{url}/v1/account/topup", "POST",
{"api_key": owner["api_key"]}, token=owner["session_token"])
assert result["credited"] == pytest.approx(10.0)
assert result["balance"] == pytest.approx(11.0) # 1.0 caller credit + 10.0
with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/account/topup", "POST",
{"api_key": owner["api_key"]}, token=other["session_token"])
assert exc_info.value.code == 403
assert ledger.get_client_balance(owner["api_key"]) == pytest.approx(11.0)
def test_topup_404_when_disabled(account_tracker):
url, _ = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "t@example.com", "password": "secret-123"})
me = _call(f"{url}/v1/account", token=reg["session_token"])
assert me["topup_amount"] == pytest.approx(0.0)
with pytest.raises(urllib.error.HTTPError) as exc_info:
_call(f"{url}/v1/account/topup", "POST",
{"api_key": reg["api_key"]}, token=reg["session_token"])
assert exc_info.value.code == 404

View File

@@ -525,3 +525,293 @@ def test_mdns_start_and_stop_without_zeroconf(monkeypatch):
disc = MdnsDiscovery(peer_id="x", port=8001) disc = MdnsDiscovery(peer_id="x", port=8001)
disc.start() # should not raise disc.start() # should not raise
disc.stop() # should not raise disc.stop() # should not raise
# ---------------------------------------------------------------------------
# US-036: streamed responses over relay RPC / US-037: bridge concurrency
# ---------------------------------------------------------------------------
def _start_local_http_server(handler_cls):
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, server.server_address[1]
def _run_streaming_peer(port: int, peer_id: str, ready: threading.Event):
"""Peer that answers one rpc request with a 4-frame streamed response."""
import websockets.sync.client as wsc # type: ignore[import]
with wsc.connect(f"ws://127.0.0.1:{port}/ws") as ws:
ws.send(json.dumps({
"topic": "peer-register",
"version": 1,
"from_peer": peer_id,
"msg_id": f"{peer_id}-reg",
"payload": {"peer_id": peer_id, "addr": ""},
}))
ws.recv()
ready.set()
envelope = json.loads(ws.recv(timeout=5))
rid = envelope["payload"]["request_id"]
def frame(payload):
ws.send(json.dumps({
"topic": "relay-http-response",
"version": 1,
"from_peer": peer_id,
"payload": payload,
}))
frame({"request_id": rid, "status": 200,
"headers": {"Content-Type": "text/event-stream"},
"stream": True, "done": False})
frame({"request_id": rid, "stream": True,
"chunk": 'data: {"choices": [{"delta": {"content": "hello"}}]}\n\n',
"done": False})
frame({"request_id": rid, "stream": True,
"chunk": "data: [DONE]\n\n", "done": False})
frame({"request_id": rid, "stream": True, "done": True})
def test_relay_rpc_forwards_streamed_frames_in_order():
"""A streamed response traverses the relay as multiple frames ending with done."""
import websockets.sync.client as wsc # type: ignore[import]
from meshnet_relay.server import RelayServer
relay = RelayServer(host="127.0.0.1", port=0)
port = relay.start()
ready = threading.Event()
peer_thread = threading.Thread(
target=_run_streaming_peer, args=(port, "stream_peer", ready), daemon=True
)
peer_thread.start()
assert ready.wait(timeout=5)
frames = []
with wsc.connect(f"ws://127.0.0.1:{port}/rpc/stream_peer") as ws:
ws.send(json.dumps({
"request_id": "req-stream-1",
"method": "POST",
"path": "/v1/chat/completions",
"headers": {},
"body": "{}",
}))
while True:
frames.append(json.loads(ws.recv(timeout=5)))
if not frames[-1].get("stream") or frames[-1].get("done"):
break
relay.stop()
peer_thread.join(timeout=3)
assert len(frames) == 4
assert frames[0]["status"] == 200 and frames[0]["stream"] is True
assert frames[1]["chunk"].startswith("data:")
assert frames[2]["chunk"] == "data: [DONE]\n\n"
assert frames[-1]["done"] is True
def test_tracker_relay_http_request_collapses_streamed_frames():
"""Buffered wrapper joins chunk frames into one response body."""
from meshnet_relay.server import RelayServer
from meshnet_tracker.server import _relay_http_request
relay = RelayServer(host="127.0.0.1", port=0)
port = relay.start()
ready = threading.Event()
peer_thread = threading.Thread(
target=_run_streaming_peer, args=(port, "buffer_peer", ready), daemon=True
)
peer_thread.start()
assert ready.wait(timeout=5)
response = _relay_http_request(
f"ws://127.0.0.1:{port}/rpc/buffer_peer",
path="/v1/chat/completions",
body=b"{}",
headers={},
)
relay.stop()
peer_thread.join(timeout=3)
assert response is not None
assert response["status"] == 200
assert '"content": "hello"' in response["body"]
assert "data: [DONE]" in response["body"]
def test_stream_line_tokens_accounting():
from meshnet_tracker.server import _stream_line_tokens
assert _stream_line_tokens(b": comment") == (0, None)
assert _stream_line_tokens(b"data: [DONE]") == (0, None)
observed, reported = _stream_line_tokens(
b'data: {"choices": [{"delta": {"content": "two words"}}]}'
)
assert observed == 2 and reported is None
observed, reported = _stream_line_tokens(b'data: {"usage": {"total_tokens": 42}}')
assert reported == 42
assert _stream_line_tokens(b"data: not-json") == (1, None)
def test_relay_bridge_streams_sse_as_chunk_frames():
"""Bridge forwards a local SSE response as header + chunk frames + done."""
from http.server import BaseHTTPRequestHandler
from meshnet_node.relay_bridge import RelayHttpBridge
class SSEHandler(BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0) or 0))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for i in range(3):
self.wfile.write(f'data: {{"i": {i}}}\n\n'.encode())
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
def log_message(self, *args):
pass
server, port = _start_local_http_server(SSEHandler)
try:
bridge = RelayHttpBridge(
relay_url="ws://unused.example/ws",
peer_id="peer-sse",
local_base_url=f"http://127.0.0.1:{port}",
advertised_addr="",
)
frames = []
bridge._send_response_frame = lambda payload: (frames.append(payload), True)[1]
bridge._process_request({
"request_id": "req-sse-1",
"method": "POST",
"path": "/v1/chat/completions",
"headers": {"Content-Type": "application/json"},
"body": "{}",
})
finally:
server.shutdown()
assert frames[0]["stream"] is True and frames[0]["status"] == 200
chunk_frames = [f for f in frames if f.get("chunk")]
assert len(chunk_frames) >= 2
assert frames[-1] == {"request_id": "req-sse-1", "stream": True, "done": True}
assert "".join(f["chunk"] for f in chunk_frames).count("data:") == 4
def test_relay_bridge_non_stream_response_stays_single_frame():
from http.server import BaseHTTPRequestHandler
from meshnet_node.relay_bridge import RelayHttpBridge
class JSONHandler(BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0) or 0))
body = json.dumps({"ok": True}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server, port = _start_local_http_server(JSONHandler)
try:
bridge = RelayHttpBridge(
relay_url="ws://unused.example/ws",
peer_id="peer-json",
local_base_url=f"http://127.0.0.1:{port}",
advertised_addr="",
)
frames = []
bridge._send_response_frame = lambda payload: (frames.append(payload), True)[1]
bridge._process_request({
"request_id": "req-json-1",
"method": "POST",
"path": "/v1/chat/completions",
"headers": {"Content-Type": "application/json"},
"body": "{}",
})
finally:
server.shutdown()
assert len(frames) == 1
assert "stream" not in frames[0]
assert json.loads(frames[0]["body"]) == {"ok": True}
def test_relay_bridge_serves_concurrent_requests():
"""US-037: a slow relayed request must not block a fast one."""
import websockets.sync.client as wsc # type: ignore[import]
from http.server import BaseHTTPRequestHandler
from meshnet_node.relay_bridge import RelayHttpBridge
from meshnet_relay.server import RelayServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0) or 0))
if self.path == "/slow":
time.sleep(2.0)
body = json.dumps({"path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
http_server, http_port = _start_local_http_server(Handler)
relay = RelayServer(host="127.0.0.1", port=0)
relay_port = relay.start()
bridge = RelayHttpBridge(
relay_url=f"ws://127.0.0.1:{relay_port}/ws",
peer_id="conc_peer",
local_base_url=f"http://127.0.0.1:{http_port}",
advertised_addr="",
)
bridge.start()
results = {}
try:
assert bridge.wait_connected(timeout=5)
deadline = time.time() + 5
while time.time() < deadline and relay.registry.get("conc_peer") is None:
time.sleep(0.05)
assert relay.registry.get("conc_peer") is not None
def call(path, key):
with wsc.connect(f"ws://127.0.0.1:{relay_port}/rpc/conc_peer") as ws:
ws.send(json.dumps({
"request_id": f"req-{key}",
"method": "POST",
"path": path,
"headers": {"Content-Type": "application/json"},
"body": "{}",
}))
results[key] = (json.loads(ws.recv(timeout=10)), time.monotonic())
start = time.monotonic()
slow_thread = threading.Thread(target=call, args=("/slow", "slow"), daemon=True)
slow_thread.start()
time.sleep(0.3) # let the slow request reach the bridge first
call("/fast", "fast")
fast_elapsed = results["fast"][1] - start
slow_thread.join(timeout=10)
finally:
bridge.stop()
relay.stop()
http_server.shutdown()
assert results["fast"][0]["status"] == 200
assert results["slow"][0]["status"] == 200
# The fast request finished while the slow one was still in flight.
assert fast_elapsed < 1.5
# Responses matched by request_id despite out-of-order completion.
assert json.loads(results["fast"][0]["body"]) == {"path": "/fast"}
assert json.loads(results["slow"][0]["body"]) == {"path": "/slow"}

View File

@@ -2,6 +2,7 @@
import json import json
import io import io
import os
import sys import sys
import types import types
import urllib.request import urllib.request
@@ -1387,3 +1388,64 @@ def test_startup_cpu_fallback(tmp_path, monkeypatch):
node.stop() node.stop()
finally: finally:
tracker.stop() tracker.stop()
# --------------------------------------------------- layer detection (US: composite configs)
def test_layers_from_config_top_level():
from meshnet_node.model_catalog import layers_from_config
cfg = types.SimpleNamespace(num_hidden_layers=24)
assert layers_from_config(cfg) == 24
def test_layers_from_config_nested_text_config():
"""VLM/MoE composites (e.g. Qwen3.5-MoE) keep the layer count in text_config."""
from meshnet_node.model_catalog import layers_from_config
cfg = types.SimpleNamespace(text_config=types.SimpleNamespace(num_hidden_layers=40))
assert layers_from_config(cfg) == 40
def test_layers_from_config_get_text_config_and_variants():
from meshnet_node.model_catalog import layers_from_config
inner = types.SimpleNamespace(n_layer=32)
cfg = types.SimpleNamespace(get_text_config=lambda: inner)
assert layers_from_config(cfg) == 32
assert layers_from_config(types.SimpleNamespace()) is None
def test_download_dir_env_override(tmp_path, monkeypatch):
import importlib
from meshnet_node import config as config_mod
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("MESHNET_DOWNLOAD_DIR", "/tmp/llm-store")
importlib.reload(config_mod)
assert config_mod.DEFAULTS["download_dir"] == "/tmp/llm-store"
monkeypatch.delenv("MESHNET_DOWNLOAD_DIR")
importlib.reload(config_mod)
assert config_mod.DEFAULTS["download_dir"].endswith("models")
def test_cli_loads_local_env_before_config_defaults(tmp_path, monkeypatch):
import importlib
from meshnet_node import cli as cli_mod
from meshnet_node import config as config_mod
monkeypatch.delenv("MESHNET_DOWNLOAD_DIR", raising=False)
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text(
"MESHNET_DOWNLOAD_DIR=/run/media/popov/DATA/llm/safetensor/models\n"
"HF_TOKEN=hf_test_token\n"
)
cli_mod._load_env_defaults()
importlib.reload(config_mod)
assert config_mod.DEFAULTS["download_dir"] == "/run/media/popov/DATA/llm/safetensor/models"
assert os.environ["HF_TOKEN"] == "hf_test_token"