Tracker:
- _NodeEntry gains hf_repo + num_layers fields (parsed from register body)
- GET /v1/network/assign — finds the biggest uncovered shard gap across
registered HF-model nodes; returns {hf_repo, shard_start, shard_end, num_layers}
- Returns 503 when no HF-model nodes are registered yet
Node startup:
- When model_id is set: registers with tracker including hf_repo + num_layers
so other nodes can auto-join this model
- When model_id is empty/None: queries /v1/network/assign, gets assigned the
missing layers, loads TorchNodeServer with the assigned shard automatically
- Fixes empty-string model_id leaking from DEFAULTS (treats "" same as None)
Usage: `meshnet-node start --tracker http://localhost:8080 --quantization bfloat16`
Node discovers what to serve and joins the network without any model flags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1055 lines
39 KiB
Python
1055 lines
39 KiB
Python
"""Tracker HTTP server — node registry and route selection for the inference mesh.
|
|
|
|
HTTP API contract:
|
|
- POST /v1/nodes/register
|
|
Request JSON: {
|
|
"endpoint": "http://host:port", "shard_start": int, "shard_end": int,
|
|
"model": str optional, "shard_checksum": str optional,
|
|
"hardware_profile": object, "wallet_address": str optional,
|
|
"score": number optional
|
|
}
|
|
Response 200: {"node_id": str}
|
|
Response 400: {"error": str}
|
|
- POST /v1/nodes/{node_id}/heartbeat
|
|
Response 200: {}
|
|
Response 404: {"error": "node not found"}
|
|
- GET /v1/route?model=<preset>
|
|
Response 200: {"route": ["http://node-a", "http://node-b"]}
|
|
Response 400/404/503: {"error": str}
|
|
- GET /v1/routes?model=<preset>&redundancy=<n>
|
|
Response 200: {"routes": [{"route": [...], "nodes": [...]}]}
|
|
Response 400/404/503: {"error": str}
|
|
"""
|
|
|
|
import http.server
|
|
import json
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from typing import Any
|
|
|
|
|
|
DEFAULT_MODEL_PRESETS: dict[str, dict] = {
|
|
"stub-model": {
|
|
"layers_start": 0,
|
|
"layers_end": 31,
|
|
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024},
|
|
},
|
|
"openai-community/gpt2": {
|
|
"layers_start": 0,
|
|
"layers_end": 11,
|
|
"bytes_per_layer": {"bfloat16": 30 * 1024 * 1024, "int8": 15 * 1024 * 1024, "nf4": 8 * 1024 * 1024},
|
|
},
|
|
}
|
|
|
|
DEFAULT_VRAM_BYTES = 8 * 1024 * 1024 * 1024
|
|
DEFAULT_RAM_BYTES = 16 * 1024 * 1024 * 1024
|
|
DEFAULT_QUANTIZATIONS = ["bfloat16"]
|
|
DEFAULT_BENCHMARK_TOKENS_PER_SEC = 1.0
|
|
|
|
|
|
class _NodeEntry:
|
|
__slots__ = (
|
|
"node_id", "endpoint", "shard_start", "shard_end",
|
|
"model", "hf_repo", "num_layers", "shard_checksum", "hardware_profile", "wallet_address",
|
|
"score", "vram_bytes", "ram_bytes", "quantizations",
|
|
"benchmark_tokens_per_sec", "quantization", "managed_assignment",
|
|
"pending_directives", "last_heartbeat", "tracker_mode",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
node_id: str,
|
|
endpoint: str,
|
|
shard_start: int | None,
|
|
shard_end: int | None,
|
|
model: str | None,
|
|
shard_checksum: str | None,
|
|
hardware_profile: dict,
|
|
wallet_address: str | None,
|
|
score: float,
|
|
vram_bytes: int = DEFAULT_VRAM_BYTES,
|
|
ram_bytes: int = DEFAULT_RAM_BYTES,
|
|
quantizations: list[str] | None = None,
|
|
benchmark_tokens_per_sec: float = DEFAULT_BENCHMARK_TOKENS_PER_SEC,
|
|
quantization: str | None = None,
|
|
managed_assignment: bool = False,
|
|
tracker_mode: bool = False,
|
|
hf_repo: str | None = None,
|
|
num_layers: int | None = None,
|
|
) -> None:
|
|
self.node_id = node_id
|
|
self.endpoint = endpoint
|
|
self.shard_start = shard_start
|
|
self.shard_end = shard_end
|
|
self.model = model
|
|
self.shard_checksum = shard_checksum
|
|
self.hardware_profile = hardware_profile
|
|
self.wallet_address = wallet_address
|
|
self.score = score
|
|
self.vram_bytes = vram_bytes
|
|
self.ram_bytes = ram_bytes
|
|
self.quantizations = quantizations or list(DEFAULT_QUANTIZATIONS)
|
|
self.benchmark_tokens_per_sec = benchmark_tokens_per_sec
|
|
self.quantization = quantization
|
|
self.managed_assignment = managed_assignment
|
|
self.tracker_mode = tracker_mode
|
|
self.hf_repo = hf_repo
|
|
self.num_layers = num_layers
|
|
self.pending_directives: list[dict] = []
|
|
self.last_heartbeat: float = time.monotonic()
|
|
|
|
|
|
def _select_route(
|
|
nodes: list[_NodeEntry],
|
|
required_start: int,
|
|
required_end: int,
|
|
) -> tuple[list[_NodeEntry], str]:
|
|
"""Greedy interval-cover. Returns (ordered route, error_message)."""
|
|
candidates = sorted(
|
|
[node for node in nodes if node.shard_start is not None and node.shard_end is not None],
|
|
key=lambda n: (n.shard_start, -n.shard_end), # type: ignore[operator]
|
|
)
|
|
route: list[_NodeEntry] = []
|
|
covered_up_to = required_start - 1
|
|
|
|
while covered_up_to < required_end:
|
|
best: _NodeEntry | None = None
|
|
for node in candidates:
|
|
if node.shard_start <= covered_up_to + 1 and node.shard_end > covered_up_to:
|
|
if best is None or node.shard_end > best.shard_end:
|
|
best = node
|
|
if best is None:
|
|
missing = covered_up_to + 1
|
|
return [], f"no route available: no registered node covers layer {missing}"
|
|
route.append(best)
|
|
covered_up_to = best.shard_end
|
|
candidates = [n for n in candidates if n is not best]
|
|
|
|
return route, ""
|
|
|
|
|
|
def _coverage_percentage(
|
|
nodes: list[_NodeEntry],
|
|
required_start: int,
|
|
required_end: int,
|
|
) -> float:
|
|
required_layers = required_end - required_start + 1
|
|
if required_layers <= 0:
|
|
return 0.0
|
|
|
|
intervals = sorted(
|
|
(
|
|
(max(required_start, node.shard_start), min(required_end, node.shard_end))
|
|
for node in nodes
|
|
if node.shard_start is not None
|
|
and node.shard_end is not None
|
|
if node.shard_end >= required_start and node.shard_start <= required_end
|
|
),
|
|
key=lambda interval: interval[0],
|
|
)
|
|
covered = 0
|
|
covered_until = required_start - 1
|
|
for start, end in intervals:
|
|
if end <= covered_until:
|
|
continue
|
|
next_start = max(start, covered_until + 1)
|
|
if next_start > end:
|
|
continue
|
|
covered += end - next_start + 1
|
|
covered_until = end
|
|
return round((covered / required_layers) * 100, 2)
|
|
|
|
|
|
def _preset_layer_bounds(preset: dict) -> tuple[int, int]:
|
|
start = int(preset.get("layers_start", 0))
|
|
if "layers_end" in preset:
|
|
return start, int(preset["layers_end"])
|
|
return start, start + int(preset["total_layers"]) - 1
|
|
|
|
|
|
def _preset_bytes_per_layer(preset: dict) -> dict[str, int]:
|
|
raw = preset.get("bytes_per_layer", preset.get("bytes_per_layer_at_quant", {}))
|
|
if isinstance(raw, dict) and raw:
|
|
return {str(quant): int(value) for quant, value in raw.items()}
|
|
return {"bfloat16": 30 * 1024 * 1024}
|
|
|
|
|
|
def _node_quantization(node: _NodeEntry, preset: dict) -> str:
|
|
bytes_per_layer = _preset_bytes_per_layer(preset)
|
|
if node.quantization in bytes_per_layer:
|
|
return node.quantization
|
|
for quantization in node.quantizations:
|
|
if quantization in bytes_per_layer:
|
|
return quantization
|
|
return next(iter(bytes_per_layer))
|
|
|
|
|
|
def _node_layer_capacity(node: _NodeEntry, preset: dict) -> int:
|
|
bytes_per_layer = _preset_bytes_per_layer(preset)
|
|
quantization = _node_quantization(node, preset)
|
|
layer_bytes = bytes_per_layer[quantization]
|
|
if layer_bytes <= 0:
|
|
return 0
|
|
return int((node.vram_bytes * 0.8) // layer_bytes)
|
|
|
|
|
|
def _coverage_map(
|
|
nodes: list[_NodeEntry],
|
|
required_start: int,
|
|
required_end: int,
|
|
) -> list[dict]:
|
|
layer_counts = []
|
|
for layer in range(required_start, required_end + 1):
|
|
count = 0
|
|
for node in nodes:
|
|
if node.shard_start is None or node.shard_end is None:
|
|
continue
|
|
if node.shard_start <= layer <= node.shard_end:
|
|
count += 1
|
|
layer_counts.append((layer, count))
|
|
|
|
coverage: list[dict] = []
|
|
for layer, count in layer_counts:
|
|
if coverage and coverage[-1]["node_count"] == count and coverage[-1]["end_layer"] == layer - 1:
|
|
coverage[-1]["end_layer"] = layer
|
|
else:
|
|
coverage.append({"start_layer": layer, "end_layer": layer, "node_count": count})
|
|
return coverage
|
|
|
|
|
|
def _coverage_gaps(coverage: list[dict]) -> list[tuple[int, int]]:
|
|
return [
|
|
(segment["start_layer"], segment["end_layer"])
|
|
for segment in coverage
|
|
if segment["node_count"] == 0
|
|
]
|
|
|
|
|
|
def _load_directive(node: _NodeEntry, model: str, start: int, end: int, quantization: str) -> dict:
|
|
return {
|
|
"action": "LOAD_SHARD",
|
|
"model": model,
|
|
"start_layer": start,
|
|
"end_layer": end,
|
|
"shard_start": start,
|
|
"shard_end": end,
|
|
"quantization": quantization,
|
|
}
|
|
|
|
|
|
def _drop_directive(node: _NodeEntry, model: str, start: int, end: int, quantization: str) -> dict:
|
|
return {
|
|
"action": "DROP_SHARD",
|
|
"model": model,
|
|
"start_layer": start,
|
|
"end_layer": end,
|
|
"shard_start": start,
|
|
"shard_end": end,
|
|
"quantization": quantization,
|
|
}
|
|
|
|
|
|
def _purge_expired_nodes_locked(server: "_TrackerHTTPServer") -> list[str]:
|
|
now = time.monotonic()
|
|
expired_ids = [
|
|
node_id for node_id, entry in server.registry.items()
|
|
if (now - entry.last_heartbeat) > server.heartbeat_timeout
|
|
]
|
|
for node_id in expired_ids:
|
|
del server.registry[node_id]
|
|
if expired_ids:
|
|
_rebalance_all_locked(server)
|
|
return expired_ids
|
|
|
|
|
|
def _rebalance_model_locked(server: "_TrackerHTTPServer", model: str) -> None:
|
|
preset = server.model_presets.get(model)
|
|
if preset is None:
|
|
return
|
|
required_start, required_end = _preset_layer_bounds(preset)
|
|
total_layers = required_end - required_start + 1
|
|
model_nodes = [node for node in server.registry.values() if node.model == model]
|
|
managed_nodes = [node for node in model_nodes if node.managed_assignment]
|
|
if not managed_nodes:
|
|
return
|
|
|
|
previous_ranges = {
|
|
node.node_id: (node.shard_start, node.shard_end, node.quantization)
|
|
for node in managed_nodes
|
|
}
|
|
for node in managed_nodes:
|
|
node.shard_start = None
|
|
node.shard_end = None
|
|
|
|
managed_nodes.sort(
|
|
key=lambda node: (
|
|
-node.benchmark_tokens_per_sec,
|
|
-_node_layer_capacity(node, preset),
|
|
node.node_id,
|
|
)
|
|
)
|
|
base_nodes = [node for node in model_nodes if not node.managed_assignment]
|
|
coverage = _coverage_map(base_nodes, required_start, required_end)
|
|
gaps = _coverage_gaps(coverage)
|
|
if not gaps:
|
|
gaps = [(required_start, required_end)]
|
|
|
|
eligible_nodes = [
|
|
node for node in managed_nodes
|
|
if _node_layer_capacity(node, preset) > 0
|
|
]
|
|
node_index = 0
|
|
for gap_start, gap_end in gaps:
|
|
cursor = gap_start
|
|
while cursor <= gap_end and node_index < len(eligible_nodes):
|
|
node = eligible_nodes[node_index]
|
|
remaining_layers = gap_end - cursor + 1
|
|
remaining_nodes_after = len(eligible_nodes) - node_index - 1
|
|
capacity = min(
|
|
_node_layer_capacity(node, preset),
|
|
total_layers,
|
|
max(1, remaining_layers - remaining_nodes_after),
|
|
)
|
|
if capacity <= 0:
|
|
node_index += 1
|
|
continue
|
|
quantization = _node_quantization(node, preset)
|
|
node.quantization = quantization
|
|
node.shard_start = cursor
|
|
node.shard_end = min(gap_end, cursor + capacity - 1)
|
|
cursor = node.shard_end + 1
|
|
node_index += 1
|
|
|
|
for node in managed_nodes:
|
|
previous_start, previous_end, previous_quantization = previous_ranges[node.node_id]
|
|
current_range = (node.shard_start, node.shard_end, node.quantization)
|
|
if node.shard_start is None or node.shard_end is None or current_range == previous_ranges[node.node_id]:
|
|
continue
|
|
if previous_start is not None and previous_end is not None:
|
|
node.pending_directives.append(
|
|
_drop_directive(
|
|
node,
|
|
model,
|
|
previous_start,
|
|
previous_end,
|
|
previous_quantization or _node_quantization(node, preset),
|
|
)
|
|
)
|
|
node.pending_directives.append(
|
|
_load_directive(
|
|
node,
|
|
model,
|
|
node.shard_start,
|
|
node.shard_end,
|
|
node.quantization or _node_quantization(node, preset),
|
|
)
|
|
)
|
|
|
|
|
|
def _rebalance_all_locked(server: "_TrackerHTTPServer") -> None:
|
|
for model in list(server.model_presets):
|
|
_rebalance_model_locked(server, model)
|
|
|
|
|
|
def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -> str | None:
|
|
if contracts is None or not wallet_address:
|
|
return None
|
|
if contracts.registry.get_wallet(wallet_address).banned:
|
|
return "wallet is banned"
|
|
return None
|
|
|
|
|
|
class _TrackerHTTPServer(http.server.HTTPServer):
|
|
def __init__(
|
|
self,
|
|
addr: tuple,
|
|
handler,
|
|
registry: dict,
|
|
lock: threading.Lock,
|
|
heartbeat_timeout: float,
|
|
model_presets: dict,
|
|
contracts: Any | None,
|
|
minimum_stake: int,
|
|
) -> None:
|
|
super().__init__(addr, handler)
|
|
self.registry = registry
|
|
self.lock = lock
|
|
self.heartbeat_timeout = heartbeat_timeout
|
|
self.model_presets = model_presets
|
|
self.contracts = contracts
|
|
self.minimum_stake = minimum_stake
|
|
|
|
|
|
class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args): # noqa: suppress request logs in tests
|
|
pass
|
|
|
|
def _send_json(self, status: int, data: dict) -> None:
|
|
body = json.dumps(data).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _read_json_body(self) -> dict | None:
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
try:
|
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
except json.JSONDecodeError:
|
|
self._send_json(400, {"error": "invalid JSON body"})
|
|
return None
|
|
if not isinstance(body, dict):
|
|
self._send_json(400, {"error": "JSON body must be an object"})
|
|
return None
|
|
return body
|
|
|
|
def _purge_expired_nodes(self) -> None:
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
_purge_expired_nodes_locked(server)
|
|
|
|
def do_POST(self):
|
|
if self.path == "/v1/nodes/register":
|
|
self._handle_register()
|
|
return
|
|
parts = self.path.split("/")
|
|
# /v1/nodes/<node_id>/heartbeat -> ['', 'v1', 'nodes', '<id>', 'heartbeat']
|
|
if len(parts) == 5 and parts[1] == "v1" and parts[2] == "nodes" and parts[4] == "heartbeat":
|
|
self._handle_heartbeat(parts[3])
|
|
return
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if parsed.path == "/v1/route":
|
|
self._handle_route(parsed)
|
|
elif parsed.path == "/v1/routes":
|
|
self._handle_routes(parsed)
|
|
elif parsed.path == "/v1/nodes/assign":
|
|
self._handle_assign(parsed)
|
|
elif parsed.path == "/v1/network/assign":
|
|
self._handle_network_assign(parsed)
|
|
elif parsed.path == "/v1/models":
|
|
self._handle_models()
|
|
elif parsed.path.startswith("/v1/coverage/"):
|
|
model = urllib.parse.unquote(parsed.path.removeprefix("/v1/coverage/"))
|
|
self._handle_coverage(model)
|
|
elif parsed.path.startswith("/v1/tracker-nodes/"):
|
|
model = urllib.parse.unquote(parsed.path.removeprefix("/v1/tracker-nodes/"))
|
|
self._handle_tracker_nodes(model)
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def _handle_models(self):
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
created = int(time.time())
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
alive = list(server.registry.values())
|
|
if server.contracts is not None:
|
|
alive = [
|
|
node for node in alive
|
|
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
|
]
|
|
data = []
|
|
for name, preset in server.model_presets.items():
|
|
model_nodes = [node for node in alive if node.model == name]
|
|
if not model_nodes:
|
|
continue
|
|
required_start, required_end = _preset_layer_bounds(preset)
|
|
coverage = _coverage_percentage(
|
|
model_nodes,
|
|
required_start,
|
|
required_end,
|
|
)
|
|
data.append({
|
|
"id": name,
|
|
"object": "model",
|
|
"created": created,
|
|
"owned_by": "meshnet",
|
|
"shard_coverage_percentage": coverage,
|
|
})
|
|
self._send_json(200, {"object": "list", "data": data})
|
|
|
|
def _handle_coverage(self, model: str):
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
preset = server.model_presets.get(model)
|
|
if preset is None:
|
|
self._send_json(404, {"error": f"unknown model preset: {model!r}"})
|
|
return
|
|
required_start, required_end = _preset_layer_bounds(preset)
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
alive = [node for node in server.registry.values() if node.model == model]
|
|
if server.contracts is not None:
|
|
alive = [
|
|
node for node in alive
|
|
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
|
]
|
|
coverage = _coverage_map(alive, required_start, required_end)
|
|
self._send_json(200, {"model": model, "coverage": coverage})
|
|
|
|
def _handle_tracker_nodes(self, model: str):
|
|
"""Return nodes registered with tracker_mode=True whose shard starts at layer 0."""
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
preset = server.model_presets.get(model)
|
|
if preset is None:
|
|
self._send_json(404, {"error": f"unknown model preset: {model!r}"})
|
|
return
|
|
required_start, _ = _preset_layer_bounds(preset)
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
alive = [node for node in server.registry.values() if node.model == model]
|
|
if server.contracts is not None:
|
|
alive = [
|
|
node for node in alive
|
|
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
|
]
|
|
tracker_nodes = [
|
|
node for node in alive
|
|
if node.shard_start is not None
|
|
and node.shard_start == required_start
|
|
and node.tracker_mode
|
|
]
|
|
self._send_json(200, {
|
|
"model": model,
|
|
"tracker_nodes": [
|
|
{
|
|
"node_id": node.node_id,
|
|
"endpoint": node.endpoint,
|
|
"benchmark_tokens_per_sec": node.benchmark_tokens_per_sec,
|
|
}
|
|
for node in tracker_nodes
|
|
],
|
|
})
|
|
|
|
def _handle_register(self):
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
body = self._read_json_body()
|
|
if body is None:
|
|
return
|
|
|
|
endpoint = body.get("endpoint")
|
|
if not isinstance(endpoint, str) or not endpoint:
|
|
self._send_json(400, {"error": "endpoint is required"})
|
|
return
|
|
parsed_endpoint = urllib.parse.urlparse(endpoint)
|
|
if parsed_endpoint.scheme not in {"http", "https"} or not parsed_endpoint.netloc:
|
|
self._send_json(400, {"error": "endpoint must be an http(s) URL"})
|
|
return
|
|
|
|
shard_start: int | None
|
|
shard_end: int | None
|
|
explicit_shard = "shard_start" in body or "shard_end" in body
|
|
if explicit_shard:
|
|
try:
|
|
shard_start = int(body["shard_start"])
|
|
shard_end = int(body["shard_end"])
|
|
except (KeyError, TypeError, ValueError):
|
|
self._send_json(400, {"error": "shard_start and shard_end must be numeric"})
|
|
return
|
|
if shard_start < 0 or shard_end < 0 or shard_start > shard_end:
|
|
self._send_json(400, {"error": "shard range must be non-negative and ordered"})
|
|
return
|
|
else:
|
|
shard_start = None
|
|
shard_end = None
|
|
try:
|
|
score = float(body.get("score", 1.0))
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"error": "score must be numeric"})
|
|
return
|
|
|
|
hardware_profile = body.get("hardware_profile", {})
|
|
if not isinstance(hardware_profile, dict):
|
|
self._send_json(400, {"error": "hardware_profile must be an object"})
|
|
return
|
|
model = body.get("model")
|
|
if model is None:
|
|
model = "stub-model"
|
|
if not isinstance(model, str):
|
|
self._send_json(400, {"error": "model must be a string"})
|
|
return
|
|
shard_checksum = body.get("shard_checksum")
|
|
if shard_checksum is not None and not isinstance(shard_checksum, str):
|
|
self._send_json(400, {"error": "shard_checksum must be a string"})
|
|
return
|
|
try:
|
|
vram_bytes = int(body.get("vram_bytes", DEFAULT_VRAM_BYTES))
|
|
ram_bytes = int(body.get("ram_bytes", DEFAULT_RAM_BYTES))
|
|
benchmark_tokens_per_sec = float(
|
|
body.get("benchmark_tokens_per_sec", DEFAULT_BENCHMARK_TOKENS_PER_SEC)
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"error": "vram_bytes, ram_bytes, and benchmark_tokens_per_sec must be numeric"})
|
|
return
|
|
if vram_bytes < 0 or ram_bytes < 0 or benchmark_tokens_per_sec <= 0:
|
|
self._send_json(400, {"error": "capability values must be positive"})
|
|
return
|
|
quantizations_body = body.get("quantizations", DEFAULT_QUANTIZATIONS)
|
|
if not (
|
|
isinstance(quantizations_body, list)
|
|
and quantizations_body
|
|
and all(isinstance(item, str) and item for item in quantizations_body)
|
|
):
|
|
self._send_json(400, {"error": "quantizations must be a non-empty string array"})
|
|
return
|
|
quantizations = list(quantizations_body)
|
|
quantization = body.get("quantization")
|
|
if quantization is not None and not isinstance(quantization, str):
|
|
self._send_json(400, {"error": "quantization must be a string"})
|
|
return
|
|
wallet_address = body.get("wallet_address")
|
|
if wallet_address is not None and not isinstance(wallet_address, str):
|
|
self._send_json(400, {"error": "wallet_address must be a string"})
|
|
return
|
|
ban_error = _registration_ban_error(server.contracts, wallet_address)
|
|
if ban_error:
|
|
self._send_json(403, {"error": ban_error})
|
|
return
|
|
|
|
tracker_mode = bool(body.get("tracker_mode", False))
|
|
hf_repo = body.get("hf_repo")
|
|
if hf_repo is not None and not isinstance(hf_repo, str):
|
|
self._send_json(400, {"error": "hf_repo must be a string"})
|
|
return
|
|
num_layers_body = body.get("num_layers")
|
|
num_layers: int | None = None
|
|
if num_layers_body is not None:
|
|
try:
|
|
num_layers = int(num_layers_body)
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"error": "num_layers must be an integer"})
|
|
return
|
|
|
|
node_id = str(uuid.uuid4())
|
|
entry = _NodeEntry(
|
|
node_id=node_id,
|
|
endpoint=endpoint.rstrip("/"),
|
|
shard_start=shard_start,
|
|
shard_end=shard_end,
|
|
model=model,
|
|
shard_checksum=shard_checksum,
|
|
hardware_profile=hardware_profile,
|
|
wallet_address=wallet_address,
|
|
score=score,
|
|
vram_bytes=vram_bytes,
|
|
ram_bytes=ram_bytes,
|
|
quantizations=quantizations,
|
|
benchmark_tokens_per_sec=benchmark_tokens_per_sec,
|
|
quantization=quantization,
|
|
managed_assignment=not explicit_shard,
|
|
tracker_mode=tracker_mode,
|
|
hf_repo=hf_repo,
|
|
num_layers=num_layers,
|
|
)
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
server.registry[node_id] = entry
|
|
if entry.managed_assignment:
|
|
_rebalance_model_locked(server, model)
|
|
assignment_directive = entry.pending_directives[-1] if entry.pending_directives else None
|
|
if assignment_directive is not None:
|
|
entry.pending_directives.clear()
|
|
|
|
payload = {"node_id": node_id}
|
|
if assignment_directive is not None:
|
|
payload["directive"] = assignment_directive
|
|
self._send_json(200, payload)
|
|
|
|
def _handle_heartbeat(self, node_id: str):
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
entry = server.registry.get(node_id)
|
|
if entry is None:
|
|
self._send_json(404, {"error": "node not found"})
|
|
return
|
|
entry.last_heartbeat = time.monotonic()
|
|
_rebalance_model_locked(server, entry.model or "stub-model")
|
|
directives = list(entry.pending_directives)
|
|
entry.pending_directives.clear()
|
|
if directives:
|
|
self._send_json(200, {"directives": directives})
|
|
else:
|
|
self._send_json(200, {})
|
|
|
|
def _handle_assign(self, parsed: urllib.parse.ParseResult):
|
|
"""Return an optimal shard assignment for a node given its hardware profile.
|
|
|
|
Query params:
|
|
model — model preset name (default: first preset)
|
|
device — "cuda" | "cpu"
|
|
vram_mb — integer VRAM in MB (0 for CPU)
|
|
|
|
The greedy strategy: find the first gap in current layer coverage
|
|
and assign it. If no gap exists, assign the full model range so the
|
|
node provides redundant coverage.
|
|
"""
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
|
|
model_list = params.get("model")
|
|
if not model_list:
|
|
model = next(iter(server.model_presets), None)
|
|
if model is None:
|
|
self._send_json(503, {"error": "no model presets configured"})
|
|
return
|
|
else:
|
|
model = model_list[0]
|
|
|
|
preset = server.model_presets.get(model)
|
|
if preset is None:
|
|
self._send_json(404, {"error": f"unknown model preset: {model!r}"})
|
|
return
|
|
|
|
required_start, required_end = _preset_layer_bounds(preset)
|
|
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
alive = [node for node in server.registry.values() if node.model == model]
|
|
if server.contracts is not None:
|
|
alive = [
|
|
node for node in alive
|
|
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
|
]
|
|
|
|
device = params.get("device", ["cpu"])[0]
|
|
try:
|
|
vram_mb = int(params.get("vram_mb", ["0"])[0])
|
|
except ValueError:
|
|
vram_mb = 0
|
|
max_layers = required_end - required_start + 1
|
|
if device != "cuda" or vram_mb < 8192:
|
|
max_layers = min(max_layers, 16)
|
|
|
|
# Collect covered intervals sorted by start layer.
|
|
covered = sorted(
|
|
[
|
|
(n.shard_start, n.shard_end)
|
|
for n in alive
|
|
if n.shard_start is not None and n.shard_end is not None
|
|
],
|
|
key=lambda t: t[0],
|
|
)
|
|
|
|
# Walk from required_start to find the first uncovered layer.
|
|
gap_start = required_start
|
|
for s, e in covered:
|
|
if s <= gap_start:
|
|
gap_start = max(gap_start, e + 1)
|
|
else:
|
|
break # gap found before this node
|
|
|
|
if gap_start > required_end:
|
|
# Full coverage exists — assign the full range for redundancy.
|
|
shard_start = required_start
|
|
shard_end = min(required_end, shard_start + max_layers - 1)
|
|
else:
|
|
shard_start = gap_start
|
|
shard_end = min(required_end, shard_start + max_layers - 1)
|
|
|
|
peers = [
|
|
{"endpoint": node.endpoint, "checksum": node.shard_checksum}
|
|
for node in alive
|
|
if node.model == model
|
|
and node.shard_start == shard_start
|
|
and node.shard_end == shard_end
|
|
and node.shard_checksum
|
|
]
|
|
|
|
self._send_json(200, {
|
|
"shard_start": shard_start,
|
|
"shard_end": shard_end,
|
|
"model": model,
|
|
"model_layers_end": required_end,
|
|
"peers": peers,
|
|
**({"hf_repo": preset["hf_repo"]} if "hf_repo" in preset else {}),
|
|
})
|
|
|
|
def _handle_network_assign(self, parsed: urllib.parse.ParseResult):
|
|
"""Assign a new node to fill the biggest uncovered shard gap across HF-model nodes.
|
|
|
|
Query params:
|
|
vram_mb — integer VRAM in MB (0 = CPU-only node)
|
|
device — "cuda" | "cpu"
|
|
|
|
Returns:
|
|
{hf_repo, shard_start, shard_end, num_layers}
|
|
"""
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
try:
|
|
vram_mb = int(params.get("vram_mb", ["0"])[0])
|
|
except ValueError:
|
|
vram_mb = 0
|
|
device = params.get("device", ["cpu"])[0]
|
|
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
all_nodes = list(server.registry.values())
|
|
|
|
# Collect only nodes that registered a real HF model (have hf_repo + shard bounds).
|
|
hf_nodes = [
|
|
n for n in all_nodes
|
|
if n.hf_repo
|
|
and n.shard_start is not None
|
|
and n.shard_end is not None
|
|
and n.num_layers is not None
|
|
]
|
|
|
|
if not hf_nodes:
|
|
self._send_json(503, {"error": "no HF-model nodes registered; cannot assign shards"})
|
|
return
|
|
|
|
# Group by hf_repo; pick the one with the largest total_layers and biggest gap.
|
|
from collections import defaultdict
|
|
repo_groups: dict = defaultdict(list)
|
|
repo_layers: dict = {}
|
|
for n in hf_nodes:
|
|
repo_groups[n.hf_repo].append(n)
|
|
# Use the largest num_layers seen for this repo.
|
|
if n.hf_repo not in repo_layers or n.num_layers > repo_layers[n.hf_repo]:
|
|
repo_layers[n.hf_repo] = n.num_layers
|
|
|
|
# Pick the repo where the gap is largest (most work to do).
|
|
best_repo = None
|
|
best_gap_size = -1
|
|
best_gap_start = 0
|
|
best_num_layers = 0
|
|
|
|
for repo, nodes in repo_groups.items():
|
|
total = repo_layers[repo]
|
|
covered = sorted(
|
|
[(n.shard_start, n.shard_end) for n in nodes],
|
|
key=lambda t: t[0],
|
|
)
|
|
# Walk from 0 to find first uncovered layer.
|
|
gap_start = 0
|
|
for s, e in covered:
|
|
if s <= gap_start:
|
|
gap_start = max(gap_start, e + 1)
|
|
else:
|
|
break
|
|
gap_size = max(0, (total - 1) - gap_start + 1) # layers remaining uncovered
|
|
if gap_size > best_gap_size:
|
|
best_gap_size = gap_size
|
|
best_gap_start = gap_start
|
|
best_repo = repo
|
|
best_num_layers = total
|
|
|
|
if best_repo is None or best_gap_size <= 0:
|
|
# All shards are covered — still assign to the model with most nodes for redundancy.
|
|
best_repo = max(repo_groups, key=lambda r: len(repo_groups[r]))
|
|
best_gap_start = 0
|
|
best_num_layers = repo_layers[best_repo]
|
|
|
|
# Capacity: CPU nodes get at most half the layers; CUDA nodes based on VRAM.
|
|
total_l = best_num_layers
|
|
if device == "cuda" and vram_mb >= 8192:
|
|
max_layers = total_l
|
|
else:
|
|
max_layers = max(1, total_l // 2)
|
|
|
|
shard_start = best_gap_start
|
|
shard_end = min(total_l - 1, shard_start + max_layers - 1)
|
|
|
|
self._send_json(200, {
|
|
"hf_repo": best_repo,
|
|
"shard_start": shard_start,
|
|
"shard_end": shard_end,
|
|
"num_layers": total_l,
|
|
})
|
|
|
|
def _handle_route(self, parsed: urllib.parse.ParseResult):
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
model_list = params.get("model")
|
|
if not model_list:
|
|
self._send_json(400, {"error": "missing 'model' query parameter"})
|
|
return
|
|
|
|
model = model_list[0]
|
|
preset = server.model_presets.get(model)
|
|
if preset is None:
|
|
self._send_json(404, {"error": f"unknown model preset: {model!r}"})
|
|
return
|
|
|
|
required_start, required_end = _preset_layer_bounds(preset)
|
|
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
alive = [node for node in server.registry.values() if node.model == model]
|
|
if server.contracts is not None:
|
|
alive = [
|
|
node for node in alive
|
|
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
|
]
|
|
|
|
route, error = _select_route(alive, required_start, required_end)
|
|
if error:
|
|
self._send_json(503, {"error": error})
|
|
return
|
|
|
|
self._send_json(200, {
|
|
"route": [e.endpoint for e in route],
|
|
"nodes": [
|
|
{
|
|
"endpoint": e.endpoint,
|
|
"wallet_address": e.wallet_address,
|
|
"shard_start": e.shard_start,
|
|
"shard_end": e.shard_end,
|
|
"model": e.model,
|
|
"shard_checksum": e.shard_checksum,
|
|
"score": e.score,
|
|
}
|
|
for e in route
|
|
],
|
|
})
|
|
|
|
def _handle_routes(self, parsed: urllib.parse.ParseResult):
|
|
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
model_list = params.get("model")
|
|
if not model_list:
|
|
self._send_json(400, {"error": "missing 'model' query parameter"})
|
|
return
|
|
|
|
try:
|
|
redundancy = int(params.get("redundancy", ["1"])[0])
|
|
except ValueError:
|
|
self._send_json(400, {"error": "redundancy must be an integer"})
|
|
return
|
|
if redundancy < 1:
|
|
self._send_json(400, {"error": "redundancy must be at least 1"})
|
|
return
|
|
|
|
model = model_list[0]
|
|
preset = server.model_presets.get(model)
|
|
if preset is None:
|
|
self._send_json(404, {"error": f"unknown model preset: {model!r}"})
|
|
return
|
|
|
|
required_start, required_end = _preset_layer_bounds(preset)
|
|
|
|
with server.lock:
|
|
self._purge_expired_nodes()
|
|
candidates = [node for node in server.registry.values() if node.model == model]
|
|
if server.contracts is not None:
|
|
candidates = [
|
|
node for node in candidates
|
|
if not node.wallet_address or not server.contracts.registry.get_wallet(node.wallet_address).banned
|
|
]
|
|
|
|
routes = []
|
|
remaining = list(candidates)
|
|
for _ in range(redundancy):
|
|
route, error = _select_route(remaining, required_start, required_end)
|
|
if error:
|
|
self._send_json(503, {"error": error})
|
|
return
|
|
route_endpoints = {node.endpoint for node in route}
|
|
remaining = [node for node in remaining if node.endpoint not in route_endpoints]
|
|
routes.append({
|
|
"route": [e.endpoint for e in route],
|
|
"nodes": [
|
|
{
|
|
"endpoint": e.endpoint,
|
|
"wallet_address": e.wallet_address,
|
|
"shard_start": e.shard_start,
|
|
"shard_end": e.shard_end,
|
|
"model": e.model,
|
|
"shard_checksum": e.shard_checksum,
|
|
"score": e.score,
|
|
}
|
|
for e in route
|
|
],
|
|
})
|
|
|
|
self._send_json(200, {"routes": routes})
|
|
|
|
|
|
class TrackerServer:
|
|
"""HTTP tracker that manages node registration and resolves inference routes.
|
|
|
|
Nodes register via POST /v1/nodes/register and keep themselves alive with
|
|
POST /v1/nodes/<id>/heartbeat. The gateway queries GET /v1/route?model=<name>
|
|
to obtain an ordered list of node endpoints whose shards cover all layers.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str = "127.0.0.1",
|
|
port: int = 0,
|
|
heartbeat_timeout: float = 30.0,
|
|
rebalance_interval: float = 30.0,
|
|
model_presets: dict | None = None,
|
|
contracts: Any | None = None,
|
|
minimum_stake: int = 0,
|
|
) -> None:
|
|
self._host = host
|
|
self._requested_port = port
|
|
self._heartbeat_timeout = heartbeat_timeout
|
|
self._rebalance_interval = rebalance_interval
|
|
self._model_presets: dict = (
|
|
model_presets if model_presets is not None else dict(DEFAULT_MODEL_PRESETS)
|
|
)
|
|
self._contracts = contracts
|
|
self._minimum_stake = minimum_stake
|
|
self._registry: dict[str, _NodeEntry] = {}
|
|
self._lock = threading.Lock()
|
|
self._server: _TrackerHTTPServer | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._rebalance_stop = threading.Event()
|
|
self._rebalance_thread: threading.Thread | None = None
|
|
self.port: int | None = None
|
|
|
|
def start(self) -> int:
|
|
if self._server is not None:
|
|
raise RuntimeError("TrackerServer is already running")
|
|
self._server = _TrackerHTTPServer(
|
|
(self._host, self._requested_port),
|
|
_TrackerHandler,
|
|
self._registry,
|
|
self._lock,
|
|
self._heartbeat_timeout,
|
|
self._model_presets,
|
|
self._contracts,
|
|
self._minimum_stake,
|
|
)
|
|
self.port = self._server.server_address[1]
|
|
self._rebalance_stop.clear()
|
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
self._thread.start()
|
|
self._rebalance_thread = threading.Thread(target=self._rebalance_loop, daemon=True)
|
|
self._rebalance_thread.start()
|
|
return self.port
|
|
|
|
def _rebalance_loop(self) -> None:
|
|
while not self._rebalance_stop.wait(self._rebalance_interval):
|
|
server = self._server
|
|
if server is None:
|
|
return
|
|
with self._lock:
|
|
_purge_expired_nodes_locked(server)
|
|
_rebalance_all_locked(server)
|
|
|
|
def stop(self) -> None:
|
|
if self._server is None:
|
|
return
|
|
self._rebalance_stop.set()
|
|
self._server.shutdown()
|
|
self._server.server_close()
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=1)
|
|
if self._rebalance_thread is not None:
|
|
self._rebalance_thread.join(timeout=1)
|
|
self._server = None
|
|
self._thread = None
|
|
self._rebalance_thread = None
|
|
self.port = None
|