Upload flm-add.py with huggingface_hub
Browse files- flm-add.py +654 -0
flm-add.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""flm-add.py - install a pre-converted FLM (Q4NX) model and register it with FastFlowLM.
|
| 3 |
+
|
| 4 |
+
Standalone, Python-3 stdlib only (no pip packages). Works with any repo that
|
| 5 |
+
already contains the runtime-ready files (config.json, model.q4nx,
|
| 6 |
+
tokenizer.json, tokenizer_config.json, optionally chat_template.jinja):
|
| 7 |
+
|
| 8 |
+
python3 flm-add.py Atomic-Germ/Qwen3.5-9B-Claude-4.8-Opus-NPU2
|
| 9 |
+
|
| 10 |
+
Repo can be a Hugging Face repo id, a ModelScope repo id (--modelscope), a full
|
| 11 |
+
Hugging Face URL, or a local directory holding the model files. The tag is
|
| 12 |
+
derived from the repo name (e.g. Qwen3.5-9B-Claude-4.8-Opus-NPU2 ->
|
| 13 |
+
qwen3.5-claude:9b); override with --tag. Defaults for the registry entry
|
| 14 |
+
(family, engine, size, context length) are copied from the matching official
|
| 15 |
+
FastFlowLM entry.
|
| 16 |
+
|
| 17 |
+
The script never rewrites the system model list or the system xclbins; it
|
| 18 |
+
writes a user-level registry at ~/.config/flm/model_list.json and adds a single
|
| 19 |
+
symlink into ~/.config/flm/xclbins/ for the new model directory. Custom FLM
|
| 20 |
+
models never ship xclbins (they are closed source), so the kernel symlink is
|
| 21 |
+
always taken from the matching official model, keyed by family (engine) and
|
| 22 |
+
size -- e.g. Darwin-36B-Opus-NPU2 -> Qwen3.6-35B-A3B-NPU2. The only thing
|
| 23 |
+
you need in your shell rc afterwards is:
|
| 24 |
+
|
| 25 |
+
export FLM_CONFIG_PATH="$HOME/.config/flm/model_list.json"
|
| 26 |
+
export FLM_XCLBIN_PATH="$HOME/.config/flm"
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import hashlib
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import re
|
| 34 |
+
import shutil
|
| 35 |
+
import sys
|
| 36 |
+
import urllib.request
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
|
| 39 |
+
REQUIRED_FILES = ["config.json", "model.q4nx", "tokenizer.json", "tokenizer_config.json"]
|
| 40 |
+
OPTIONAL_FILES = ["chat_template.jinja", "vision_weight.q4nx", "audio_weight.q4nx"]
|
| 41 |
+
ALL_FILES = REQUIRED_FILES + OPTIONAL_FILES
|
| 42 |
+
|
| 43 |
+
SYSTEM_LIST_CANDIDATES = [
|
| 44 |
+
"/opt/fastflowlm/share/flm/model_list.json",
|
| 45 |
+
"/usr/share/flm/model_list.json",
|
| 46 |
+
"/usr/local/share/flm/model_list.json",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
SYSTEM_XCLBIN_PREFIXES = [
|
| 50 |
+
Path("/opt/fastflowlm/share/flm"),
|
| 51 |
+
Path("/usr/share/flm"),
|
| 52 |
+
Path("/usr/local/share/flm"),
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
# Dir-name prefix -> runtime details.family, used only when no official entry
|
| 56 |
+
# can be matched by name. The official model_list.json is the primary source.
|
| 57 |
+
FAMILY_ALIASES = [
|
| 58 |
+
("qwen3.5-omni", "qwen3.5-omni"),
|
| 59 |
+
("qwen3.6", "qwen3.6-moe"),
|
| 60 |
+
("qwen3.5", "qwen3.5"),
|
| 61 |
+
("qwen3", "qwen3"),
|
| 62 |
+
("qwen2.5vl", "qwen2.5vl"),
|
| 63 |
+
("qwen2.5", "qwen2"),
|
| 64 |
+
("qwen2vl", "qwen2vl"),
|
| 65 |
+
("qwen2", "qwen2"),
|
| 66 |
+
("gemma4", "gemma4e"),
|
| 67 |
+
("gemma3", "gemma3"),
|
| 68 |
+
("llama3", "llama3"),
|
| 69 |
+
("deepseek-r1-0528", "deepseek-r1-0528"),
|
| 70 |
+
("deepseek-r1", "deepseek-r1"),
|
| 71 |
+
("deepseek", "deepseek-r1"),
|
| 72 |
+
("nanbeige4", "nanbeige"),
|
| 73 |
+
("nanbeige", "nanbeige"),
|
| 74 |
+
("gpt-oss", "gpt-oss"),
|
| 75 |
+
("lfm2.5", "lfm2.5-tk"),
|
| 76 |
+
("lfm2", "lfm2"),
|
| 77 |
+
("phi4", "phi4"),
|
| 78 |
+
("whisper-v3", "whisper-v3"),
|
| 79 |
+
("whisper", "whisper-v3"),
|
| 80 |
+
("embed-gemma", "embed-gemma"),
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def log(msg):
|
| 85 |
+
print(msg, file=sys.stderr)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def err(msg):
|
| 89 |
+
print(f"[ERROR] {msg}", file=sys.stderr)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def load_json(path):
|
| 93 |
+
with open(path, encoding="utf-8") as f:
|
| 94 |
+
return json.load(f)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def save_json(path, data):
|
| 98 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 99 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 100 |
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def find_system_model_list():
|
| 104 |
+
exe = shutil.which("flm")
|
| 105 |
+
candidates = []
|
| 106 |
+
if exe:
|
| 107 |
+
candidates.append(Path(exe).parent / "model_list.json")
|
| 108 |
+
candidates += [Path(p) for p in SYSTEM_LIST_CANDIDATES]
|
| 109 |
+
for c in candidates:
|
| 110 |
+
if c.is_file():
|
| 111 |
+
return c
|
| 112 |
+
raise SystemExit(
|
| 113 |
+
"Could not locate the system model_list.json (looked next to `flm` and in "
|
| 114 |
+
"/opt,/usr,/usr/local share/flm). Pass --system-list."
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def find_system_xclbin_root():
|
| 119 |
+
"""Directory whose <root>/xclbins/ holds the per-model kernel folders."""
|
| 120 |
+
exe = shutil.which("flm")
|
| 121 |
+
candidates = []
|
| 122 |
+
if exe:
|
| 123 |
+
candidates.append(Path(exe).parent)
|
| 124 |
+
candidates.append((Path(exe).parent / ".." / "share" / "flm").resolve())
|
| 125 |
+
candidates += SYSTEM_XCLBIN_PREFIXES
|
| 126 |
+
for c in candidates:
|
| 127 |
+
if (c / "xclbins").is_dir():
|
| 128 |
+
return c / "xclbins"
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def user_xclbin_dir(arg):
|
| 133 |
+
"""Resolve the user-level xclbins directory (where symlinks are added)."""
|
| 134 |
+
if arg:
|
| 135 |
+
base = Path(arg)
|
| 136 |
+
else:
|
| 137 |
+
env = os.environ.get("FLM_XCLBIN_PATH")
|
| 138 |
+
base = Path(env) if env else Path.home() / ".config" / "flm"
|
| 139 |
+
return base if base.name == "xclbins" else base / "xclbins"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def user_registry_path(arg):
|
| 143 |
+
if arg:
|
| 144 |
+
return Path(arg)
|
| 145 |
+
env = os.environ.get("FLM_CONFIG_PATH")
|
| 146 |
+
if env:
|
| 147 |
+
return Path(env)
|
| 148 |
+
return Path.home() / ".config" / "flm" / "model_list.json"
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def models_root_dir(arg):
|
| 152 |
+
if arg:
|
| 153 |
+
return Path(arg)
|
| 154 |
+
env = os.environ.get("FLM_MODEL_PATH")
|
| 155 |
+
if env:
|
| 156 |
+
return Path(env) / "models"
|
| 157 |
+
return Path.home() / ".config" / "flm" / "models"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------- tag derivation
|
| 161 |
+
|
| 162 |
+
def _strip_npu2(name):
|
| 163 |
+
return re.sub(r"-NPU2$", "", name, flags=re.IGNORECASE)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _extract_size(bare):
|
| 167 |
+
m = re.search(r"(\d+(?:\.\d+)?[Bb](?:-[A-Za-z]+\d+(?:\.\d+)?)*)", bare)
|
| 168 |
+
if not m:
|
| 169 |
+
return None, bare
|
| 170 |
+
size = m.group(1).lower()
|
| 171 |
+
rest = (bare[: m.start()] + " " + bare[m.end():]).strip()
|
| 172 |
+
return size, rest
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def derive_tag(dir_name, explicit=None):
|
| 176 |
+
if explicit:
|
| 177 |
+
return explicit
|
| 178 |
+
size, rest = _extract_size(_strip_npu2(dir_name))
|
| 179 |
+
if not size:
|
| 180 |
+
raise SystemExit(
|
| 181 |
+
f"Could not derive a size from '{dir_name}' (no 'NNb' marker). "
|
| 182 |
+
"Pass --tag name:size."
|
| 183 |
+
)
|
| 184 |
+
tokens = [t for t in re.split(r"[-_ ]+", rest) if t]
|
| 185 |
+
if not tokens:
|
| 186 |
+
raise SystemExit("Could not derive a tag from the repo name. Pass --tag name:size.")
|
| 187 |
+
family = tokens[0].lower()
|
| 188 |
+
variant = None
|
| 189 |
+
for t in tokens[1:]:
|
| 190 |
+
if re.fullmatch(r"\d+(\.\d+)?[MmKk]?", t):
|
| 191 |
+
continue
|
| 192 |
+
variant = t.lower()
|
| 193 |
+
break
|
| 194 |
+
return f"{family}-{variant}:{size}" if variant else f"{family}:{size}"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def match_official_entry(system_registry, dir_name):
|
| 198 |
+
"""Official entry whose directory name shares the longest token prefix."""
|
| 199 |
+
best = None
|
| 200 |
+
dir_tokens = re.split(r"[-_ ]+", dir_name)
|
| 201 |
+
for bucket, sizes in system_registry.get("models", {}).items():
|
| 202 |
+
for size, info in sizes.items():
|
| 203 |
+
name = info.get("name")
|
| 204 |
+
if not name:
|
| 205 |
+
continue
|
| 206 |
+
common = 0
|
| 207 |
+
for x, y in zip(re.split(r"[-_ ]+", name), dir_tokens):
|
| 208 |
+
if x.lower() != y.lower():
|
| 209 |
+
break
|
| 210 |
+
common += 1
|
| 211 |
+
if common >= 2 and (best is None or common > best[0]):
|
| 212 |
+
best = (common, bucket, size, info)
|
| 213 |
+
return best
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _official_entries(system_registry, family):
|
| 217 |
+
return [
|
| 218 |
+
(bucket, sz, info)
|
| 219 |
+
for bucket, sizes in system_registry.get("models", {}).items()
|
| 220 |
+
for sz, info in sizes.items()
|
| 221 |
+
if (info.get("details") or {}).get("family") == family
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def match_official_by_family_size(system_registry, family, size):
|
| 226 |
+
"""Official entry matching details.family and registry size (bytes).
|
| 227 |
+
|
| 228 |
+
Used for repos that share an engine with an official model but not a
|
| 229 |
+
name prefix (e.g. Huihui-Qwythos-9B-... -> qwen3.5 + 9B -> Qwen3.5-9B-NPU2).
|
| 230 |
+
"""
|
| 231 |
+
if not family or not size:
|
| 232 |
+
return None
|
| 233 |
+
for bucket, sz, info in _official_entries(system_registry, family):
|
| 234 |
+
if info.get("size") == size:
|
| 235 |
+
return (0, bucket, sz, info)
|
| 236 |
+
return None
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def resolve_official(system_registry, dir_name, family, size):
|
| 240 |
+
"""Pick the official model that supplies the xclbins for this install.
|
| 241 |
+
|
| 242 |
+
Custom FLM models never ship xclbins (closed source), so the kernels must
|
| 243 |
+
be linked from the matching official model, keyed by family (engine) and
|
| 244 |
+
size. Returns (official_4tuple, note) where note explains any size
|
| 245 |
+
mismatch, or (None, None) when no official model matches.
|
| 246 |
+
"""
|
| 247 |
+
official = match_official_entry(system_registry, dir_name)
|
| 248 |
+
if official:
|
| 249 |
+
return official, None
|
| 250 |
+
official = match_official_by_family_size(system_registry, family, size)
|
| 251 |
+
if official:
|
| 252 |
+
return official, None
|
| 253 |
+
entries = _official_entries(system_registry, family)
|
| 254 |
+
if len(entries) == 1:
|
| 255 |
+
bucket, sz, info = entries[0]
|
| 256 |
+
note = None
|
| 257 |
+
if size:
|
| 258 |
+
official_size = info.get("size", 0)
|
| 259 |
+
if official_size and official_size != size:
|
| 260 |
+
note = f"tag size {size/1e9:g}B differs from official {official_size/1e9:g}B"
|
| 261 |
+
return (0, bucket, sz, info), note
|
| 262 |
+
if entries and size:
|
| 263 |
+
best = min(entries, key=lambda e: abs(e[2].get("size", 0) - size))
|
| 264 |
+
bucket, sz, info = best
|
| 265 |
+
return (0, bucket, sz, info), (
|
| 266 |
+
f"no exact size match for {size/1e9:g}B; using {info.get('size', 0)/1e9:g}B kernels"
|
| 267 |
+
)
|
| 268 |
+
return None, None
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def derive_family(system_registry, dir_name, explicit=None, base_entry=None):
|
| 272 |
+
if explicit:
|
| 273 |
+
return explicit
|
| 274 |
+
if base_entry:
|
| 275 |
+
fam = base_entry.get("details", {}).get("family")
|
| 276 |
+
if fam:
|
| 277 |
+
return fam
|
| 278 |
+
lower = dir_name.lower()
|
| 279 |
+
for prefix, family in FAMILY_ALIASES:
|
| 280 |
+
if lower.startswith(prefix.lower()):
|
| 281 |
+
return family
|
| 282 |
+
raise SystemExit(
|
| 283 |
+
f"Could not determine details.family for '{dir_name}'. "
|
| 284 |
+
"Pass --family (e.g. qwen3.5, qwen3.6-moe, nanbeige, llama3, ...)."
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ------------------------------------------------------------------- asset fetch
|
| 289 |
+
|
| 290 |
+
def _hf_headers():
|
| 291 |
+
headers = {"User-Agent": "flm-add/1.0"}
|
| 292 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 293 |
+
if token:
|
| 294 |
+
headers["Authorization"] = f"Bearer {token}"
|
| 295 |
+
return headers
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _http_get_json(url):
|
| 299 |
+
req = urllib.request.Request(url, headers=_hf_headers())
|
| 300 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 301 |
+
return json.loads(resp.read().decode("utf-8"))
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def hf_file_tree(repo_id):
|
| 305 |
+
return _http_get_json(f"https://huggingface.co/api/models/{repo_id}/tree/main?recursive=true")
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def ms_file_tree(repo_id):
|
| 309 |
+
return _http_get_json(
|
| 310 |
+
f"https://modelscope.cn/api/v1/models/{repo_id}/repo/files?Revision=master&Recursive=false"
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def hf_cache_snapshot(repo_id):
|
| 315 |
+
roots = []
|
| 316 |
+
for env in ("HF_HUB_CACHE", "HF_HOME"):
|
| 317 |
+
if os.environ.get(env):
|
| 318 |
+
p = Path(os.environ[env])
|
| 319 |
+
roots.append(p if p.name == "hub" else p / "hub")
|
| 320 |
+
roots.append(Path.home() / ".cache" / "huggingface" / "hub")
|
| 321 |
+
repo_dir_name = "models--" + repo_id.replace("/", "--")
|
| 322 |
+
for root in roots:
|
| 323 |
+
snapshots = root / repo_dir_name / "snapshots"
|
| 324 |
+
if not snapshots.is_dir():
|
| 325 |
+
continue
|
| 326 |
+
for ref in (root / repo_dir_name / "refs").glob("*"):
|
| 327 |
+
try:
|
| 328 |
+
rev = ref.read_text().strip()
|
| 329 |
+
except Exception:
|
| 330 |
+
continue
|
| 331 |
+
d = snapshots / rev
|
| 332 |
+
if d.is_dir():
|
| 333 |
+
return d
|
| 334 |
+
first = next((d for d in snapshots.iterdir() if d.is_dir()), None)
|
| 335 |
+
if first:
|
| 336 |
+
return first
|
| 337 |
+
return None
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def download_file(url, dest, expected_size=None, expected_sha=None, verify=True, quiet=False):
|
| 341 |
+
req = urllib.request.Request(url, headers=_hf_headers())
|
| 342 |
+
tmp = str(dest) + ".part"
|
| 343 |
+
written = 0
|
| 344 |
+
with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as out:
|
| 345 |
+
length = int(resp.headers.get("Content-Length") or 0)
|
| 346 |
+
total = expected_size or length or 0
|
| 347 |
+
last_pct = -1
|
| 348 |
+
while True:
|
| 349 |
+
chunk = resp.read(1024 * 1024)
|
| 350 |
+
if not chunk:
|
| 351 |
+
break
|
| 352 |
+
out.write(chunk)
|
| 353 |
+
written += len(chunk)
|
| 354 |
+
if total and not quiet:
|
| 355 |
+
pct = int(written * 100 / total)
|
| 356 |
+
if pct != last_pct and pct % 5 == 0:
|
| 357 |
+
log(f" {pct:3d}% ({written/1e9:.2f} GB / {total/1e9:.2f} GB)")
|
| 358 |
+
last_pct = pct
|
| 359 |
+
if expected_size and written != expected_size:
|
| 360 |
+
os.unlink(tmp)
|
| 361 |
+
raise SystemExit(f"Size mismatch for {dest.name}: got {written}, expected {expected_size}")
|
| 362 |
+
if expected_sha and verify:
|
| 363 |
+
h = hashlib.sha256()
|
| 364 |
+
with open(tmp, "rb") as f:
|
| 365 |
+
while True:
|
| 366 |
+
chunk = f.read(1024 * 1024)
|
| 367 |
+
if not chunk:
|
| 368 |
+
break
|
| 369 |
+
h.update(chunk)
|
| 370 |
+
if h.hexdigest() != expected_sha:
|
| 371 |
+
os.unlink(tmp)
|
| 372 |
+
raise SystemExit(f"sha256 mismatch for {dest.name}")
|
| 373 |
+
os.replace(tmp, dest)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def fetch_assets(repo_id, target, modelscope=False, verify=True, force=False, quiet=False):
|
| 377 |
+
"""Populate target/ with the model files; returns the list of files present."""
|
| 378 |
+
obtained = []
|
| 379 |
+
if modelscope:
|
| 380 |
+
tree = ms_file_tree(repo_id)
|
| 381 |
+
entries = {}
|
| 382 |
+
for e in tree.get("Data", []):
|
| 383 |
+
p = e.get("Path") or e.get("Name")
|
| 384 |
+
if p and "/" not in p:
|
| 385 |
+
entries[p] = {}
|
| 386 |
+
for fname in ALL_FILES:
|
| 387 |
+
if fname not in entries:
|
| 388 |
+
continue
|
| 389 |
+
dest = target / fname
|
| 390 |
+
if dest.is_file() and not force:
|
| 391 |
+
obtained.append(fname)
|
| 392 |
+
continue
|
| 393 |
+
log(f"Downloading {fname} from ModelScope...")
|
| 394 |
+
download_file(
|
| 395 |
+
f"https://modelscope.cn/models/{repo_id}/resolve/master/{fname}",
|
| 396 |
+
dest,
|
| 397 |
+
verify=verify,
|
| 398 |
+
quiet=quiet,
|
| 399 |
+
)
|
| 400 |
+
obtained.append(fname)
|
| 401 |
+
return obtained
|
| 402 |
+
|
| 403 |
+
# Hugging Face: local cache first, then the tree API.
|
| 404 |
+
entries = {}
|
| 405 |
+
for e in hf_file_tree(repo_id):
|
| 406 |
+
p = e.get("path")
|
| 407 |
+
if p and "/" not in p:
|
| 408 |
+
entries[p] = e
|
| 409 |
+
for fname in ALL_FILES:
|
| 410 |
+
if fname not in entries:
|
| 411 |
+
continue
|
| 412 |
+
dest = target / fname
|
| 413 |
+
if dest.is_file() and not force:
|
| 414 |
+
obtained.append(fname)
|
| 415 |
+
continue
|
| 416 |
+
lfs = entries[fname].get("lfs") or {}
|
| 417 |
+
expected_sha = lfs.get("oid")
|
| 418 |
+
expected_size = lfs.get("size") or entries[fname].get("size")
|
| 419 |
+
log(f"Downloading {fname} ({expected_size/1e9:.2f} GB)...")
|
| 420 |
+
download_file(
|
| 421 |
+
f"https://huggingface.co/{repo_id}/resolve/main/{fname}",
|
| 422 |
+
dest,
|
| 423 |
+
expected_size=expected_size,
|
| 424 |
+
expected_sha=expected_sha,
|
| 425 |
+
verify=verify,
|
| 426 |
+
quiet=quiet,
|
| 427 |
+
)
|
| 428 |
+
obtained.append(fname)
|
| 429 |
+
return obtained
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def copy_from_dir(src_dir, target, force=False):
|
| 433 |
+
obtained = []
|
| 434 |
+
for fname in ALL_FILES:
|
| 435 |
+
src = src_dir / fname
|
| 436 |
+
if src.is_file():
|
| 437 |
+
dest = target / fname
|
| 438 |
+
if dest.is_file() and not force:
|
| 439 |
+
obtained.append(fname)
|
| 440 |
+
continue
|
| 441 |
+
shutil.copy2(src, dest)
|
| 442 |
+
obtained.append(fname)
|
| 443 |
+
return obtained
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# ---------------------------------------------------------------- registry
|
| 447 |
+
|
| 448 |
+
def size_from_tag(tag):
|
| 449 |
+
"""Registry 'size' (bytes) from the tag size marker, e.g. '3b' -> 3000000000,
|
| 450 |
+
'9b-claude-4.8' -> 9000000000, '0.8b' -> 800000000."""
|
| 451 |
+
m = re.match(r".*:(\d+(?:\.\d+)?)b\b", tag, flags=re.IGNORECASE)
|
| 452 |
+
if not m:
|
| 453 |
+
return None
|
| 454 |
+
return int(float(m.group(1)) * 1_000_000_000)
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def estimate_size(config_path):
|
| 458 |
+
try:
|
| 459 |
+
cfg = load_json(config_path)
|
| 460 |
+
except Exception:
|
| 461 |
+
return None
|
| 462 |
+
hidden = cfg.get("hidden_size")
|
| 463 |
+
layers = cfg.get("num_hidden_layers")
|
| 464 |
+
if not hidden or not layers:
|
| 465 |
+
return None
|
| 466 |
+
intermediate = cfg.get("intermediate_size")
|
| 467 |
+
per_layer = 12 * hidden * hidden
|
| 468 |
+
if intermediate:
|
| 469 |
+
per_layer += 3 * hidden * intermediate
|
| 470 |
+
total = per_layer * layers + 2 * hidden * (cfg.get("vocab_size") or hidden)
|
| 471 |
+
return max(int(round(total / 1e9 * 2) / 2 * 1e9), 1_000_000_000)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def build_entry(base_entry, dir_name, files, size):
|
| 475 |
+
entry = dict(base_entry) if base_entry else {}
|
| 476 |
+
entry["name"] = dir_name
|
| 477 |
+
entry["files"] = list(files)
|
| 478 |
+
entry["url"] = ""
|
| 479 |
+
entry["file_url"] = ""
|
| 480 |
+
entry["ms_url"] = ""
|
| 481 |
+
entry.setdefault("max_prefill_len", 4096)
|
| 482 |
+
entry.setdefault("default_context_length", 8192)
|
| 483 |
+
entry.setdefault("flm_min_version", "0.9.45")
|
| 484 |
+
entry.setdefault("details", {}).setdefault("format", "NPU2")
|
| 485 |
+
if size:
|
| 486 |
+
entry["size"] = size
|
| 487 |
+
entry["vlm"] = any(f.startswith("vision") for f in files)
|
| 488 |
+
return entry
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def register(user_list_path, tag, entry, system_registry):
|
| 492 |
+
if user_list_path.is_file():
|
| 493 |
+
registry = load_json(user_list_path)
|
| 494 |
+
else:
|
| 495 |
+
registry = json.loads(json.dumps(system_registry))
|
| 496 |
+
registry.setdefault("model_path", "models")
|
| 497 |
+
model_type, size = tag.split(":", 1)
|
| 498 |
+
registry.setdefault("models", {}).setdefault(model_type, {})[size] = entry
|
| 499 |
+
save_json(user_list_path, registry)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# ------------------------------------------------------------------- xclbins
|
| 503 |
+
|
| 504 |
+
def link_xclbins(system_root, user_root, dir_name, source_name, force=False, quiet=False):
|
| 505 |
+
if not source_name:
|
| 506 |
+
if not quiet:
|
| 507 |
+
log("[WARN] No xclbin source; skipping symlink. Pass --xclbin-from NAME to link an official model's kernels.")
|
| 508 |
+
return
|
| 509 |
+
src = system_root / source_name
|
| 510 |
+
if not src.is_dir():
|
| 511 |
+
if not quiet:
|
| 512 |
+
log(f"[WARN] Official model has no xclbins directory: {source_name}")
|
| 513 |
+
return
|
| 514 |
+
user_root.mkdir(parents=True, exist_ok=True)
|
| 515 |
+
link = user_root / dir_name
|
| 516 |
+
target = str(src)
|
| 517 |
+
if link.is_symlink():
|
| 518 |
+
if os.readlink(link) == target:
|
| 519 |
+
if not quiet:
|
| 520 |
+
log(f"[INFO] xclbins link already in place: {link}")
|
| 521 |
+
return
|
| 522 |
+
link.unlink()
|
| 523 |
+
elif link.exists():
|
| 524 |
+
if force:
|
| 525 |
+
shutil.rmtree(link)
|
| 526 |
+
else:
|
| 527 |
+
raise SystemExit(
|
| 528 |
+
f"{link} already exists and is not a symlink. Remove it or pass --force."
|
| 529 |
+
)
|
| 530 |
+
os.symlink(target, link)
|
| 531 |
+
if not quiet:
|
| 532 |
+
log(f"[INFO] Linked xclbins: {link} -> {target}")
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
# ---------------------------------------------------------------------- main
|
| 536 |
+
|
| 537 |
+
def main():
|
| 538 |
+
ap = argparse.ArgumentParser(
|
| 539 |
+
prog="flm-add.py",
|
| 540 |
+
description="Install a pre-converted FLM (Q4NX) model and register it with FastFlowLM.",
|
| 541 |
+
)
|
| 542 |
+
ap.add_argument("repo", help="Hugging Face repo id (Org/Name), ModelScope id (with --modelscope), URL, or local directory")
|
| 543 |
+
ap.add_argument("--tag", help="Registry tag (default: derived from the repo name, e.g. qwen3.5-claude:9b)")
|
| 544 |
+
ap.add_argument("--family", help="details.family for engine dispatch (default: from matching official entry)")
|
| 545 |
+
ap.add_argument("--config", help="model_list.json to update (default: $FLM_CONFIG_PATH or ~/.config/flm/model_list.json)")
|
| 546 |
+
ap.add_argument("--models-root", help="models directory (default: $FLM_MODEL_PATH or ~/.config/flm/models)")
|
| 547 |
+
ap.add_argument("--xclbin-dir", help="user xclbins directory (default: ~/.config/flm/xclbins)")
|
| 548 |
+
ap.add_argument("--xclbin-from", help="official model directory name to link xclbins from (default: best match, e.g. Qwen3.6-35B-A3B-NPU2)")
|
| 549 |
+
ap.add_argument("--system-list", help="official model_list.json used for defaults (default: auto-detect)")
|
| 550 |
+
ap.add_argument("--modelscope", action="store_true", help="Treat REPO as a ModelScope repo id")
|
| 551 |
+
ap.add_argument("--no-xclbin", action="store_true", help="Do not create the xclbins symlink")
|
| 552 |
+
ap.add_argument("--no-verify", action="store_true", help="Skip sha256 verification of downloads")
|
| 553 |
+
ap.add_argument("--force", action="store_true", help="Overwrite existing model files/links")
|
| 554 |
+
ap.add_argument("--dry-run", action="store_true", help="Print the plan and exit")
|
| 555 |
+
ap.add_argument("--quiet", action="store_true", help="Less output")
|
| 556 |
+
args = ap.parse_args()
|
| 557 |
+
|
| 558 |
+
repo = args.repo
|
| 559 |
+
if repo.startswith(("https://", "http://")):
|
| 560 |
+
repo = repo.split("/", 3)[-1]
|
| 561 |
+
local_dir = Path(repo) if os.path.isdir(repo) else None
|
| 562 |
+
dir_name = local_dir.name if local_dir else repo.split("/")[-1]
|
| 563 |
+
if not dir_name:
|
| 564 |
+
raise SystemExit("Could not determine a model directory name from the repo.")
|
| 565 |
+
|
| 566 |
+
system_list = find_system_model_list()
|
| 567 |
+
system_registry = load_json(system_list)
|
| 568 |
+
user_list = user_registry_path(args.config)
|
| 569 |
+
models_root = models_root_dir(args.models_root)
|
| 570 |
+
target = models_root / dir_name
|
| 571 |
+
|
| 572 |
+
tag = derive_tag(dir_name, args.tag)
|
| 573 |
+
bucket, size_token = tag.split(":", 1)
|
| 574 |
+
official = match_official_entry(system_registry, dir_name)
|
| 575 |
+
base_entry = official[3] if official else None
|
| 576 |
+
family = derive_family(system_registry, dir_name, args.family, base_entry)
|
| 577 |
+
size_value = (base_entry or {}).get("size") or size_from_tag(tag)
|
| 578 |
+
official, official_note = resolve_official(system_registry, dir_name, family, size_value)
|
| 579 |
+
base_entry = official[3] if official else None
|
| 580 |
+
src_tag = f"{official[1]}:{official[2]}" if official else None
|
| 581 |
+
xclbin_source = args.xclbin_from or (base_entry or {}).get("name")
|
| 582 |
+
if not args.dry_run:
|
| 583 |
+
if official:
|
| 584 |
+
note = f" ({official_note})" if official_note else ""
|
| 585 |
+
log(f"[INFO] xclbins from official {src_tag}{note}")
|
| 586 |
+
else:
|
| 587 |
+
log("[WARN] No official model matched; no xclbins link. Pass --xclbin-from NAME (or --no-xclbin).")
|
| 588 |
+
|
| 589 |
+
if args.dry_run:
|
| 590 |
+
print(f"repo directory : {dir_name}")
|
| 591 |
+
print(f"tag : {tag}")
|
| 592 |
+
print(f"details.family : {family}")
|
| 593 |
+
print(f"official match : {src_tag or '(none)'}")
|
| 594 |
+
print(f"xclbin source : {xclbin_source or '(none)'}")
|
| 595 |
+
print(f"models dir : {target}")
|
| 596 |
+
print(f"registry : {user_list}")
|
| 597 |
+
return
|
| 598 |
+
|
| 599 |
+
# --- acquire model files ---
|
| 600 |
+
if local_dir:
|
| 601 |
+
if not args.quiet:
|
| 602 |
+
log(f"[INFO] Using local model directory: {local_dir}")
|
| 603 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 604 |
+
files = copy_from_dir(local_dir, target, force=args.force)
|
| 605 |
+
else:
|
| 606 |
+
snapshot = None if args.modelscope else hf_cache_snapshot(repo)
|
| 607 |
+
if snapshot:
|
| 608 |
+
if not args.quiet:
|
| 609 |
+
log(f"[INFO] Found local HF cache: {snapshot}")
|
| 610 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 611 |
+
files = copy_from_dir(snapshot, target, force=args.force)
|
| 612 |
+
else:
|
| 613 |
+
if not args.quiet:
|
| 614 |
+
log(f"[INFO] Downloading model files from {'ModelScope' if args.modelscope else 'Hugging Face'}: {repo}")
|
| 615 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 616 |
+
files = fetch_assets(repo, target, args.modelscope, verify=not args.no_verify, force=args.force, quiet=args.quiet)
|
| 617 |
+
|
| 618 |
+
missing = [f for f in REQUIRED_FILES if not (target / f).is_file()]
|
| 619 |
+
if missing:
|
| 620 |
+
raise SystemExit(f"Model is missing required files: {missing}")
|
| 621 |
+
|
| 622 |
+
if not size_value:
|
| 623 |
+
size_value = estimate_size(target / "config.json")
|
| 624 |
+
entry = build_entry(base_entry, dir_name, files, size_value)
|
| 625 |
+
entry.setdefault("details", {})["family"] = family
|
| 626 |
+
|
| 627 |
+
register(user_list, tag, entry, system_registry)
|
| 628 |
+
log(f"[INFO] Registered tag '{tag}' in {user_list}")
|
| 629 |
+
|
| 630 |
+
if not args.no_xclbin:
|
| 631 |
+
system_root = find_system_xclbin_root()
|
| 632 |
+
if system_root is None:
|
| 633 |
+
log("[WARN] Could not locate system xclbins; skipped symlink.")
|
| 634 |
+
else:
|
| 635 |
+
link_xclbins(
|
| 636 |
+
system_root,
|
| 637 |
+
user_xclbin_dir(args.xclbin_dir),
|
| 638 |
+
dir_name,
|
| 639 |
+
xclbin_source,
|
| 640 |
+
force=args.force,
|
| 641 |
+
quiet=args.quiet,
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
print()
|
| 645 |
+
print(f"Done: {dir_name} installed to {target}")
|
| 646 |
+
print(f"Run: flm run {tag} (or: flm serve {tag})")
|
| 647 |
+
print()
|
| 648 |
+
print("Make sure your shell has these exports (add to ~/.bashrc):")
|
| 649 |
+
print(' export FLM_CONFIG_PATH="$HOME/.config/flm/model_list.json"')
|
| 650 |
+
print(' export FLM_XCLBIN_PATH="$HOME/.config/flm"')
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
if __name__ == "__main__":
|
| 654 |
+
main()
|