new tasks, devnet topup, cli, new model support
This commit is contained in:
@@ -159,6 +159,30 @@ CURATED_MODELS: list[ModelPreset] = [
|
||||
]
|
||||
|
||||
|
||||
def layers_from_config(cfg) -> int | None:
|
||||
"""Extract the transformer layer count from a HuggingFace config object.
|
||||
|
||||
Composite configs (vision-language, some MoE) nest the decoder settings in
|
||||
``text_config`` — e.g. Qwen3.5-MoE has no top-level ``num_hidden_layers``.
|
||||
"""
|
||||
candidates = [cfg]
|
||||
get_text_config = getattr(cfg, "get_text_config", None)
|
||||
if callable(get_text_config):
|
||||
try:
|
||||
candidates.append(get_text_config())
|
||||
except Exception:
|
||||
pass
|
||||
nested = getattr(cfg, "text_config", None)
|
||||
if nested is not None:
|
||||
candidates.append(nested)
|
||||
for candidate in candidates:
|
||||
for attr in ("num_hidden_layers", "num_layers", "n_layer", "n_layers"):
|
||||
value = getattr(candidate, attr, None)
|
||||
if value is not None:
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def detect_num_layers(hf_repo: str) -> int | None:
|
||||
"""Return num_hidden_layers from HuggingFace config.json (downloads ~1 KB only)."""
|
||||
# Check curated list first (no network call)
|
||||
@@ -168,7 +192,7 @@ def detect_num_layers(hf_repo: str) -> int | None:
|
||||
try:
|
||||
from transformers import AutoConfig # type: ignore[import]
|
||||
cfg = AutoConfig.from_pretrained(hf_repo)
|
||||
return int(cfg.num_hidden_layers)
|
||||
return layers_from_config(cfg)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -195,6 +219,8 @@ def model_metadata_for(
|
||||
hf_repo,
|
||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
||||
)
|
||||
# Composite configs (VLM/MoE) nest decoder fields in text_config.
|
||||
text_cfg = getattr(cfg, "text_config", None) or cfg
|
||||
for attr, key in (
|
||||
("model_type", "architecture"),
|
||||
("num_hidden_layers", "num_layers"),
|
||||
@@ -204,8 +230,14 @@ def model_metadata_for(
|
||||
("max_position_embeddings", "context_length"),
|
||||
):
|
||||
value = getattr(cfg, attr, None)
|
||||
if value is None:
|
||||
value = getattr(text_cfg, attr, None)
|
||||
if value is not None:
|
||||
metadata[key] = value
|
||||
if "num_layers" not in metadata:
|
||||
layers = layers_from_config(cfg)
|
||||
if layers is not None:
|
||||
metadata["num_layers"] = layers
|
||||
except Exception:
|
||||
pass
|
||||
return metadata
|
||||
|
||||
@@ -5,14 +5,18 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CONCURRENCY = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelayBridgeInfo:
|
||||
@@ -32,8 +36,23 @@ def _make_envelope(topic: str, payload: dict, peer_id: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _max_concurrency_from_env() -> int:
|
||||
try:
|
||||
value = int(os.environ.get("MESHNET_RELAY_CONCURRENCY", DEFAULT_MAX_CONCURRENCY))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_CONCURRENCY
|
||||
return max(1, value)
|
||||
|
||||
|
||||
class RelayHttpBridge:
|
||||
"""Connect outbound to a relay and proxy relay HTTP requests to localhost."""
|
||||
"""Connect outbound to a relay and proxy relay HTTP requests to localhost.
|
||||
|
||||
Requests are dispatched on a bounded worker pool (US-037) so a node that is
|
||||
the head of one route can still serve per-token ``/forward`` hops of another.
|
||||
Streaming responses (``text/event-stream``) are forwarded as multiple chunk
|
||||
frames sharing one ``request_id`` (US-036); everything else stays a single
|
||||
frame for backward compatibility.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -42,15 +61,20 @@ class RelayHttpBridge:
|
||||
local_base_url: str,
|
||||
advertised_addr: str,
|
||||
reconnect_interval: float = 3.0,
|
||||
max_concurrency: int | None = None,
|
||||
) -> 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.max_concurrency = max(1, max_concurrency) if max_concurrency else _max_concurrency_from_env()
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._connected = threading.Event()
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._send_lock = threading.Lock()
|
||||
self._ws = None
|
||||
|
||||
@property
|
||||
def relay_addr(self) -> str:
|
||||
@@ -61,6 +85,9 @@ class RelayHttpBridge:
|
||||
|
||||
def start(self) -> RelayBridgeInfo:
|
||||
self._running = True
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=self.max_concurrency, thread_name_prefix="relay-http-worker"
|
||||
)
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
|
||||
self._thread.start()
|
||||
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
|
||||
@@ -72,6 +99,8 @@ class RelayHttpBridge:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
if self._executor is not None:
|
||||
self._executor.shutdown(wait=False)
|
||||
|
||||
def _run(self) -> None:
|
||||
import websockets.sync.client as wsc # type: ignore[import]
|
||||
@@ -79,6 +108,7 @@ class RelayHttpBridge:
|
||||
while self._running:
|
||||
try:
|
||||
with wsc.connect(self.relay_url, open_timeout=5) as ws:
|
||||
self._ws = ws
|
||||
self._connected.set()
|
||||
ws.send(json.dumps(_make_envelope(
|
||||
"peer-register",
|
||||
@@ -99,19 +129,36 @@ class RelayHttpBridge:
|
||||
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,
|
||||
)))
|
||||
if self._executor is None:
|
||||
break
|
||||
self._executor.submit(self._process_request, payload)
|
||||
except Exception as exc:
|
||||
self._connected.clear()
|
||||
self._ws = None
|
||||
if self._running:
|
||||
log.debug("relay bridge disconnected: %s", exc)
|
||||
time.sleep(self.reconnect_interval)
|
||||
self._ws = None
|
||||
|
||||
def _handle_request(self, payload: dict) -> dict:
|
||||
def _send_response_frame(self, payload: dict) -> bool:
|
||||
"""Send one relay-http-response frame; False if the socket is gone.
|
||||
|
||||
The lock is held per frame so concurrent workers interleave whole
|
||||
frames on the shared WebSocket, never torn ones.
|
||||
"""
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return False
|
||||
message = json.dumps(_make_envelope("relay-http-response", payload, self.peer_id))
|
||||
try:
|
||||
with self._send_lock:
|
||||
ws.send(message)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.debug("relay bridge send failed (request orphaned): %s", exc)
|
||||
return False
|
||||
|
||||
def _process_request(self, payload: dict) -> None:
|
||||
request_id = str(payload.get("request_id") or "")
|
||||
method = str(payload.get("method") or "POST").upper()
|
||||
path = str(payload.get("path") or "/")
|
||||
@@ -130,10 +177,14 @@ class RelayHttpBridge:
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
||||
resp_bytes = resp.read()
|
||||
resp_headers = dict(resp.headers)
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
if "text/event-stream" in content_type:
|
||||
self._stream_response(request_id, resp, resp_headers)
|
||||
return
|
||||
resp_bytes = resp.read()
|
||||
# Forward all X-Meshnet-* headers so the caller can reconstruct the activation.
|
||||
is_binary = "octet-stream" in resp.headers.get("Content-Type", "")
|
||||
is_binary = "octet-stream" in content_type
|
||||
result: dict = {
|
||||
"request_id": request_id,
|
||||
"status": resp.status,
|
||||
@@ -143,21 +194,66 @@ class RelayHttpBridge:
|
||||
result["body_base64"] = base64.b64encode(resp_bytes).decode()
|
||||
else:
|
||||
result["body"] = resp_bytes.decode(errors="replace")
|
||||
return result
|
||||
self._send_response_frame(result)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return {
|
||||
self._send_response_frame({
|
||||
"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 {
|
||||
self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"status": 503,
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
|
||||
}
|
||||
})
|
||||
|
||||
def _stream_response(self, request_id: str, resp, resp_headers: dict) -> None:
|
||||
"""Forward an SSE response as chunk frames, one per complete SSE event.
|
||||
|
||||
Frame order: header frame (status + headers), chunk frames, done frame.
|
||||
A receiver that sees no ``stream`` key treats the frame as a complete
|
||||
legacy response, so non-streaming peers are unaffected.
|
||||
"""
|
||||
sent = self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"status": resp.status,
|
||||
"headers": resp_headers,
|
||||
"stream": True,
|
||||
"done": False,
|
||||
})
|
||||
if not sent:
|
||||
return
|
||||
event_lines: list[str] = []
|
||||
for raw_line in resp:
|
||||
line = raw_line.decode(errors="replace")
|
||||
event_lines.append(line)
|
||||
if line.strip():
|
||||
continue
|
||||
# Blank line terminates one SSE event — flush it as a frame.
|
||||
if not self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"stream": True,
|
||||
"chunk": "".join(event_lines),
|
||||
"done": False,
|
||||
}):
|
||||
return
|
||||
event_lines = []
|
||||
if event_lines:
|
||||
if not self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"stream": True,
|
||||
"chunk": "".join(event_lines),
|
||||
"done": False,
|
||||
}):
|
||||
return
|
||||
self._send_response_frame({
|
||||
"request_id": request_id,
|
||||
"stream": True,
|
||||
"done": True,
|
||||
})
|
||||
|
||||
|
||||
def peer_id_from_wallet(wallet_address: str) -> str:
|
||||
|
||||
@@ -734,11 +734,20 @@ def _detect_num_layers(model_id: str, cache_dir: Path | None = None) -> int | No
|
||||
"""Fetch num_hidden_layers from HuggingFace model config (downloads ~1 KB config.json only)."""
|
||||
try:
|
||||
from transformers import AutoConfig # type: ignore[import]
|
||||
|
||||
from .model_catalog import layers_from_config
|
||||
|
||||
cfg = AutoConfig.from_pretrained(
|
||||
model_id,
|
||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
||||
)
|
||||
return int(cfg.num_hidden_layers)
|
||||
layers = layers_from_config(cfg)
|
||||
if layers is None:
|
||||
print(
|
||||
f" Warning: no layer count in {model_id} config "
|
||||
"(checked top level and text_config)", flush=True,
|
||||
)
|
||||
return layers
|
||||
except Exception as exc:
|
||||
print(f" Warning: could not read model config from HF: {exc}", flush=True)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user