Unify --model and --model-id so catalog names use the tracker path, and allow --shard-start/--shard-end with --model instead of requiring --model-id. Co-authored-by: Cursor <cursoragent@cursor.com>
386 lines
16 KiB
Python
386 lines
16 KiB
Python
"""meshnet-node CLI entry point — mining-style UX."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import socket
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
|
|
def _load_env_file(path: Path) -> None:
|
|
"""Load simple KEY=VALUE pairs from an env file without overriding env vars."""
|
|
if not path.exists():
|
|
return
|
|
try:
|
|
lines = path.read_text().splitlines()
|
|
except OSError:
|
|
return
|
|
for line in lines:
|
|
text = line.strip()
|
|
if not text or text.startswith("#"):
|
|
continue
|
|
if text.startswith("export "):
|
|
text = text[len("export "):].strip()
|
|
if "=" not in text:
|
|
continue
|
|
key, value = text.split("=", 1)
|
|
key = key.strip()
|
|
if not key or key in os.environ:
|
|
continue
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
value = value[1:-1]
|
|
os.environ[key] = value
|
|
|
|
|
|
def _load_env_defaults() -> None:
|
|
"""Load local and user-level node env defaults before config defaults are imported."""
|
|
_load_env_file(Path.cwd() / ".env")
|
|
_load_env_file(Path.home() / ".config" / "meshnet" / "secrets.env")
|
|
|
|
|
|
def _run_node(cfg: dict) -> None:
|
|
"""Start the node and hand off to the live dashboard. Blocks until Ctrl-C."""
|
|
from .startup import run_startup
|
|
from .dashboard import run_dashboard
|
|
|
|
start_time = time.monotonic()
|
|
try:
|
|
node = run_startup(
|
|
tracker_url=cfg["tracker_url"],
|
|
port=cfg.get("port", 7000),
|
|
model=cfg.get("model_name") or None,
|
|
model_id=cfg.get("model_hf_repo") or None,
|
|
shard_start=cfg.get("shard_start"),
|
|
shard_end=cfg.get("shard_end"),
|
|
quantization=cfg.get("quantization", "auto").replace("bf16", "bfloat16"),
|
|
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,
|
|
host=cfg.get("host", "0.0.0.0"),
|
|
advertise_host=cfg.get("advertise_host"),
|
|
route_timeout=float(cfg.get("route_timeout", 30.0)),
|
|
vram_mb_override=cfg.get("vram_mb_override"),
|
|
max_loaded_shards=int(cfg.get("max_loaded_shards", 1)),
|
|
debug=bool(cfg.get("debug", False)),
|
|
tracker_source_disabled=bool(cfg.get("tracker_source_disabled", False)),
|
|
torch_threads=cfg.get("torch_threads"),
|
|
torch_interop_threads=cfg.get("torch_interop_threads"),
|
|
)
|
|
except Exception as exc:
|
|
print(f"\nERROR: {exc}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
run_dashboard(node, cfg, start_time)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
node.stop()
|
|
req = getattr(node, "chat_completion_count", 0)
|
|
elapsed = time.monotonic() - start_time
|
|
h, rem = divmod(int(elapsed), 3600)
|
|
m, s = divmod(rem, 60)
|
|
print(
|
|
f"\nmeshnet-node stopped. "
|
|
f"Served {req} requests in {h:02d}:{m:02d}:{s:02d}.",
|
|
flush=True,
|
|
)
|
|
|
|
|
|
def _resolve_model_flags(
|
|
model: str | None,
|
|
model_id: str | None,
|
|
) -> tuple[str | None, str | None]:
|
|
"""Return (model_name, hf_repo_or_none) from --model / --model-id flags."""
|
|
explicit = model_id or model
|
|
if not explicit:
|
|
return None, None
|
|
if "/" in explicit:
|
|
return explicit.split("/")[-1], explicit
|
|
return explicit, 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:
|
|
"""No subcommand: wizard if no config, else start with saved config."""
|
|
from .config import load_config, save_config, merge_cli_overrides
|
|
from .wizard import run_wizard
|
|
|
|
cfg = load_config()
|
|
if cfg is None or args.reset_config:
|
|
if args.reset_config and cfg is not None:
|
|
print("Resetting config — re-running setup wizard.\n")
|
|
try:
|
|
cfg = run_wizard()
|
|
except KeyboardInterrupt:
|
|
print("\nSetup cancelled.")
|
|
return 1
|
|
save_config(cfg)
|
|
print(f"\nConfig saved to ~/.config/meshnet/config.json\n")
|
|
|
|
# Apply CLI overrides on top of saved config
|
|
overrides: dict = {}
|
|
model_name, hf_repo = _resolve_model_flags(args.model, getattr(args, "model_id", None))
|
|
if model_name is not None:
|
|
overrides["model_name"] = model_name
|
|
overrides["model_hf_repo"] = hf_repo or ""
|
|
if args.quantization:
|
|
overrides["quantization"] = args.quantization
|
|
if args.download_dir:
|
|
overrides["download_dir"] = args.download_dir
|
|
if args.tracker:
|
|
overrides["tracker_url"] = args.tracker
|
|
if args.wallet:
|
|
overrides["wallet_path"] = args.wallet
|
|
if args.shard_start is not None:
|
|
overrides["shard_start"] = args.shard_start
|
|
if args.shard_end is not None:
|
|
overrides["shard_end"] = args.shard_end
|
|
if args.port is not None:
|
|
overrides["port"] = args.port
|
|
if args.host:
|
|
overrides["host"] = args.host
|
|
if args.advertise_host:
|
|
overrides["advertise_host"] = args.advertise_host
|
|
if args.route_timeout != 30.0:
|
|
overrides["route_timeout"] = args.route_timeout
|
|
if getattr(args, "memory", None) is not None:
|
|
overrides["vram_mb_override"] = args.memory
|
|
if getattr(args, "max_shards", None) is not None:
|
|
overrides["max_loaded_shards"] = args.max_shards
|
|
if args.debug:
|
|
overrides["debug"] = True
|
|
if getattr(args, "tracker_source_disabled", False):
|
|
overrides["tracker_source_disabled"] = True
|
|
if getattr(args, "torch_threads", None) is not None:
|
|
overrides["torch_threads"] = args.torch_threads
|
|
if getattr(args, "torch_interop_threads", None) is not None:
|
|
overrides["torch_interop_threads"] = args.torch_interop_threads
|
|
|
|
if overrides:
|
|
cfg = merge_cli_overrides(cfg, **overrides)
|
|
|
|
_run_node(cfg)
|
|
return 0
|
|
|
|
|
|
def _cmd_models(args) -> int:
|
|
"""List curated models (with optional HF Hub browse)."""
|
|
from .wizard import print_models_table, _browse_hf_interactive
|
|
|
|
if args.browse:
|
|
from .model_catalog import browse_hf_hub
|
|
print("Fetching HuggingFace Hub top models...\n")
|
|
try:
|
|
models = browse_hf_hub(top_n=20)
|
|
print(f"{'#':<4} {'Repo':<60} {'Downloads':>12}")
|
|
print(f"{'─'*4} {'─'*60} {'─'*12}")
|
|
for i, m in enumerate(models, 1):
|
|
dl = m["downloads"]
|
|
dl_str = (
|
|
f"{dl/1e6:.1f}M" if dl >= 1_000_000
|
|
else f"{dl/1e3:.0f}k" if dl >= 1000
|
|
else str(dl)
|
|
)
|
|
print(f"{i:<4} {m['repo']:<60} {dl_str:>12}")
|
|
except RuntimeError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
else:
|
|
print_models_table()
|
|
return 0
|
|
|
|
|
|
def _cmd_config(args) -> int:
|
|
"""Print current config."""
|
|
import json
|
|
from .config import load_config, config_path
|
|
|
|
cfg = load_config()
|
|
if cfg is None:
|
|
print("No config file found. Run `meshnet-node` to start setup.")
|
|
return 1
|
|
print(f"Config: {config_path()}")
|
|
print(json.dumps(cfg, indent=2))
|
|
return 0
|
|
|
|
|
|
def _cmd_start(args) -> int:
|
|
"""Legacy `start` subcommand — preserves backward compatibility with existing tests."""
|
|
from .config import DEFAULTS
|
|
|
|
# Build a transient config from flags (don't write to disk)
|
|
cfg = dict(DEFAULTS)
|
|
if args.tracker:
|
|
cfg["tracker_url"] = args.tracker
|
|
cfg["port"] = args.port if args.port is not None else _first_available_port(args.host)
|
|
model_name, hf_repo = _resolve_model_flags(
|
|
args.model or cfg.get("model_hf_repo") or cfg.get("model_name") or None,
|
|
args.model_id,
|
|
)
|
|
if model_name is not None:
|
|
cfg["model_name"] = model_name
|
|
cfg["model_hf_repo"] = hf_repo or ""
|
|
cfg["quantization"] = args.quantization
|
|
cfg["host"] = args.host
|
|
if args.shard_start is not None:
|
|
cfg["shard_start"] = args.shard_start
|
|
if args.shard_end is not None:
|
|
cfg["shard_end"] = args.shard_end
|
|
if args.wallet:
|
|
cfg["wallet_path"] = args.wallet
|
|
if args.download_dir:
|
|
cfg["download_dir"] = args.download_dir
|
|
|
|
# Legacy start: just run without the dashboard (keep original blocking loop)
|
|
from .startup import run_startup
|
|
|
|
try:
|
|
node = run_startup(
|
|
tracker_url=cfg["tracker_url"],
|
|
port=cfg["port"],
|
|
model=cfg["model_name"],
|
|
model_id=cfg.get("model_hf_repo") or None,
|
|
shard_start=cfg.get("shard_start"),
|
|
shard_end=cfg.get("shard_end"),
|
|
quantization=cfg["quantization"].replace("bf16", "bfloat16"),
|
|
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,
|
|
host=cfg["host"],
|
|
advertise_host=getattr(args, "advertise_host", None),
|
|
route_timeout=getattr(args, "route_timeout", 30.0),
|
|
vram_mb_override=getattr(args, "memory", None),
|
|
max_loaded_shards=getattr(args, "max_shards", 1),
|
|
debug=getattr(args, "debug", False),
|
|
tracker_source_disabled=getattr(args, "tracker_source_disabled", False),
|
|
torch_threads=getattr(args, "torch_threads", None),
|
|
torch_interop_threads=getattr(args, "torch_interop_threads", None),
|
|
)
|
|
except Exception as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
while True:
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
node.stop()
|
|
return 0
|
|
|
|
|
|
def main() -> None:
|
|
_load_env_defaults()
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="meshnet-node",
|
|
description="Distributed AI Inference — Node Client",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"Run with no arguments to start the setup wizard.\n"
|
|
"After first setup, `meshnet-node` starts using your saved config.\n\n"
|
|
"Subcommands:\n"
|
|
" models List supported models\n"
|
|
" models --browse Browse HuggingFace Hub\n"
|
|
" config Show current config\n"
|
|
),
|
|
)
|
|
|
|
# Flags that apply to the no-subcommand (default) path
|
|
parser.add_argument("--model", metavar="MODEL", help="Model name or HuggingFace repo ID to serve")
|
|
parser.add_argument("--model-id", metavar="MODEL", help="Alias for --model (catalog name or HuggingFace repo)")
|
|
parser.add_argument("--quantization", "-q", choices=["bf16", "int8", "nf4", "bfloat16"],
|
|
help="Quantization level")
|
|
parser.add_argument("--download-dir", metavar="PATH", help="Model download directory")
|
|
parser.add_argument("--tracker", metavar="URL", help="Tracker URL")
|
|
parser.add_argument("--tracker-source-disabled", action="store_true",
|
|
help="Skip tracker/peer model-file sources and download from HuggingFace directly")
|
|
parser.add_argument("--wallet", metavar="PATH", help="Wallet file path")
|
|
parser.add_argument("--shard-start", type=int, metavar="N", help="Pin shard start 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("--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,
|
|
help="Seconds to wait for tracker route lookup (default 30)")
|
|
parser.add_argument("--memory", type=int, metavar="MB", default=None,
|
|
help="Override autodetected VRAM/RAM budget in MB used for shard assignment")
|
|
parser.add_argument("--max-shards", type=int, metavar="N", default=None,
|
|
help="Maximum shard slots this node advertises to the tracker (default 1)")
|
|
parser.add_argument("--torch-threads", type=int, metavar="N",
|
|
help="Set PyTorch intra-op CPU worker threads")
|
|
parser.add_argument("--torch-interop-threads", type=int, metavar="N",
|
|
help="Set PyTorch inter-op CPU worker threads")
|
|
parser.add_argument("--debug", action="store_true", help="Enable verbose node debug logging")
|
|
parser.add_argument("--no-tui", action="store_true", help="Plain-text output (no rich dashboard)")
|
|
parser.add_argument("--compact", action="store_true", help="Single-line status output")
|
|
parser.add_argument("--reset-config", action="store_true", help="Re-run wizard even if config exists")
|
|
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
# models subcommand
|
|
models_cmd = subparsers.add_parser("models", help="List supported models")
|
|
models_cmd.add_argument("--browse", action="store_true", help="Browse HuggingFace Hub top-20")
|
|
|
|
# config subcommand
|
|
subparsers.add_parser("config", help="Show current saved config")
|
|
|
|
# start subcommand (legacy / backward-compat)
|
|
start_cmd = subparsers.add_parser("start", help="Start node (legacy flags)")
|
|
start_cmd.add_argument("--tracker")
|
|
start_cmd.add_argument("--port", type=int)
|
|
start_cmd.add_argument("--model", help="Model name or HuggingFace repo ID")
|
|
start_cmd.add_argument("--model-id", help="Alias for --model (catalog name or HuggingFace repo)")
|
|
start_cmd.add_argument("--shard-start", type=int)
|
|
start_cmd.add_argument("--shard-end", type=int)
|
|
start_cmd.add_argument("--quantization", choices=["auto", "bfloat16", "int8", "nf4", "bf16"], default="auto")
|
|
start_cmd.add_argument("--host", default="0.0.0.0")
|
|
start_cmd.add_argument("--advertise-host")
|
|
start_cmd.add_argument("--tracker-mode", action="store_true")
|
|
start_cmd.add_argument("--tracker-url", default=None)
|
|
start_cmd.add_argument("--wallet")
|
|
start_cmd.add_argument("--download-dir")
|
|
start_cmd.add_argument("--route-timeout", type=float, default=30.0,
|
|
help="Seconds to wait for tracker route lookup (default 30)")
|
|
start_cmd.add_argument("--memory", type=int, default=None, metavar="MB",
|
|
help="Override autodetected VRAM/RAM budget in MB used for shard assignment")
|
|
start_cmd.add_argument("--max-shards", type=int, default=1, metavar="N",
|
|
help="Maximum shard slots this node advertises to the tracker (default 1)")
|
|
start_cmd.add_argument("--torch-threads", type=int, metavar="N",
|
|
help="Set PyTorch intra-op CPU worker threads")
|
|
start_cmd.add_argument("--torch-interop-threads", type=int, metavar="N",
|
|
help="Set PyTorch inter-op CPU worker threads")
|
|
start_cmd.add_argument("--debug", action="store_true", help="Enable verbose node debug logging")
|
|
start_cmd.add_argument("--tracker-source-disabled", action="store_true",
|
|
help="Skip tracker/peer model-file sources and download from HuggingFace directly")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "models":
|
|
sys.exit(_cmd_models(args))
|
|
elif args.command == "config":
|
|
sys.exit(_cmd_config(args))
|
|
elif args.command == "start":
|
|
sys.exit(_cmd_start(args))
|
|
else:
|
|
# Default: wizard or start with saved config
|
|
sys.exit(_cmd_default(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|