Compare commits
3 Commits
d8a723a4c7
...
ab6558d861
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6558d861 | ||
|
|
6c46f96aaf | ||
|
|
8157151102 |
@@ -153,10 +153,10 @@ lines like:
|
|||||||
curl http://192.168.0.42:8005/v1/health
|
curl http://192.168.0.42:8005/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
If that endpoint returns 404, that is okay: it still proves the TCP connection
|
If that endpoint returns 404 or 501, that is okay: it still proves the TCP
|
||||||
reached the node process. If it times out or connection-refuses, check the
|
connection reached the node process. If it times out or connection-refuses, check
|
||||||
Windows Firewall rule, `--host 0.0.0.0`, the selected LAN IP, and that the node is
|
the Windows Firewall rule, `--host 0.0.0.0`, the selected LAN IP, and that the
|
||||||
still running.
|
node is still running.
|
||||||
|
|
||||||
### Public tracker + WSS relay
|
### Public tracker + WSS relay
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -114,20 +115,35 @@ class RelayHttpBridge:
|
|||||||
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 "/")
|
||||||
body_text = payload.get("body") or ""
|
|
||||||
headers = payload.get("headers") if isinstance(payload.get("headers"), dict) else {}
|
headers = payload.get("headers") if isinstance(payload.get("headers"), dict) else {}
|
||||||
|
|
||||||
|
# body_base64 carries binary data (e.g. bfloat16 activation tensors) safely.
|
||||||
|
# Fallback to text "body" for backward-compat with non-binary requests.
|
||||||
|
body_b64 = payload.get("body_base64")
|
||||||
|
if body_b64:
|
||||||
|
data = base64.b64decode(body_b64)
|
||||||
|
else:
|
||||||
|
body_text = payload.get("body") or ""
|
||||||
|
data = body_text.encode() if isinstance(body_text, str) else bytes(body_text)
|
||||||
|
|
||||||
url = f"{self.local_base_url}{path}"
|
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)
|
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:
|
||||||
return {
|
resp_bytes = resp.read()
|
||||||
|
resp_headers = dict(resp.headers)
|
||||||
|
# Forward all X-Meshnet-* headers so the caller can reconstruct the activation.
|
||||||
|
is_binary = "octet-stream" in resp.headers.get("Content-Type", "")
|
||||||
|
result: dict = {
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"status": resp.status,
|
"status": resp.status,
|
||||||
"headers": {"Content-Type": resp.headers.get("Content-Type", "application/json")},
|
"headers": resp_headers,
|
||||||
"body": resp.read().decode(errors="replace"),
|
|
||||||
}
|
}
|
||||||
|
if is_binary:
|
||||||
|
result["body_base64"] = base64.b64encode(resp_bytes).decode()
|
||||||
|
else:
|
||||||
|
result["body"] = resp_bytes.decode(errors="replace")
|
||||||
|
return result
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
return {
|
return {
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@@ -29,6 +30,40 @@ from .server import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relay_hop(
|
||||||
|
relay_addr: str,
|
||||||
|
path: str,
|
||||||
|
body: bytes,
|
||||||
|
headers: dict[str, str],
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> tuple[int, dict[str, str], bytes]:
|
||||||
|
"""Send a single HTTP-shaped request through a relay RPC WebSocket.
|
||||||
|
|
||||||
|
relay_addr is the wss://relay.../rpc/{peer_id} URL.
|
||||||
|
Returns (status, response_headers_lower, response_body).
|
||||||
|
Raises on connection failure so callers can fall back to direct.
|
||||||
|
"""
|
||||||
|
import websockets.sync.client as wsc # type: ignore[import]
|
||||||
|
|
||||||
|
request_id = f"{time.time_ns():x}"
|
||||||
|
payload = json.dumps({
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": "POST",
|
||||||
|
"path": path,
|
||||||
|
"headers": headers,
|
||||||
|
"body_base64": base64.b64encode(body).decode(),
|
||||||
|
})
|
||||||
|
with wsc.connect(relay_addr, open_timeout=timeout) as ws:
|
||||||
|
ws.send(payload)
|
||||||
|
raw = ws.recv(timeout=timeout)
|
||||||
|
resp = json.loads(raw)
|
||||||
|
status = int(resp.get("status", 503))
|
||||||
|
resp_headers = {k.lower(): v for k, v in (resp.get("headers") or {}).items()}
|
||||||
|
body_b64 = resp.get("body_base64")
|
||||||
|
resp_body = base64.b64decode(body_b64) if body_b64 else (resp.get("body") or "").encode()
|
||||||
|
return status, resp_headers, resp_body
|
||||||
|
|
||||||
|
|
||||||
class _TorchHTTPServer(http.server.HTTPServer):
|
class _TorchHTTPServer(http.server.HTTPServer):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -326,8 +361,8 @@ class _TorchHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
result_text = "".join(generated)
|
result_text = "".join(generated)
|
||||||
self._send_openai_response(result_text, model_name, stream, messages)
|
self._send_openai_response(result_text, model_name, stream, messages)
|
||||||
|
|
||||||
def _get_remaining_route(self, model: str) -> list[tuple[str, int]]:
|
def _get_remaining_route(self, model: str) -> list[dict]:
|
||||||
"""Return downstream hops as (endpoint, start_layer) pairs.
|
"""Return downstream hops as dicts with endpoint, start_layer, and optional relay_addr.
|
||||||
|
|
||||||
Fast path reads X-Meshnet-Route header injected by the tracker.
|
Fast path reads X-Meshnet-Route header injected by the tracker.
|
||||||
Slow path queries the tracker's /v1/route endpoint as a fallback.
|
Slow path queries the tracker's /v1/route endpoint as a fallback.
|
||||||
@@ -340,13 +375,19 @@ class _TorchHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
route = json.loads(injected)
|
route = json.loads(injected)
|
||||||
if isinstance(route, list):
|
if isinstance(route, list):
|
||||||
hops: list[tuple[str, int]] = []
|
hops: list[dict] = []
|
||||||
for item in route:
|
for item in route:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
hops.append((str(item["endpoint"]), int(item.get("start_layer", 0))))
|
hop = {
|
||||||
|
"endpoint": str(item["endpoint"]),
|
||||||
|
"start_layer": int(item.get("start_layer", 0)),
|
||||||
|
}
|
||||||
|
if item.get("relay_addr"):
|
||||||
|
hop["relay_addr"] = str(item["relay_addr"])
|
||||||
|
hops.append(hop)
|
||||||
elif isinstance(item, str):
|
elif isinstance(item, str):
|
||||||
hops.append((item, 0)) # backward-compat: plain string, no start_layer
|
hops.append({"endpoint": item, "start_layer": 0})
|
||||||
print(f" [node] using injected downstream route: {[ep for ep, _ in hops]}", flush=True)
|
print(f" [node] using injected downstream route: {[h['endpoint'] for h in hops]}", flush=True)
|
||||||
return hops
|
return hops
|
||||||
except (json.JSONDecodeError, TypeError, KeyError):
|
except (json.JSONDecodeError, TypeError, KeyError):
|
||||||
pass
|
pass
|
||||||
@@ -362,7 +403,6 @@ class _TorchHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
route_resp = json.loads(r.read())
|
route_resp = json.loads(r.read())
|
||||||
own_port = server.server_address[1]
|
own_port = server.server_address[1]
|
||||||
nodes_info = route_resp.get("nodes", [])
|
nodes_info = route_resp.get("nodes", [])
|
||||||
# nodes_info is ordered; find own node and compute start_layers post-hoc
|
|
||||||
hops = []
|
hops = []
|
||||||
covered_up_to: int | None = None
|
covered_up_to: int | None = None
|
||||||
for node_info in nodes_info:
|
for node_info in nodes_info:
|
||||||
@@ -371,18 +411,19 @@ class _TorchHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
covered_up_to = node_info.get("shard_end")
|
covered_up_to = node_info.get("shard_end")
|
||||||
continue
|
continue
|
||||||
if covered_up_to is None:
|
if covered_up_to is None:
|
||||||
# Own node not found yet; use node's shard_start as fallback
|
|
||||||
covered_up_to = (node_info.get("shard_start") or 1) - 1
|
covered_up_to = (node_info.get("shard_start") or 1) - 1
|
||||||
start_l = covered_up_to + 1
|
hop = {"endpoint": ep, "start_layer": covered_up_to + 1}
|
||||||
hops.append((ep, start_l))
|
if node_info.get("relay_addr"):
|
||||||
|
hop["relay_addr"] = str(node_info["relay_addr"])
|
||||||
|
hops.append(hop)
|
||||||
covered_up_to = node_info.get("shard_end", covered_up_to)
|
covered_up_to = node_info.get("shard_end", covered_up_to)
|
||||||
print(f" [node] tracker downstream route: {[ep for ep, _ in hops]}", flush=True)
|
print(f" [node] tracker downstream route: {[h['endpoint'] for h in hops]}", flush=True)
|
||||||
return hops
|
return hops
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f" [node] WARNING: route lookup failed for {route_model!r}: {exc}", flush=True)
|
print(f" [node] WARNING: route lookup failed for {route_model!r}: {exc}", flush=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _run_downstream_pipeline(self, payload: object, route: list[tuple[str, int]]) -> str:
|
def _run_downstream_pipeline(self, payload: object, route: list[dict]) -> str:
|
||||||
server: _TorchHTTPServer = self.server # type: ignore[assignment]
|
server: _TorchHTTPServer = self.server # type: ignore[assignment]
|
||||||
if not route:
|
if not route:
|
||||||
# Partial shard at tail: decode the activation from the previous node.
|
# Partial shard at tail: decode the activation from the previous node.
|
||||||
@@ -407,9 +448,16 @@ class _TorchHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
current_attn = attn_mask
|
current_attn = attn_mask
|
||||||
current_pos = pos_ids
|
current_pos = pos_ids
|
||||||
|
|
||||||
for hop_index, (node_url, start_layer) in enumerate(route):
|
for hop_index, hop in enumerate(route):
|
||||||
|
node_url = hop["endpoint"]
|
||||||
|
start_layer = hop.get("start_layer", 0)
|
||||||
|
relay_addr = hop.get("relay_addr")
|
||||||
if server.debug:
|
if server.debug:
|
||||||
print(f" [node] pipeline hop {hop_index}: {node_url} start_layer={start_layer}", flush=True)
|
print(
|
||||||
|
f" [node] pipeline hop {hop_index}: {node_url} start_layer={start_layer}"
|
||||||
|
+ (f" relay={relay_addr}" if relay_addr else ""),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
headers: dict[str, str] = {
|
headers: dict[str, str] = {
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
"X-Meshnet-Wire": _WIRE_VERSION,
|
"X-Meshnet-Wire": _WIRE_VERSION,
|
||||||
@@ -425,19 +473,38 @@ class _TorchHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
headers["X-Meshnet-Attn-Mask"] = current_attn
|
headers["X-Meshnet-Attn-Mask"] = current_attn
|
||||||
if current_pos:
|
if current_pos:
|
||||||
headers["X-Meshnet-Position-Ids"] = current_pos
|
headers["X-Meshnet-Position-Ids"] = current_pos
|
||||||
req = urllib.request.Request(
|
if relay_addr:
|
||||||
f"{node_url}/forward",
|
try:
|
||||||
data=current_body,
|
status, resp_headers, resp_body = _relay_hop(
|
||||||
headers=headers,
|
relay_addr, "/forward", current_body, headers, timeout=120.0,
|
||||||
method="POST",
|
)
|
||||||
)
|
if status >= 400:
|
||||||
try:
|
print(
|
||||||
with urllib.request.urlopen(req, timeout=120.0) as r:
|
f" [node] relay hop {hop_index} returned {status} from {relay_addr}",
|
||||||
resp_body = r.read()
|
flush=True,
|
||||||
resp_headers = {k.lower(): v for k, v in r.headers.items()}
|
)
|
||||||
except Exception as exc:
|
return f"pipeline error at {node_url} via relay: status {status}"
|
||||||
print(f" [node] pipeline hop {hop_index} failed at {node_url}: {exc}", flush=True)
|
except Exception as exc:
|
||||||
return f"pipeline error at {node_url}: {exc}"
|
print(
|
||||||
|
f" [node] relay hop {hop_index} failed at {relay_addr}: {exc}; "
|
||||||
|
f"falling back to direct {node_url}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
relay_addr = None # fall through to direct
|
||||||
|
if not relay_addr:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{node_url}/forward",
|
||||||
|
data=current_body,
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120.0) as r:
|
||||||
|
resp_body = r.read()
|
||||||
|
resp_headers = {k.lower(): v for k, v in r.headers.items()}
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" [node] pipeline hop {hop_index} failed at {node_url}: {exc}", flush=True)
|
||||||
|
return f"pipeline error at {node_url}: {exc}"
|
||||||
content_type = resp_headers.get("content-type", "")
|
content_type = resp_headers.get("content-type", "")
|
||||||
if "application/json" in content_type:
|
if "application/json" in content_type:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1074,7 +1074,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
covered_up_to = rs - 1
|
covered_up_to = rs - 1
|
||||||
route_hops: list[dict] = []
|
route_hops: list[dict] = []
|
||||||
for rn in route_nodes:
|
for rn in route_nodes:
|
||||||
route_hops.append({"endpoint": rn.endpoint, "start_layer": covered_up_to + 1})
|
hop: dict = {"endpoint": rn.endpoint, "start_layer": covered_up_to + 1}
|
||||||
|
if rn.relay_addr:
|
||||||
|
hop["relay_addr"] = rn.relay_addr
|
||||||
|
route_hops.append(hop)
|
||||||
covered_up_to = rn.shard_end if rn.shard_end is not None else covered_up_to
|
covered_up_to = rn.shard_end if rn.shard_end is not None else covered_up_to
|
||||||
# Strip the first-shard node we're about to proxy to — it's already handling the request.
|
# Strip the first-shard node we're about to proxy to — it's already handling the request.
|
||||||
downstream_hops = [h for h in route_hops if h["endpoint"].rstrip("/") != node.endpoint.rstrip("/")]
|
downstream_hops = [h for h in route_hops if h["endpoint"].rstrip("/") != node.endpoint.rstrip("/")]
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ def test_default_cli_passes_advertise_host(monkeypatch):
|
|||||||
"meshnet-node",
|
"meshnet-node",
|
||||||
"--tracker", "http://192.168.0.179:8081",
|
"--tracker", "http://192.168.0.179:8081",
|
||||||
"--advertise-host", "192.168.0.42",
|
"--advertise-host", "192.168.0.42",
|
||||||
|
"--debug",
|
||||||
"--no-tui",
|
"--no-tui",
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -465,3 +466,4 @@ def test_default_cli_passes_advertise_host(monkeypatch):
|
|||||||
|
|
||||||
assert captured["tracker_url"] == "http://192.168.0.179:8081"
|
assert captured["tracker_url"] == "http://192.168.0.179:8081"
|
||||||
assert captured["advertise_host"] == "192.168.0.42"
|
assert captured["advertise_host"] == "192.168.0.42"
|
||||||
|
assert captured["debug"] is True
|
||||||
|
|||||||
@@ -81,6 +81,38 @@ class _FakeFullBackend(_FakeBackend):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeChatTokenizer:
|
||||||
|
eos_token = ""
|
||||||
|
|
||||||
|
def apply_chat_template(self, messages, add_generation_prompt=True, tokenize=False):
|
||||||
|
assert add_generation_prompt is True
|
||||||
|
assert tokenize is False
|
||||||
|
return "debug prompt"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePipelineHeadBackend(_FakeBackend):
|
||||||
|
tokenizer = _FakeChatTokenizer()
|
||||||
|
|
||||||
|
def encode_prompt(self, prompt: str) -> TensorPayload:
|
||||||
|
assert prompt == "debug prompt"
|
||||||
|
return TensorPayload(
|
||||||
|
body=b"\x00" * (1 * 6 * 8 * 2),
|
||||||
|
shape=[1, 6, 8],
|
||||||
|
attention_mask_header=None,
|
||||||
|
position_ids_header=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePipelineTailBackend(_FakeTailBackend):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.start_layers: list[int | None] = []
|
||||||
|
|
||||||
|
def forward_bytes(self, body, shape, attention_mask_header, position_ids_header, start_layer=None):
|
||||||
|
self.start_layers.append(start_layer)
|
||||||
|
assert len(body) == 1 * 6 * 8 * 2
|
||||||
|
return " token"
|
||||||
|
|
||||||
|
|
||||||
def test_quantization_flag_validation():
|
def test_quantization_flag_validation():
|
||||||
assert validate_quantization("bfloat16") == "bfloat16"
|
assert validate_quantization("bfloat16") == "bfloat16"
|
||||||
assert validate_quantization("int8") == "int8"
|
assert validate_quantization("int8") == "int8"
|
||||||
@@ -198,6 +230,75 @@ def test_full_model_chat_completion_uses_generation_not_single_token_decode():
|
|||||||
node.stop()
|
node.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_hop_logs_are_suppressed_without_debug(capsys):
|
||||||
|
tail_backend = _FakePipelineTailBackend()
|
||||||
|
head = TorchNodeServer(backend=_FakePipelineHeadBackend(), tracker_mode=True)
|
||||||
|
tail = TorchNodeServer(backend=tail_backend)
|
||||||
|
head_port = head.start()
|
||||||
|
tail_port = tail.start()
|
||||||
|
try:
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": "fake-model",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
"max_tokens": 1,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{head_port}/v1/chat/completions",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Meshnet-Route": json.dumps([
|
||||||
|
{"endpoint": f"http://127.0.0.1:{tail_port}", "start_layer": 22},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
body = json.loads(resp.read())
|
||||||
|
finally:
|
||||||
|
head.stop()
|
||||||
|
tail.stop()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert body["choices"][0]["message"]["content"] == " token"
|
||||||
|
assert tail_backend.start_layers == [22]
|
||||||
|
assert "pipeline hop 0:" not in out
|
||||||
|
assert "pipeline hop 0 returned text" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_hop_logs_are_enabled_with_debug(capsys):
|
||||||
|
head = TorchNodeServer(backend=_FakePipelineHeadBackend(), tracker_mode=True, debug=True)
|
||||||
|
tail = TorchNodeServer(backend=_FakePipelineTailBackend())
|
||||||
|
head_port = head.start()
|
||||||
|
tail_port = tail.start()
|
||||||
|
try:
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": "fake-model",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
"max_tokens": 1,
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{head_port}/v1/chat/completions",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Meshnet-Route": json.dumps([
|
||||||
|
{"endpoint": f"http://127.0.0.1:{tail_port}", "start_layer": 22},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
json.loads(resp.read())
|
||||||
|
finally:
|
||||||
|
head.stop()
|
||||||
|
tail.stop()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert f" [node] pipeline hop 0: http://127.0.0.1:{tail_port} start_layer=22" in out
|
||||||
|
assert " [node] pipeline hop 0 returned text=' token'" in out
|
||||||
|
|
||||||
|
|
||||||
def test_int_tensor_header_serializes_torch_tensors():
|
def test_int_tensor_header_serializes_torch_tensors():
|
||||||
torch = pytest.importorskip("torch")
|
torch = pytest.importorskip("torch")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user