new tasks, devnet topup, cli, new model support
This commit is contained in:
88
docs/issues/36-relay-streamed-chat.md
Normal file
88
docs/issues/36-relay-streamed-chat.md
Normal 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
|
||||
Reference in New Issue
Block a user