Merge branch 'master' of https://git.d-popov.com/popov/neuron-tai
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
# US-019 — Binary data plane and optional peer weight transfer
|
||||||
|
|
||||||
|
Status: needs-triage
|
||||||
|
Priority: Low
|
||||||
|
Stage: Design parking lot
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current project focus is inference democratization: let small GPU owners contribute useful compute and let users run inference on models larger than one host can serve alone. Weight distribution is useful, but it is secondary to low-latency distributed inference.
|
||||||
|
|
||||||
|
Recent findings:
|
||||||
|
|
||||||
|
- HuggingFace already handles initial model origin distribution well enough for the first working version.
|
||||||
|
- The inference-critical path is activation transfer between shard nodes, not torrenting model files.
|
||||||
|
- Torrent/content-addressed transfer is a good future fit for model weights, shard cache replication, fine-tuned models, and offline/local swarm behavior.
|
||||||
|
- Torrenting is not a good fit for activation traffic because activations are latency-sensitive, ordered, session-specific binary streams.
|
||||||
|
|
||||||
|
## Design note
|
||||||
|
|
||||||
|
Keep the tracker as the control plane:
|
||||||
|
|
||||||
|
- node registration
|
||||||
|
- heartbeats
|
||||||
|
- route selection
|
||||||
|
- model/shard manifests
|
||||||
|
- peer/checksum metadata
|
||||||
|
|
||||||
|
Keep binary payloads on the data plane:
|
||||||
|
|
||||||
|
- direct node-to-node activation transfer where reachable
|
||||||
|
- relay/QUIC/WSS fallback where direct transport is unavailable
|
||||||
|
- future peer weight transfer as content-addressed blobs or pieces
|
||||||
|
|
||||||
|
## Potential future direction
|
||||||
|
|
||||||
|
For inference traffic:
|
||||||
|
|
||||||
|
- Prefer direct binary transport with backpressure.
|
||||||
|
- Use raw binary activation frames rather than JSON/base64.
|
||||||
|
- Preserve tensor metadata out-of-band: shape, dtype, session, chunk index, encoding.
|
||||||
|
- Consider QUIC or a mature NAT-friendly transport for direct-when-possible, relay-when-needed behavior.
|
||||||
|
|
||||||
|
For model weights:
|
||||||
|
|
||||||
|
- Keep HuggingFace as default origin and fallback.
|
||||||
|
- Add peer cache transfer only as an optimization.
|
||||||
|
- Consider a real content-addressed/torrent-like library or sidecar for weight blobs.
|
||||||
|
- Store manifests and checksums in tracker state so peers can verify exact shard contents.
|
||||||
|
- Prefer piece/chunk transfer with resume and hash verification over one giant tarball.
|
||||||
|
|
||||||
|
## Not in scope now
|
||||||
|
|
||||||
|
- Replacing HuggingFace as the primary model origin.
|
||||||
|
- Building a full BitTorrent/IPFS/libp2p subsystem.
|
||||||
|
- Routing activation traffic through a torrent protocol.
|
||||||
|
- Making peer weight transfer mandatory for node startup.
|
||||||
|
|
||||||
|
## Acceptance criteria for a future implementation issue
|
||||||
|
|
||||||
|
- [ ] Activation transfer remains binary end-to-end and avoids JSON/base64 payloads.
|
||||||
|
- [ ] Tracker does not proxy large binary payloads except as an explicit fallback path.
|
||||||
|
- [ ] Weight transfer, if added, is optional and falls back to HuggingFace.
|
||||||
|
- [ ] Weight pieces are content-addressed and checksum-verified.
|
||||||
|
- [ ] The design preserves low-latency inference as the primary objective.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Created as a low-priority design parking-lot item after discussing inference democratization versus weight distribution. Do not pick up for implementation until the core public tracker, relay, and binary activation path are stable.
|
||||||
@@ -11,8 +11,14 @@ Tested on: AMD Ryzen AI Max (Strix Halo APU), 124 GB RAM, Linux, CPU inference.
|
|||||||
# Clone and enter repo
|
# Clone and enter repo
|
||||||
cd /run/media/popov/d/DEV/repos/d-popov.com/AI
|
cd /run/media/popov/d/DEV/repos/d-popov.com/AI
|
||||||
|
|
||||||
|
# Create the virtualenv if it does not exist yet
|
||||||
|
python3 -m venv .venv
|
||||||
|
|
||||||
|
# Keep packaging tools current enough for editable installs
|
||||||
|
.venv/bin/python -m pip install --upgrade pip setuptools wheel
|
||||||
|
|
||||||
# Install Python packages (editable — picks up code changes immediately)
|
# Install Python packages (editable — picks up code changes immediately)
|
||||||
.venv/bin/pip install -e packages/tracker packages/node packages/p2p packages/relay
|
.venv/bin/pip install -e packages/tracker -e packages/node -e packages/p2p -e packages/gateway -e packages/relay
|
||||||
|
|
||||||
# CPU-only PyTorch (skip if you have CUDA/ROCm already)
|
# CPU-only PyTorch (skip if you have CUDA/ROCm already)
|
||||||
.venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
.venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
@@ -24,6 +30,52 @@ cd /run/media/popov/d/DEV/repos/d-popov.com/AI
|
|||||||
> **NVIDIA GPU (CUDA):** replace the torch line with `pip install torch` (default index).
|
> **NVIDIA GPU (CUDA):** replace the torch line with `pip install torch` (default index).
|
||||||
> **AMD GPU (ROCm):** `pip install torch --index-url https://download.pytorch.org/whl/rocm6.2`
|
> **AMD GPU (ROCm):** `pip install torch --index-url https://download.pytorch.org/whl/rocm6.2`
|
||||||
|
|
||||||
|
### Windows / WSL2
|
||||||
|
|
||||||
|
Run the Linux commands from WSL, not Git Bash. From the repo opened in Git Bash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wsl
|
||||||
|
cd /mnt/d/DEV/workspace/REPOS/git.d-popov.com/neuron-tai
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install --upgrade pip setuptools wheel
|
||||||
|
.venv/bin/pip install -e packages/tracker -e packages/node -e packages/p2p -e packages/gateway -e packages/relay
|
||||||
|
.venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
.venv/bin/pip install transformers accelerate
|
||||||
|
.venv/bin/meshnet-node --help
|
||||||
|
```
|
||||||
|
|
||||||
|
If `.venv/bin/meshnet-node` is missing, the editable install step did not finish
|
||||||
|
successfully. Re-run the `.venv/bin/pip install -e ...` command above inside WSL.
|
||||||
|
|
||||||
|
### Public tracker + WSS relay
|
||||||
|
|
||||||
|
For internet nodes, expose one public HTTPS host and proxy these paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/v1/* -> meshnet-tracker, for registration, heartbeats, routing, and OpenAI requests
|
||||||
|
/ws -> meshnet-relay, for outbound node gossip/bridge connections
|
||||||
|
/rpc/* -> meshnet-relay, for tracker-to-node relay requests
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the tracker with the public relay URL it should advertise:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/meshnet-relay --host 0.0.0.0 --port 8765
|
||||||
|
.venv/bin/meshnet-tracker start \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8081 \
|
||||||
|
--relay-url wss://ai.neuron.d-popov.com/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Then a node only needs the public tracker address:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/meshnet-node start \
|
||||||
|
--tracker https://ai.neuron.d-popov.com \
|
||||||
|
--model-id Qwen/Qwen2.5-0.5B-Instruct
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1 — Start the tracker (Terminal 1)
|
## Step 1 — Start the tracker (Terminal 1)
|
||||||
|
|||||||
4
_DEV_NOTES.md
Normal file
4
_DEV_NOTES.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
win
|
||||||
|
.venv/bin/meshnet-node start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 20
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -26,6 +27,7 @@ def _run_node(cfg: dict) -> None:
|
|||||||
wallet_path=Path(cfg["wallet_path"]) if cfg.get("wallet_path") else None,
|
wallet_path=Path(cfg["wallet_path"]) if cfg.get("wallet_path") else None,
|
||||||
cache_dir=Path(cfg["download_dir"]) if cfg.get("download_dir") else None,
|
cache_dir=Path(cfg["download_dir"]) if cfg.get("download_dir") else None,
|
||||||
host=cfg.get("host", "0.0.0.0"),
|
host=cfg.get("host", "0.0.0.0"),
|
||||||
|
advertise_host=cfg.get("advertise_host"),
|
||||||
route_timeout=float(cfg.get("route_timeout", 30.0)),
|
route_timeout=float(cfg.get("route_timeout", 30.0)),
|
||||||
vram_mb_override=cfg.get("vram_mb_override"),
|
vram_mb_override=cfg.get("vram_mb_override"),
|
||||||
)
|
)
|
||||||
@@ -50,6 +52,19 @@ def _run_node(cfg: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_available_port(host: str, start: int = 7000, attempts: int = 100) -> int:
|
||||||
|
"""Return the first TCP port bindable on host, starting at start."""
|
||||||
|
bind_host = "" if host == "0.0.0.0" else host
|
||||||
|
for port in range(start, start + attempts):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
try:
|
||||||
|
sock.bind((bind_host, port))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return port
|
||||||
|
raise OSError(f"no available TCP port in range {start}-{start + attempts - 1}")
|
||||||
|
|
||||||
|
|
||||||
def _cmd_default(args) -> int:
|
def _cmd_default(args) -> int:
|
||||||
"""No subcommand: wizard if no config, else start with saved config."""
|
"""No subcommand: wizard if no config, else start with saved config."""
|
||||||
from .config import load_config, save_config, merge_cli_overrides
|
from .config import load_config, save_config, merge_cli_overrides
|
||||||
@@ -88,6 +103,8 @@ def _cmd_default(args) -> int:
|
|||||||
overrides["port"] = args.port
|
overrides["port"] = args.port
|
||||||
if args.host:
|
if args.host:
|
||||||
overrides["host"] = args.host
|
overrides["host"] = args.host
|
||||||
|
if args.advertise_host:
|
||||||
|
overrides["advertise_host"] = args.advertise_host
|
||||||
if args.route_timeout != 30.0:
|
if args.route_timeout != 30.0:
|
||||||
overrides["route_timeout"] = args.route_timeout
|
overrides["route_timeout"] = args.route_timeout
|
||||||
if getattr(args, "memory", None) is not None:
|
if getattr(args, "memory", None) is not None:
|
||||||
@@ -148,8 +165,12 @@ def _cmd_start(args) -> int:
|
|||||||
# Build a transient config from flags (don't write to disk)
|
# Build a transient config from flags (don't write to disk)
|
||||||
cfg = dict(DEFAULTS)
|
cfg = dict(DEFAULTS)
|
||||||
cfg["tracker_url"] = args.tracker
|
cfg["tracker_url"] = args.tracker
|
||||||
cfg["port"] = args.port
|
cfg["port"] = args.port if args.port is not None else _first_available_port(args.host)
|
||||||
cfg["model_name"] = args.model
|
if args.model_id is None and "/" in args.model:
|
||||||
|
cfg["model_hf_repo"] = args.model
|
||||||
|
cfg["model_name"] = args.model.split("/")[-1]
|
||||||
|
else:
|
||||||
|
cfg["model_name"] = args.model
|
||||||
cfg["quantization"] = args.quantization
|
cfg["quantization"] = args.quantization
|
||||||
cfg["host"] = args.host
|
cfg["host"] = args.host
|
||||||
if args.model_id:
|
if args.model_id:
|
||||||
@@ -220,6 +241,7 @@ def main() -> None:
|
|||||||
parser.add_argument("--shard-end", type=int, metavar="N", help="Pin shard end layer")
|
parser.add_argument("--shard-end", type=int, metavar="N", help="Pin shard end layer")
|
||||||
parser.add_argument("--port", type=int, metavar="N", help="Port to listen on")
|
parser.add_argument("--port", type=int, metavar="N", help="Port to listen on")
|
||||||
parser.add_argument("--host", metavar="ADDR", help="Interface to bind (default 0.0.0.0)")
|
parser.add_argument("--host", metavar="ADDR", help="Interface to bind (default 0.0.0.0)")
|
||||||
|
parser.add_argument("--advertise-host", metavar="ADDR", help="Host/IP advertised to the tracker")
|
||||||
parser.add_argument("--route-timeout", type=float, metavar="SEC", default=30.0,
|
parser.add_argument("--route-timeout", type=float, metavar="SEC", default=30.0,
|
||||||
help="Seconds to wait for tracker route lookup (default 30)")
|
help="Seconds to wait for tracker route lookup (default 30)")
|
||||||
parser.add_argument("--memory", type=int, metavar="MB", default=None,
|
parser.add_argument("--memory", type=int, metavar="MB", default=None,
|
||||||
@@ -240,7 +262,7 @@ def main() -> None:
|
|||||||
# start subcommand (legacy / backward-compat)
|
# start subcommand (legacy / backward-compat)
|
||||||
start_cmd = subparsers.add_parser("start", help="Start node (legacy flags)")
|
start_cmd = subparsers.add_parser("start", help="Start node (legacy flags)")
|
||||||
start_cmd.add_argument("--tracker", default="http://localhost:8080")
|
start_cmd.add_argument("--tracker", default="http://localhost:8080")
|
||||||
start_cmd.add_argument("--port", type=int, default=7000)
|
start_cmd.add_argument("--port", type=int)
|
||||||
start_cmd.add_argument("--model", default="stub-model")
|
start_cmd.add_argument("--model", default="stub-model")
|
||||||
start_cmd.add_argument("--model-id", help="HuggingFace repo ID")
|
start_cmd.add_argument("--model-id", help="HuggingFace repo ID")
|
||||||
start_cmd.add_argument("--shard-start", type=int)
|
start_cmd.add_argument("--shard-start", type=int)
|
||||||
|
|||||||
148
packages/node/meshnet_node/relay_bridge.py
Normal file
148
packages/node/meshnet_node/relay_bridge.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Outbound relay bridge for NAT-safe node HTTP requests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RelayBridgeInfo:
|
||||||
|
peer_id: str
|
||||||
|
relay_addr: str
|
||||||
|
|
||||||
|
|
||||||
|
def _make_envelope(topic: str, payload: dict, peer_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"topic": topic,
|
||||||
|
"version": 1,
|
||||||
|
"from_peer": peer_id,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"msg_id": f"{peer_id}-{time.time_ns():x}",
|
||||||
|
"ttl": 1,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RelayHttpBridge:
|
||||||
|
"""Connect outbound to a relay and proxy relay HTTP requests to localhost."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
relay_url: str,
|
||||||
|
peer_id: str,
|
||||||
|
local_base_url: str,
|
||||||
|
advertised_addr: str,
|
||||||
|
reconnect_interval: float = 3.0,
|
||||||
|
) -> None:
|
||||||
|
self.relay_url = relay_url.rstrip("/")
|
||||||
|
self.peer_id = peer_id
|
||||||
|
self.local_base_url = local_base_url.rstrip("/")
|
||||||
|
self.advertised_addr = advertised_addr
|
||||||
|
self.reconnect_interval = reconnect_interval
|
||||||
|
self._running = False
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._connected = threading.Event()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def relay_addr(self) -> str:
|
||||||
|
base = self.relay_url
|
||||||
|
if base.endswith("/ws"):
|
||||||
|
base = base[:-3]
|
||||||
|
return f"{base}/rpc/{self.peer_id}"
|
||||||
|
|
||||||
|
def start(self) -> RelayBridgeInfo:
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
|
||||||
|
self._thread.start()
|
||||||
|
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
|
||||||
|
|
||||||
|
def wait_connected(self, timeout: float = 5.0) -> bool:
|
||||||
|
return self._connected.wait(timeout)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=3.0)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
import websockets.sync.client as wsc # type: ignore[import]
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
with wsc.connect(self.relay_url, open_timeout=5) as ws:
|
||||||
|
self._connected.set()
|
||||||
|
ws.send(json.dumps(_make_envelope(
|
||||||
|
"peer-register",
|
||||||
|
{"peer_id": self.peer_id, "addr": self.advertised_addr},
|
||||||
|
self.peer_id,
|
||||||
|
)))
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
raw = ws.recv(timeout=1)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
envelope = json.loads(raw)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
if envelope.get("topic") != "relay-http-request":
|
||||||
|
continue
|
||||||
|
payload = envelope.get("payload", {})
|
||||||
|
if payload.get("target_peer") not in {None, self.peer_id}:
|
||||||
|
continue
|
||||||
|
response = self._handle_request(payload)
|
||||||
|
ws.send(json.dumps(_make_envelope(
|
||||||
|
"relay-http-response",
|
||||||
|
response,
|
||||||
|
self.peer_id,
|
||||||
|
)))
|
||||||
|
except Exception as exc:
|
||||||
|
self._connected.clear()
|
||||||
|
if self._running:
|
||||||
|
log.debug("relay bridge disconnected: %s", exc)
|
||||||
|
time.sleep(self.reconnect_interval)
|
||||||
|
|
||||||
|
def _handle_request(self, payload: dict) -> dict:
|
||||||
|
request_id = str(payload.get("request_id") or "")
|
||||||
|
method = str(payload.get("method") or "POST").upper()
|
||||||
|
path = str(payload.get("path") or "/")
|
||||||
|
body_text = payload.get("body") or ""
|
||||||
|
headers = payload.get("headers") if isinstance(payload.get("headers"), dict) else {}
|
||||||
|
|
||||||
|
url = f"{self.local_base_url}{path}"
|
||||||
|
data = body_text.encode() if isinstance(body_text, str) else bytes(body_text)
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
||||||
|
return {
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": resp.status,
|
||||||
|
"headers": {"Content-Type": resp.headers.get("Content-Type", "application/json")},
|
||||||
|
"body": resp.read().decode(errors="replace"),
|
||||||
|
}
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return {
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": exc.code,
|
||||||
|
"headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")},
|
||||||
|
"body": exc.read().decode(errors="replace"),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": 503,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def peer_id_from_wallet(wallet_address: str) -> str:
|
||||||
|
return wallet_address[:16] if len(wallet_address) >= 16 else wallet_address
|
||||||
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
|
|
||||||
from .downloader import compute_shard_checksum, download_shard
|
from .downloader import compute_shard_checksum, download_shard
|
||||||
from .hardware import detect_hardware
|
from .hardware import detect_hardware
|
||||||
|
from .relay_bridge import RelayHttpBridge, peer_id_from_wallet
|
||||||
from .server import StubNodeServer
|
from .server import StubNodeServer
|
||||||
from .torch_server import TorchNodeServer
|
from .torch_server import TorchNodeServer
|
||||||
from .wallet import load_or_create_wallet
|
from .wallet import load_or_create_wallet
|
||||||
@@ -34,6 +35,57 @@ def _get_json(url: str, timeout: float = 10.0) -> dict:
|
|||||||
return json.loads(r.read())
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_relay_url(tracker_url: str) -> str | None:
|
||||||
|
try:
|
||||||
|
network_map = _get_json(f"{tracker_url}/v1/network/map", timeout=5.0)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
relay_url = network_map.get("relay_url")
|
||||||
|
return relay_url if isinstance(relay_url, str) and relay_url else None
|
||||||
|
|
||||||
|
|
||||||
|
def _start_relay_bridge_if_available(
|
||||||
|
tracker_url: str,
|
||||||
|
wallet_address: str,
|
||||||
|
local_base_url: str,
|
||||||
|
advertised_endpoint: str,
|
||||||
|
) -> tuple[RelayHttpBridge | None, dict]:
|
||||||
|
relay_url = _discover_relay_url(tracker_url)
|
||||||
|
if not relay_url:
|
||||||
|
return None, {}
|
||||||
|
peer_id = peer_id_from_wallet(wallet_address)
|
||||||
|
bridge = RelayHttpBridge(
|
||||||
|
relay_url=relay_url,
|
||||||
|
peer_id=peer_id,
|
||||||
|
local_base_url=local_base_url,
|
||||||
|
advertised_addr=advertised_endpoint,
|
||||||
|
)
|
||||||
|
info = bridge.start()
|
||||||
|
if bridge.wait_connected(timeout=5.0):
|
||||||
|
print(f" Relay connected — {info.relay_addr}", flush=True)
|
||||||
|
else:
|
||||||
|
print(f" Relay configured but not connected yet — {info.relay_addr}", flush=True)
|
||||||
|
return bridge, {
|
||||||
|
"relay_addr": info.relay_addr,
|
||||||
|
"peer_id": info.peer_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_relay_bridge(node: StubNodeServer | TorchNodeServer, bridge: RelayHttpBridge | None) -> None:
|
||||||
|
setattr(node, "relay_bridge", bridge)
|
||||||
|
if bridge is None:
|
||||||
|
return
|
||||||
|
original_stop = node.stop
|
||||||
|
|
||||||
|
def _stop_with_bridge() -> None:
|
||||||
|
try:
|
||||||
|
bridge.stop()
|
||||||
|
finally:
|
||||||
|
original_stop()
|
||||||
|
|
||||||
|
node.stop = _stop_with_bridge # type: ignore[method-assign]
|
||||||
|
|
||||||
|
|
||||||
def _start_heartbeat(
|
def _start_heartbeat(
|
||||||
tracker_url: str,
|
tracker_url: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
@@ -248,6 +300,14 @@ def run_startup(
|
|||||||
shard_label = f"layers {shard_start}–{shard_end}"
|
shard_label = f"layers {shard_start}–{shard_end}"
|
||||||
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
|
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
|
||||||
endpoint = f"http://{public_host}:{actual_port}"
|
endpoint = f"http://{public_host}:{actual_port}"
|
||||||
|
local_base_url = f"http://127.0.0.1:{actual_port}"
|
||||||
|
relay_bridge, relay_fields = _start_relay_bridge_if_available(
|
||||||
|
tracker_url,
|
||||||
|
address,
|
||||||
|
local_base_url,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
_attach_relay_bridge(node, relay_bridge)
|
||||||
# Register with tracker so other nodes can auto-join this model.
|
# Register with tracker so other nodes can auto-join this model.
|
||||||
total_layers = getattr(getattr(node, "backend", None), "total_layers", None)
|
total_layers = getattr(getattr(node, "backend", None), "total_layers", None)
|
||||||
reg_payload = {
|
reg_payload = {
|
||||||
@@ -262,6 +322,7 @@ def run_startup(
|
|||||||
"quantization": quantization,
|
"quantization": quantization,
|
||||||
"score": 1.0,
|
"score": 1.0,
|
||||||
"tracker_mode": (shard_start == 0),
|
"tracker_mode": (shard_start == 0),
|
||||||
|
**relay_fields,
|
||||||
}
|
}
|
||||||
tracker_node_id: str | None = None
|
tracker_node_id: str | None = None
|
||||||
try:
|
try:
|
||||||
@@ -327,6 +388,14 @@ def run_startup(
|
|||||||
actual_port = node.start()
|
actual_port = node.start()
|
||||||
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
|
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
|
||||||
endpoint = f"http://{public_host}:{actual_port}"
|
endpoint = f"http://{public_host}:{actual_port}"
|
||||||
|
local_base_url = f"http://127.0.0.1:{actual_port}"
|
||||||
|
relay_bridge, relay_fields = _start_relay_bridge_if_available(
|
||||||
|
tracker_url,
|
||||||
|
address,
|
||||||
|
local_base_url,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
_attach_relay_bridge(node, relay_bridge)
|
||||||
auto_reg_payload = {
|
auto_reg_payload = {
|
||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"model": assigned_hf_repo.split("/")[-1],
|
"model": assigned_hf_repo.split("/")[-1],
|
||||||
@@ -339,6 +408,7 @@ def run_startup(
|
|||||||
"quantization": quantization,
|
"quantization": quantization,
|
||||||
"score": 1.0,
|
"score": 1.0,
|
||||||
"tracker_mode": (assigned_shard_start == 0),
|
"tracker_mode": (assigned_shard_start == 0),
|
||||||
|
**relay_fields,
|
||||||
}
|
}
|
||||||
tracker_node_id = None
|
tracker_node_id = None
|
||||||
try:
|
try:
|
||||||
@@ -414,6 +484,14 @@ def run_startup(
|
|||||||
actual_port = node.start()
|
actual_port = node.start()
|
||||||
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
|
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
|
||||||
endpoint = f"http://{public_host}:{actual_port}"
|
endpoint = f"http://{public_host}:{actual_port}"
|
||||||
|
local_base_url = f"http://127.0.0.1:{actual_port}"
|
||||||
|
relay_bridge, relay_fields = _start_relay_bridge_if_available(
|
||||||
|
tracker_url,
|
||||||
|
address,
|
||||||
|
local_base_url,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
_attach_relay_bridge(node, relay_bridge)
|
||||||
|
|
||||||
# 6. Register with tracker
|
# 6. Register with tracker
|
||||||
print("Registering with tracker...", flush=True)
|
print("Registering with tracker...", flush=True)
|
||||||
@@ -429,6 +507,7 @@ def run_startup(
|
|||||||
"hardware_profile": hw,
|
"hardware_profile": hw,
|
||||||
"wallet_address": address,
|
"wallet_address": address,
|
||||||
"score": 1.0,
|
"score": 1.0,
|
||||||
|
**relay_fields,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
node_id = str(reg_resp["node_id"])
|
node_id = str(reg_resp["node_id"])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"safetensors>=0.4",
|
"safetensors>=0.4",
|
||||||
"torch>=2.1",
|
"torch>=2.1",
|
||||||
"transformers>=4.39",
|
"transformers>=4.39",
|
||||||
|
"websockets>=13",
|
||||||
"zstandard>=0.22",
|
"zstandard>=0.22",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .peer_registry import PeerRegistry
|
from .peer_registry import PeerRegistry
|
||||||
@@ -56,6 +57,7 @@ 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] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def registry(self) -> PeerRegistry:
|
def registry(self) -> PeerRegistry:
|
||||||
@@ -121,6 +123,9 @@ class RelayServer:
|
|||||||
if path.startswith("/relay/"):
|
if path.startswith("/relay/"):
|
||||||
peer_id = path[len("/relay/"):]
|
peer_id = path[len("/relay/"):]
|
||||||
await self._handle_circuit_relay(ws, peer_id)
|
await self._handle_circuit_relay(ws, peer_id)
|
||||||
|
elif path.startswith("/rpc/"):
|
||||||
|
peer_id = path[len("/rpc/"):]
|
||||||
|
await self._handle_rpc(ws, peer_id)
|
||||||
elif path == "/health":
|
elif path == "/health":
|
||||||
await ws.send(json.dumps({"status": "ok", "peers": len(self._registry)}))
|
await ws.send(json.dumps({"status": "ok", "peers": len(self._registry)}))
|
||||||
await ws.close()
|
await ws.close()
|
||||||
@@ -164,6 +169,14 @@ class RelayServer:
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if topic == "relay-http-response":
|
||||||
|
payload = envelope.get("payload", {})
|
||||||
|
request_id = payload.get("request_id")
|
||||||
|
fut = self._pending_rpc.pop(request_id, None)
|
||||||
|
if fut is not None and not fut.done():
|
||||||
|
fut.set_result(payload)
|
||||||
|
continue
|
||||||
|
|
||||||
# Fan out to all other registered peers
|
# Fan out to all other registered peers
|
||||||
if peer_id:
|
if peer_id:
|
||||||
self._registry.touch(peer_id)
|
self._registry.touch(peer_id)
|
||||||
@@ -205,6 +218,50 @@ class RelayServer:
|
|||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _handle_rpc(self, ws_requester, target_peer_id: str) -> None:
|
||||||
|
"""Send one HTTP-shaped request to a connected peer and relay its response."""
|
||||||
|
target = self._registry.get(target_peer_id)
|
||||||
|
if target is None:
|
||||||
|
await ws_requester.send(json.dumps({
|
||||||
|
"status": 503,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps({"error": f"peer {target_peer_id!r} not connected to relay"}),
|
||||||
|
}))
|
||||||
|
await ws_requester.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws_requester.recv(), timeout=30.0)
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
await ws_requester.close(1003, "invalid relay rpc request")
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = str(payload.get("request_id") or uuid.uuid4())
|
||||||
|
payload["request_id"] = request_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()
|
||||||
|
self._pending_rpc[request_id] = fut
|
||||||
|
try:
|
||||||
|
await target.ws.send(json.dumps({
|
||||||
|
"topic": "relay-http-request",
|
||||||
|
"version": 1,
|
||||||
|
"from_peer": "relay",
|
||||||
|
"payload": payload,
|
||||||
|
}))
|
||||||
|
response = await asyncio.wait_for(fut, timeout=310.0)
|
||||||
|
await ws_requester.send(json.dumps(response))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await ws_requester.send(json.dumps({
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": 504,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps({"error": "relay rpc timed out"}),
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
self._pending_rpc.pop(request_id, None)
|
||||||
|
await ws_requester.close()
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast(raw: str | bytes, peers: list) -> None:
|
async def _broadcast(raw: str | bytes, peers: list) -> None:
|
||||||
"""Send raw message to all peers; ignore individual send failures."""
|
"""Send raw message to all peers; ignore individual send failures."""
|
||||||
|
|||||||
@@ -8,41 +8,49 @@ from .server import TrackerServer
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
common = argparse.ArgumentParser(add_help=False)
|
||||||
prog="meshnet-tracker",
|
common.add_argument("--host", default="0.0.0.0", help="Host interface to listen on")
|
||||||
description="Distributed Inference Network node registry and route selection",
|
common.add_argument("--port", type=int, default=8080, help="Port to listen on")
|
||||||
)
|
common.add_argument(
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
|
||||||
|
|
||||||
start_cmd = subparsers.add_parser("start", help="Start the tracker server")
|
|
||||||
start_cmd.add_argument("--host", default="127.0.0.1", help="Host interface to listen on")
|
|
||||||
start_cmd.add_argument("--port", type=int, default=8080, help="Port to listen on")
|
|
||||||
start_cmd.add_argument(
|
|
||||||
"--heartbeat-timeout",
|
"--heartbeat-timeout",
|
||||||
type=float,
|
type=float,
|
||||||
default=30.0,
|
default=30.0,
|
||||||
help="Seconds before a node is removed from the registry after missed heartbeat",
|
help="Seconds before a node is removed from the registry after missed heartbeat",
|
||||||
)
|
)
|
||||||
start_cmd.add_argument(
|
common.add_argument(
|
||||||
"--cluster-peers",
|
"--cluster-peers",
|
||||||
default="",
|
default="",
|
||||||
help="Comma-separated URLs of peer tracker nodes (enables Raft cluster mode)",
|
help="Comma-separated URLs of peer tracker nodes (enables Raft cluster mode)",
|
||||||
)
|
)
|
||||||
start_cmd.add_argument(
|
common.add_argument(
|
||||||
"--self-url",
|
"--self-url",
|
||||||
default=None,
|
default=None,
|
||||||
help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)",
|
help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)",
|
||||||
)
|
)
|
||||||
start_cmd.add_argument(
|
common.add_argument(
|
||||||
"--stats-db",
|
"--stats-db",
|
||||||
default=None,
|
default=None,
|
||||||
metavar="PATH",
|
metavar="PATH",
|
||||||
help="SQLite database path for persistent model usage statistics",
|
help="SQLite database path for persistent model usage statistics",
|
||||||
)
|
)
|
||||||
|
common.add_argument(
|
||||||
|
"--relay-url",
|
||||||
|
default=None,
|
||||||
|
help="Public ws(s):// relay URL advertised to nodes, for example wss://ai.neuron.d-popov.com/ws",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="meshnet-tracker",
|
||||||
|
description="Distributed Inference Network node registry and route selection",
|
||||||
|
parents=[common],
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
subparsers.add_parser("start", help="Start the tracker server", parents=[common])
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "start":
|
if args.command in {None, "start"}:
|
||||||
cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()]
|
cluster_peers = [u.strip() for u in args.cluster_peers.split(",") if u.strip()]
|
||||||
server = TrackerServer(
|
server = TrackerServer(
|
||||||
host=args.host,
|
host=args.host,
|
||||||
@@ -51,6 +59,7 @@ def main() -> None:
|
|||||||
cluster_peers=cluster_peers or None,
|
cluster_peers=cluster_peers or None,
|
||||||
cluster_self_url=args.self_url,
|
cluster_self_url=args.self_url,
|
||||||
stats_db=getattr(args, "stats_db", None),
|
stats_db=getattr(args, "stats_db", None),
|
||||||
|
relay_url=args.relay_url,
|
||||||
)
|
)
|
||||||
port = server.start()
|
port = server.start()
|
||||||
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
|
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import sqlite3
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .gossip import NodeGossip
|
from .gossip import NodeGossip
|
||||||
@@ -507,6 +509,35 @@ def _coverage_gaps(coverage: list[dict]) -> list[tuple[int, int]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 RPC WebSocket."""
|
||||||
|
try:
|
||||||
|
import websockets.sync.client as wsc # type: ignore[import]
|
||||||
|
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
with wsc.connect(relay_addr, open_timeout=10, close_timeout=5) as ws:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": "POST",
|
||||||
|
"path": path,
|
||||||
|
"headers": headers,
|
||||||
|
"body": body.decode(errors="replace"),
|
||||||
|
}))
|
||||||
|
raw = ws.recv(timeout=timeout)
|
||||||
|
response = json.loads(raw)
|
||||||
|
if response.get("request_id") not in {None, request_id}:
|
||||||
|
return None
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _nodes_and_bounds_for_model(
|
def _nodes_and_bounds_for_model(
|
||||||
server: "_TrackerHTTPServer",
|
server: "_TrackerHTTPServer",
|
||||||
model: str,
|
model: str,
|
||||||
@@ -701,6 +732,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
model_presets: dict,
|
model_presets: dict,
|
||||||
contracts: Any | None,
|
contracts: Any | None,
|
||||||
minimum_stake: int,
|
minimum_stake: int,
|
||||||
|
relay_url: str | None = None,
|
||||||
raft: "RaftNode | None" = None,
|
raft: "RaftNode | None" = None,
|
||||||
gossip: "NodeGossip | None" = None,
|
gossip: "NodeGossip | None" = None,
|
||||||
stats: "_StatsCollector | None" = None,
|
stats: "_StatsCollector | None" = None,
|
||||||
@@ -712,6 +744,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
self.model_presets = model_presets
|
self.model_presets = model_presets
|
||||||
self.contracts = contracts
|
self.contracts = contracts
|
||||||
self.minimum_stake = minimum_stake
|
self.minimum_stake = minimum_stake
|
||||||
|
self.relay_url = relay_url.rstrip("/") if relay_url else None
|
||||||
self.raft = raft
|
self.raft = raft
|
||||||
self.gossip = gossip
|
self.gossip = gossip
|
||||||
self.stats: _StatsCollector | None = stats
|
self.stats: _StatsCollector | None = stats
|
||||||
@@ -785,6 +818,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self._handle_assign(parsed)
|
self._handle_assign(parsed)
|
||||||
elif parsed.path == "/v1/network/assign":
|
elif parsed.path == "/v1/network/assign":
|
||||||
self._handle_network_assign(parsed)
|
self._handle_network_assign(parsed)
|
||||||
|
elif parsed.path == "/v1/network/map":
|
||||||
|
self._handle_network_map()
|
||||||
elif parsed.path == "/v1/models":
|
elif parsed.path == "/v1/models":
|
||||||
self._handle_models()
|
self._handle_models()
|
||||||
elif parsed.path.startswith("/v1/coverage/"):
|
elif parsed.path.startswith("/v1/coverage/"):
|
||||||
@@ -930,12 +965,38 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
{
|
{
|
||||||
"node_id": node.node_id,
|
"node_id": node.node_id,
|
||||||
"endpoint": node.endpoint,
|
"endpoint": node.endpoint,
|
||||||
|
"relay_addr": node.relay_addr,
|
||||||
|
"peer_id": node.peer_id,
|
||||||
"benchmark_tokens_per_sec": node.benchmark_tokens_per_sec,
|
"benchmark_tokens_per_sec": node.benchmark_tokens_per_sec,
|
||||||
}
|
}
|
||||||
for node in tracker_nodes
|
for node in tracker_nodes
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _handle_network_map(self) -> None:
|
||||||
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||||
|
with server.lock:
|
||||||
|
self._purge_expired_nodes()
|
||||||
|
nodes = list(server.registry.values())
|
||||||
|
self._send_json(200, {
|
||||||
|
"relay_url": server.relay_url,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"node_id": node.node_id,
|
||||||
|
"endpoint": node.endpoint,
|
||||||
|
"relay_addr": node.relay_addr,
|
||||||
|
"peer_id": node.peer_id,
|
||||||
|
"model": node.model,
|
||||||
|
"hf_repo": node.hf_repo,
|
||||||
|
"shard_start": node.shard_start,
|
||||||
|
"shard_end": node.shard_end,
|
||||||
|
"tracker_mode": node.tracker_mode,
|
||||||
|
"last_heartbeat": node.last_heartbeat,
|
||||||
|
}
|
||||||
|
for node in nodes
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
# ---------------------------------------------------------------- OpenAI proxy
|
# ---------------------------------------------------------------- OpenAI proxy
|
||||||
|
|
||||||
def _handle_proxy_chat(self) -> None:
|
def _handle_proxy_chat(self) -> None:
|
||||||
@@ -1059,6 +1120,25 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if node.relay_addr:
|
||||||
|
print(
|
||||||
|
f"[tracker] direct proxy failed {request_id}: {target_url}: {exc}; "
|
||||||
|
f"trying relay {node.relay_addr}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
relayed = _relay_http_request(
|
||||||
|
node.relay_addr,
|
||||||
|
path="/v1/chat/completions",
|
||||||
|
body=raw_body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Meshnet-Route": downstream_urls,
|
||||||
|
**({"Authorization": auth} if auth else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if relayed is not None:
|
||||||
|
self._send_relayed_response(relayed)
|
||||||
|
return
|
||||||
print(f"[tracker] proxy failed {request_id}: {target_url}: {exc}", flush=True)
|
print(f"[tracker] proxy failed {request_id}: {target_url}: {exc}", flush=True)
|
||||||
self._send_json(503, {"error": {
|
self._send_json(503, {"error": {
|
||||||
"message": f"upstream node unreachable: {exc}",
|
"message": f"upstream node unreachable: {exc}",
|
||||||
@@ -1100,6 +1180,20 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
except BrokenPipeError:
|
except BrokenPipeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _send_relayed_response(self, response: dict) -> None:
|
||||||
|
status = int(response.get("status", 503))
|
||||||
|
headers = response.get("headers") if isinstance(response.get("headers"), dict) else {}
|
||||||
|
body_text = response.get("body") or ""
|
||||||
|
body = body_text.encode() if isinstance(body_text, str) else bytes(body_text)
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", headers.get("Content-Type", "application/json"))
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
self.wfile.write(body)
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _handle_register(self):
|
def _handle_register(self):
|
||||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||||
body = self._read_json_body()
|
body = self._read_json_body()
|
||||||
@@ -1686,6 +1780,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
"node_id": e.node_id,
|
"node_id": e.node_id,
|
||||||
"endpoint": e.endpoint,
|
"endpoint": e.endpoint,
|
||||||
"start_layer": start,
|
"start_layer": start,
|
||||||
|
"relay_addr": e.relay_addr,
|
||||||
|
"peer_id": e.peer_id,
|
||||||
"wallet_address": e.wallet_address,
|
"wallet_address": e.wallet_address,
|
||||||
"shard_start": e.shard_start,
|
"shard_start": e.shard_start,
|
||||||
"shard_end": e.shard_end,
|
"shard_end": e.shard_end,
|
||||||
@@ -1746,6 +1842,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
{
|
{
|
||||||
"node_id": e.node_id,
|
"node_id": e.node_id,
|
||||||
"endpoint": e.endpoint,
|
"endpoint": e.endpoint,
|
||||||
|
"relay_addr": e.relay_addr,
|
||||||
|
"peer_id": e.peer_id,
|
||||||
"wallet_address": e.wallet_address,
|
"wallet_address": e.wallet_address,
|
||||||
"shard_start": e.shard_start,
|
"shard_start": e.shard_start,
|
||||||
"shard_end": e.shard_end,
|
"shard_end": e.shard_end,
|
||||||
@@ -1780,6 +1878,7 @@ class TrackerServer:
|
|||||||
cluster_peers: list[str] | None = None,
|
cluster_peers: list[str] | None = None,
|
||||||
cluster_self_url: str | None = None,
|
cluster_self_url: str | None = None,
|
||||||
stats_db: str | None = None,
|
stats_db: str | None = None,
|
||||||
|
relay_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._host = host
|
self._host = host
|
||||||
self._requested_port = port
|
self._requested_port = port
|
||||||
@@ -1792,6 +1891,7 @@ class TrackerServer:
|
|||||||
self._minimum_stake = minimum_stake
|
self._minimum_stake = minimum_stake
|
||||||
self._cluster_peers: list[str] = list(cluster_peers) if cluster_peers else []
|
self._cluster_peers: list[str] = list(cluster_peers) if cluster_peers else []
|
||||||
self._cluster_self_url: str | None = cluster_self_url
|
self._cluster_self_url: str | None = cluster_self_url
|
||||||
|
self._relay_url = relay_url
|
||||||
self._registry: dict[str, _NodeEntry] = {}
|
self._registry: dict[str, _NodeEntry] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._server: _TrackerHTTPServer | None = None
|
self._server: _TrackerHTTPServer | None = None
|
||||||
@@ -1819,6 +1919,7 @@ class TrackerServer:
|
|||||||
self._model_presets,
|
self._model_presets,
|
||||||
self._contracts,
|
self._contracts,
|
||||||
self._minimum_stake,
|
self._minimum_stake,
|
||||||
|
relay_url=self._relay_url,
|
||||||
stats=self._stats,
|
stats=self._stats,
|
||||||
)
|
)
|
||||||
self.port = self._server.server_address[1]
|
self.port = self._server.server_address[1]
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ version = "0.1.0"
|
|||||||
description = "Distributed Inference Network node registry and route selection"
|
description = "Distributed Inference Network node registry and route selection"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"websockets>=13",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
meshnet-tracker = "meshnet_tracker.cli:main"
|
meshnet-tracker = "meshnet_tracker.cli:main"
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,61 @@ def test_relay_circuit_relay_proxies_message():
|
|||||||
assert received_via_relay, "NAT'd peer did not receive message via circuit relay"
|
assert received_via_relay, "NAT'd peer did not receive message via circuit relay"
|
||||||
|
|
||||||
|
|
||||||
|
def test_relay_rpc_round_trips_http_request_to_peer():
|
||||||
|
"""Relay /rpc/<peer> sends one HTTP-shaped request to a connected peer."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
def run_peer():
|
||||||
|
with wsc.connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
"topic": "peer-register",
|
||||||
|
"version": 1,
|
||||||
|
"from_peer": "rpc_peer",
|
||||||
|
"msg_id": "rpc-reg-001",
|
||||||
|
"payload": {"peer_id": "rpc_peer", "addr": ""},
|
||||||
|
}))
|
||||||
|
ws.recv()
|
||||||
|
ready.set()
|
||||||
|
envelope = json.loads(ws.recv(timeout=3))
|
||||||
|
payload = envelope["payload"]
|
||||||
|
ws.send(json.dumps({
|
||||||
|
"topic": "relay-http-response",
|
||||||
|
"version": 1,
|
||||||
|
"from_peer": "rpc_peer",
|
||||||
|
"payload": {
|
||||||
|
"request_id": payload["request_id"],
|
||||||
|
"status": 200,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps({"ok": True, "path": payload["path"]}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
peer_thread = threading.Thread(target=run_peer, daemon=True)
|
||||||
|
peer_thread.start()
|
||||||
|
assert ready.wait(timeout=5)
|
||||||
|
|
||||||
|
with wsc.connect(f"ws://127.0.0.1:{port}/rpc/rpc_peer") as ws:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
"request_id": "req-1",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/chat/completions",
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": "{}",
|
||||||
|
}))
|
||||||
|
response = json.loads(ws.recv(timeout=5))
|
||||||
|
|
||||||
|
relay.stop()
|
||||||
|
peer_thread.join(timeout=3)
|
||||||
|
|
||||||
|
assert response["status"] == 200
|
||||||
|
assert json.loads(response["body"]) == {"ok": True, "path": "/v1/chat/completions"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tracker gossip fields tests
|
# Tracker gossip fields tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -349,6 +404,41 @@ def test_tracker_accepts_registration_without_gossip_fields():
|
|||||||
assert "node_id" in resp
|
assert "node_id" in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracker_network_map_exposes_relay_and_registered_peer():
|
||||||
|
import json as _json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from meshnet_tracker.server import TrackerServer
|
||||||
|
|
||||||
|
tracker = TrackerServer(host="127.0.0.1", port=0, relay_url="wss://ai.neuron.d-popov.com/ws")
|
||||||
|
port = tracker.start()
|
||||||
|
url = f"http://127.0.0.1:{port}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{url}/v1/nodes/register",
|
||||||
|
data=_json.dumps({
|
||||||
|
"endpoint": "http://192.0.2.10:7000",
|
||||||
|
"model": "stub-model",
|
||||||
|
"shard_start": 0,
|
||||||
|
"shard_end": 31,
|
||||||
|
"relay_addr": "wss://ai.neuron.d-popov.com/rpc/peer123",
|
||||||
|
"peer_id": "peer123",
|
||||||
|
}).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5):
|
||||||
|
pass
|
||||||
|
with urllib.request.urlopen(f"{url}/v1/network/map", timeout=5) as resp:
|
||||||
|
body = _json.loads(resp.read())
|
||||||
|
finally:
|
||||||
|
tracker.stop()
|
||||||
|
|
||||||
|
assert body["relay_url"] == "wss://ai.neuron.d-popov.com/ws"
|
||||||
|
assert body["nodes"][0]["relay_addr"] == "wss://ai.neuron.d-popov.com/rpc/peer123"
|
||||||
|
assert body["nodes"][0]["peer_id"] == "peer123"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# mDNS (no-op without zeroconf installed)
|
# mDNS (no-op without zeroconf installed)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -354,3 +355,113 @@ def test_legacy_start_subcommand_accepted(monkeypatch):
|
|||||||
|
|
||||||
# Exited (either 0 or via KeyboardInterrupt caught in _cmd_start)
|
# Exited (either 0 or via KeyboardInterrupt caught in _cmd_start)
|
||||||
# The important thing is no unhandled exception from arg parsing
|
# The important thing is no unhandled exception from arg parsing
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_start_treats_repo_model_as_model_id(monkeypatch):
|
||||||
|
"""`meshnet-node start --model org/repo` enters the HF model startup path."""
|
||||||
|
from meshnet_node.cli import main
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run_startup(*args, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
class _FakeNode:
|
||||||
|
chat_completion_count = 0
|
||||||
|
def stop(self): pass
|
||||||
|
return _FakeNode()
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "argv", [
|
||||||
|
"meshnet-node", "start",
|
||||||
|
"--tracker", "http://192.168.0.179:8081",
|
||||||
|
"--model", "Qwen/Qwen2.5-0.5B-Instruct",
|
||||||
|
"--port", "0",
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
|
||||||
|
with patch("time.sleep", side_effect=KeyboardInterrupt):
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except SystemExit as exc:
|
||||||
|
assert exc.code == 0
|
||||||
|
|
||||||
|
assert captured["model"] == "Qwen2.5-0.5B-Instruct"
|
||||||
|
assert captured["model_id"] == "Qwen/Qwen2.5-0.5B-Instruct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_start_without_port_uses_next_available_port(monkeypatch):
|
||||||
|
"""Omitting --port skips an occupied default port before startup loads the model."""
|
||||||
|
from meshnet_node.cli import main
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run_startup(*args, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
class _FakeNode:
|
||||||
|
chat_completion_count = 0
|
||||||
|
def stop(self): pass
|
||||||
|
return _FakeNode()
|
||||||
|
|
||||||
|
occupied = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
occupied.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
occupied.bind(("127.0.0.1", 7000))
|
||||||
|
occupied.listen(1)
|
||||||
|
try:
|
||||||
|
monkeypatch.setattr(sys, "argv", [
|
||||||
|
"meshnet-node", "start",
|
||||||
|
"--tracker", "http://192.168.0.179:8081",
|
||||||
|
"--model", "Qwen/Qwen2.5-0.5B-Instruct",
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
|
||||||
|
with patch("time.sleep", side_effect=KeyboardInterrupt):
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except SystemExit as exc:
|
||||||
|
assert exc.code == 0
|
||||||
|
finally:
|
||||||
|
occupied.close()
|
||||||
|
|
||||||
|
assert captured["port"] == 7001
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_cli_passes_advertise_host(monkeypatch):
|
||||||
|
"""The documented no-subcommand LAN flag reaches startup."""
|
||||||
|
from meshnet_node.cli import main
|
||||||
|
|
||||||
|
saved = {
|
||||||
|
"model_hf_repo": "Qwen/Qwen2.5-0.5B-Instruct",
|
||||||
|
"model_name": "Qwen2.5-0.5B-Instruct",
|
||||||
|
"quantization": "nf4",
|
||||||
|
"tracker_url": "http://localhost:8080",
|
||||||
|
"wallet_path": "",
|
||||||
|
"download_dir": "",
|
||||||
|
"port": 7000,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
}
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run_startup(*args, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
class _FakeNode:
|
||||||
|
chat_completion_count = 0
|
||||||
|
def stop(self): pass
|
||||||
|
return _FakeNode()
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "argv", [
|
||||||
|
"meshnet-node",
|
||||||
|
"--tracker", "http://192.168.0.179:8081",
|
||||||
|
"--advertise-host", "192.168.0.42",
|
||||||
|
"--no-tui",
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("meshnet_node.config.load_config", return_value=saved):
|
||||||
|
with patch("meshnet_node.startup.run_startup", side_effect=fake_run_startup):
|
||||||
|
with patch("meshnet_node.dashboard.run_dashboard", side_effect=KeyboardInterrupt):
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except SystemExit as exc:
|
||||||
|
assert exc.code == 0
|
||||||
|
|
||||||
|
assert captured["tracker_url"] == "http://192.168.0.179:8081"
|
||||||
|
assert captured["advertise_host"] == "192.168.0.42"
|
||||||
|
|||||||
Reference in New Issue
Block a user