Atomic-Germ's picture
flm-add.py: fix -A3B size suffix (35b-a3b) and add family aliases (granite/llama, darwin/qwen3.6-moe, crow/qwythos/qwopus/huihui/qwen3.5, gemma-4/gemma4e)
4a12219 verified
Raw
History Blame Contribute Delete
24.4 kB
#!/usr/bin/env python3
"""flm-add.py - install a pre-converted FLM (Q4NX) model and register it with FastFlowLM.
Standalone, Python-3 stdlib only (no pip packages). Works with any repo that
already contains the runtime-ready files (config.json, model.q4nx,
tokenizer.json, tokenizer_config.json, optionally chat_template.jinja):
python3 flm-add.py Atomic-Germ/Qwen3.5-9B-Claude-4.8-Opus-NPU2
Repo can be a Hugging Face repo id, a ModelScope repo id (--modelscope), a full
Hugging Face URL, or a local directory holding the model files. The tag is
derived from the repo name (e.g. Qwen3.5-9B-Claude-4.8-Opus-NPU2 ->
qwen3.5-claude:9b); override with --tag. Defaults for the registry entry
(family, engine, size, context length) are copied from the matching official
FastFlowLM entry.
The script never rewrites the system model list or the system xclbins; it
writes a user-level registry at ~/.config/flm/model_list.json and adds a single
symlink into ~/.config/flm/xclbins/ for the new model directory. Custom FLM
models never ship xclbins (they are closed source), so the kernel symlink is
always taken from the matching official model, keyed by family (engine) and
size -- e.g. Darwin-36B-Opus-NPU2 -> Qwen3.6-35B-A3B-NPU2. The only thing
you need in your shell rc afterwards is:
export FLM_CONFIG_PATH="$HOME/.config/flm/model_list.json"
export FLM_XCLBIN_PATH="$HOME/.config/flm"
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
import urllib.request
from pathlib import Path
REQUIRED_FILES = ["config.json", "model.q4nx", "tokenizer.json", "tokenizer_config.json"]
OPTIONAL_FILES = ["chat_template.jinja", "vision_weight.q4nx", "audio_weight.q4nx"]
ALL_FILES = REQUIRED_FILES + OPTIONAL_FILES
SYSTEM_LIST_CANDIDATES = [
"/opt/fastflowlm/share/flm/model_list.json",
"/usr/share/flm/model_list.json",
"/usr/local/share/flm/model_list.json",
]
SYSTEM_XCLBIN_PREFIXES = [
Path("/opt/fastflowlm/share/flm"),
Path("/usr/share/flm"),
Path("/usr/local/share/flm"),
]
# Dir-name prefix -> runtime details.family, used only when no official entry
# can be matched by name. The official model_list.json is the primary source.
FAMILY_ALIASES = [
("qwen3.5-omni", "qwen3.5-omni"),
("qwen3.6", "qwen3.6-moe"),
("qwen3.5", "qwen3.5"),
("qwen3", "qwen3"),
("qwen2.5vl", "qwen2.5vl"),
("qwen2.5", "qwen2"),
("qwen2vl", "qwen2vl"),
("qwen2", "qwen2"),
("gemma4", "gemma4e"),
("gemma-4", "gemma4e"),
("gemma3", "gemma3"),
("llama3", "llama3"),
("llama", "llama3"),
("granite", "llama3"),
("crow", "qwen3.5"),
("huihui", "qwen3.5"),
("qwythos", "qwen3.5"),
("qwopus", "qwen3.5"),
("darwin", "qwen3.6-moe"),
("deepseek-r1-0528", "deepseek-r1-0528"),
("deepseek-r1", "deepseek-r1"),
("deepseek", "deepseek-r1"),
("nanbeige4", "nanbeige"),
("nanbeige", "nanbeige"),
("gpt-oss", "gpt-oss"),
("lfm2.5", "lfm2.5-tk"),
("lfm2", "lfm2"),
("phi4", "phi4"),
("whisper-v3", "whisper-v3"),
("whisper", "whisper-v3"),
("embed-gemma", "embed-gemma"),
]
def log(msg):
print(msg, file=sys.stderr)
def err(msg):
print(f"[ERROR] {msg}", file=sys.stderr)
def load_json(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def save_json(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def find_system_model_list():
exe = shutil.which("flm")
candidates = []
if exe:
candidates.append(Path(exe).parent / "model_list.json")
candidates += [Path(p) for p in SYSTEM_LIST_CANDIDATES]
for c in candidates:
if c.is_file():
return c
raise SystemExit(
"Could not locate the system model_list.json (looked next to `flm` and in "
"/opt,/usr,/usr/local share/flm). Pass --system-list."
)
def find_system_xclbin_root():
"""Directory whose <root>/xclbins/ holds the per-model kernel folders."""
exe = shutil.which("flm")
candidates = []
if exe:
candidates.append(Path(exe).parent)
candidates.append((Path(exe).parent / ".." / "share" / "flm").resolve())
candidates += SYSTEM_XCLBIN_PREFIXES
for c in candidates:
if (c / "xclbins").is_dir():
return c / "xclbins"
return None
def user_xclbin_dir(arg):
"""Resolve the user-level xclbins directory (where symlinks are added)."""
if arg:
base = Path(arg)
else:
env = os.environ.get("FLM_XCLBIN_PATH")
base = Path(env) if env else Path.home() / ".config" / "flm"
return base if base.name == "xclbins" else base / "xclbins"
def user_registry_path(arg):
if arg:
return Path(arg)
env = os.environ.get("FLM_CONFIG_PATH")
if env:
return Path(env)
return Path.home() / ".config" / "flm" / "model_list.json"
def models_root_dir(arg):
if arg:
return Path(arg)
env = os.environ.get("FLM_MODEL_PATH")
if env:
return Path(env) / "models"
return Path.home() / ".config" / "flm" / "models"
# ---------------------------------------------------------------- tag derivation
def _strip_npu2(name):
return re.sub(r"-NPU2$", "", name, flags=re.IGNORECASE)
def _extract_size(bare):
# Trailing size groups like "-A3B" (Qwen3.6-35B-A3B) end in a letter that
# the digit group must not swallow (previously "-A3B" -> "35b-a3").
m = re.search(r"(\d+(?:\.\d+)?[Bb](?:-[A-Za-z]+\d+(?:\.\d+)?[A-Za-z]*)*)", bare)
if not m:
return None, bare
size = m.group(1).lower()
rest = (bare[: m.start()] + " " + bare[m.end():]).strip()
return size, rest
def derive_tag(dir_name, explicit=None):
if explicit:
return explicit
size, rest = _extract_size(_strip_npu2(dir_name))
if not size:
raise SystemExit(
f"Could not derive a size from '{dir_name}' (no 'NNb' marker). "
"Pass --tag name:size."
)
tokens = [t for t in re.split(r"[-_ ]+", rest) if t]
if not tokens:
raise SystemExit("Could not derive a tag from the repo name. Pass --tag name:size.")
family = tokens[0].lower()
variant = None
for t in tokens[1:]:
if re.fullmatch(r"\d+(\.\d+)?[MmKk]?", t):
continue
variant = t.lower()
break
return f"{family}-{variant}:{size}" if variant else f"{family}:{size}"
def match_official_entry(system_registry, dir_name):
"""Official entry whose directory name shares the longest token prefix."""
best = None
dir_tokens = re.split(r"[-_ ]+", dir_name)
for bucket, sizes in system_registry.get("models", {}).items():
for size, info in sizes.items():
name = info.get("name")
if not name:
continue
common = 0
for x, y in zip(re.split(r"[-_ ]+", name), dir_tokens):
if x.lower() != y.lower():
break
common += 1
if common >= 2 and (best is None or common > best[0]):
best = (common, bucket, size, info)
return best
def _official_entries(system_registry, family):
return [
(bucket, sz, info)
for bucket, sizes in system_registry.get("models", {}).items()
for sz, info in sizes.items()
if (info.get("details") or {}).get("family") == family
]
def match_official_by_family_size(system_registry, family, size):
"""Official entry matching details.family and registry size (bytes).
Used for repos that share an engine with an official model but not a
name prefix (e.g. Huihui-Qwythos-9B-... -> qwen3.5 + 9B -> Qwen3.5-9B-NPU2).
"""
if not family or not size:
return None
for bucket, sz, info in _official_entries(system_registry, family):
if info.get("size") == size:
return (0, bucket, sz, info)
return None
def resolve_official(system_registry, dir_name, family, size):
"""Pick the official model that supplies the xclbins for this install.
Custom FLM models never ship xclbins (closed source), so the kernels must
be linked from the matching official model, keyed by family (engine) and
size. Returns (official_4tuple, note) where note explains any size
mismatch, or (None, None) when no official model matches.
"""
official = match_official_entry(system_registry, dir_name)
if official:
return official, None
official = match_official_by_family_size(system_registry, family, size)
if official:
return official, None
entries = _official_entries(system_registry, family)
if len(entries) == 1:
bucket, sz, info = entries[0]
note = None
if size:
official_size = info.get("size", 0)
if official_size and official_size != size:
note = f"tag size {size/1e9:g}B differs from official {official_size/1e9:g}B"
return (0, bucket, sz, info), note
if entries and size:
best = min(entries, key=lambda e: abs(e[2].get("size", 0) - size))
bucket, sz, info = best
return (0, bucket, sz, info), (
f"no exact size match for {size/1e9:g}B; using {info.get('size', 0)/1e9:g}B kernels"
)
return None, None
def derive_family(system_registry, dir_name, explicit=None, base_entry=None):
if explicit:
return explicit
if base_entry:
fam = base_entry.get("details", {}).get("family")
if fam:
return fam
lower = dir_name.lower()
for prefix, family in FAMILY_ALIASES:
if lower.startswith(prefix.lower()):
return family
raise SystemExit(
f"Could not determine details.family for '{dir_name}'. "
"Pass --family (e.g. qwen3.5, qwen3.6-moe, nanbeige, llama3, ...)."
)
# ------------------------------------------------------------------- asset fetch
def _hf_headers():
headers = {"User-Agent": "flm-add/1.0"}
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _http_get_json(url):
req = urllib.request.Request(url, headers=_hf_headers())
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def hf_file_tree(repo_id):
return _http_get_json(f"https://huggingface.co/api/models/{repo_id}/tree/main?recursive=true")
def ms_file_tree(repo_id):
return _http_get_json(
f"https://modelscope.cn/api/v1/models/{repo_id}/repo/files?Revision=master&Recursive=false"
)
def hf_cache_snapshot(repo_id):
roots = []
for env in ("HF_HUB_CACHE", "HF_HOME"):
if os.environ.get(env):
p = Path(os.environ[env])
roots.append(p if p.name == "hub" else p / "hub")
roots.append(Path.home() / ".cache" / "huggingface" / "hub")
repo_dir_name = "models--" + repo_id.replace("/", "--")
for root in roots:
snapshots = root / repo_dir_name / "snapshots"
if not snapshots.is_dir():
continue
for ref in (root / repo_dir_name / "refs").glob("*"):
try:
rev = ref.read_text().strip()
except Exception:
continue
d = snapshots / rev
if d.is_dir():
return d
first = next((d for d in snapshots.iterdir() if d.is_dir()), None)
if first:
return first
return None
def download_file(url, dest, expected_size=None, expected_sha=None, verify=True, quiet=False):
req = urllib.request.Request(url, headers=_hf_headers())
tmp = str(dest) + ".part"
written = 0
with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as out:
length = int(resp.headers.get("Content-Length") or 0)
total = expected_size or length or 0
last_pct = -1
while True:
chunk = resp.read(1024 * 1024)
if not chunk:
break
out.write(chunk)
written += len(chunk)
if total and not quiet:
pct = int(written * 100 / total)
if pct != last_pct and pct % 5 == 0:
log(f" {pct:3d}% ({written/1e9:.2f} GB / {total/1e9:.2f} GB)")
last_pct = pct
if expected_size and written != expected_size:
os.unlink(tmp)
raise SystemExit(f"Size mismatch for {dest.name}: got {written}, expected {expected_size}")
if expected_sha and verify:
h = hashlib.sha256()
with open(tmp, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
h.update(chunk)
if h.hexdigest() != expected_sha:
os.unlink(tmp)
raise SystemExit(f"sha256 mismatch for {dest.name}")
os.replace(tmp, dest)
def fetch_assets(repo_id, target, modelscope=False, verify=True, force=False, quiet=False):
"""Populate target/ with the model files; returns the list of files present."""
obtained = []
if modelscope:
tree = ms_file_tree(repo_id)
entries = {}
for e in tree.get("Data", []):
p = e.get("Path") or e.get("Name")
if p and "/" not in p:
entries[p] = {}
for fname in ALL_FILES:
if fname not in entries:
continue
dest = target / fname
if dest.is_file() and not force:
obtained.append(fname)
continue
log(f"Downloading {fname} from ModelScope...")
download_file(
f"https://modelscope.cn/models/{repo_id}/resolve/master/{fname}",
dest,
verify=verify,
quiet=quiet,
)
obtained.append(fname)
return obtained
# Hugging Face: local cache first, then the tree API.
entries = {}
for e in hf_file_tree(repo_id):
p = e.get("path")
if p and "/" not in p:
entries[p] = e
for fname in ALL_FILES:
if fname not in entries:
continue
dest = target / fname
if dest.is_file() and not force:
obtained.append(fname)
continue
lfs = entries[fname].get("lfs") or {}
expected_sha = lfs.get("oid")
expected_size = lfs.get("size") or entries[fname].get("size")
log(f"Downloading {fname} ({expected_size/1e9:.2f} GB)...")
download_file(
f"https://huggingface.co/{repo_id}/resolve/main/{fname}",
dest,
expected_size=expected_size,
expected_sha=expected_sha,
verify=verify,
quiet=quiet,
)
obtained.append(fname)
return obtained
def copy_from_dir(src_dir, target, force=False):
obtained = []
for fname in ALL_FILES:
src = src_dir / fname
if src.is_file():
dest = target / fname
if dest.is_file() and not force:
obtained.append(fname)
continue
shutil.copy2(src, dest)
obtained.append(fname)
return obtained
# ---------------------------------------------------------------- registry
def size_from_tag(tag):
"""Registry 'size' (bytes) from the tag size marker, e.g. '3b' -> 3000000000,
'9b-claude-4.8' -> 9000000000, '0.8b' -> 800000000."""
m = re.match(r".*:(\d+(?:\.\d+)?)b\b", tag, flags=re.IGNORECASE)
if not m:
return None
return int(float(m.group(1)) * 1_000_000_000)
def estimate_size(config_path):
try:
cfg = load_json(config_path)
except Exception:
return None
hidden = cfg.get("hidden_size")
layers = cfg.get("num_hidden_layers")
if not hidden or not layers:
return None
intermediate = cfg.get("intermediate_size")
per_layer = 12 * hidden * hidden
if intermediate:
per_layer += 3 * hidden * intermediate
total = per_layer * layers + 2 * hidden * (cfg.get("vocab_size") or hidden)
return max(int(round(total / 1e9 * 2) / 2 * 1e9), 1_000_000_000)
def build_entry(base_entry, dir_name, files, size):
entry = dict(base_entry) if base_entry else {}
entry["name"] = dir_name
entry["files"] = list(files)
entry["url"] = ""
entry["file_url"] = ""
entry["ms_url"] = ""
entry.setdefault("max_prefill_len", 4096)
entry.setdefault("default_context_length", 8192)
entry.setdefault("flm_min_version", "0.9.45")
entry.setdefault("details", {}).setdefault("format", "NPU2")
if size:
entry["size"] = size
entry["vlm"] = any(f.startswith("vision") for f in files)
return entry
def register(user_list_path, tag, entry, system_registry):
if user_list_path.is_file():
registry = load_json(user_list_path)
else:
registry = json.loads(json.dumps(system_registry))
registry.setdefault("model_path", "models")
model_type, size = tag.split(":", 1)
registry.setdefault("models", {}).setdefault(model_type, {})[size] = entry
save_json(user_list_path, registry)
# ------------------------------------------------------------------- xclbins
def link_xclbins(system_root, user_root, dir_name, source_name, force=False, quiet=False):
if not source_name:
if not quiet:
log("[WARN] No xclbin source; skipping symlink. Pass --xclbin-from NAME to link an official model's kernels.")
return
src = system_root / source_name
if not src.is_dir():
if not quiet:
log(f"[WARN] Official model has no xclbins directory: {source_name}")
return
user_root.mkdir(parents=True, exist_ok=True)
link = user_root / dir_name
target = str(src)
if link.is_symlink():
if os.readlink(link) == target:
if not quiet:
log(f"[INFO] xclbins link already in place: {link}")
return
link.unlink()
elif link.exists():
if force:
shutil.rmtree(link)
else:
raise SystemExit(
f"{link} already exists and is not a symlink. Remove it or pass --force."
)
os.symlink(target, link)
if not quiet:
log(f"[INFO] Linked xclbins: {link} -> {target}")
# ---------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(
prog="flm-add.py",
description="Install a pre-converted FLM (Q4NX) model and register it with FastFlowLM.",
)
ap.add_argument("repo", help="Hugging Face repo id (Org/Name), ModelScope id (with --modelscope), URL, or local directory")
ap.add_argument("--tag", help="Registry tag (default: derived from the repo name, e.g. qwen3.5-claude:9b)")
ap.add_argument("--family", help="details.family for engine dispatch (default: from matching official entry)")
ap.add_argument("--config", help="model_list.json to update (default: $FLM_CONFIG_PATH or ~/.config/flm/model_list.json)")
ap.add_argument("--models-root", help="models directory (default: $FLM_MODEL_PATH or ~/.config/flm/models)")
ap.add_argument("--xclbin-dir", help="user xclbins directory (default: ~/.config/flm/xclbins)")
ap.add_argument("--xclbin-from", help="official model directory name to link xclbins from (default: best match, e.g. Qwen3.6-35B-A3B-NPU2)")
ap.add_argument("--system-list", help="official model_list.json used for defaults (default: auto-detect)")
ap.add_argument("--modelscope", action="store_true", help="Treat REPO as a ModelScope repo id")
ap.add_argument("--no-xclbin", action="store_true", help="Do not create the xclbins symlink")
ap.add_argument("--no-verify", action="store_true", help="Skip sha256 verification of downloads")
ap.add_argument("--force", action="store_true", help="Overwrite existing model files/links")
ap.add_argument("--dry-run", action="store_true", help="Print the plan and exit")
ap.add_argument("--quiet", action="store_true", help="Less output")
args = ap.parse_args()
repo = args.repo
if repo.startswith(("https://", "http://")):
repo = repo.split("/", 3)[-1]
local_dir = Path(repo) if os.path.isdir(repo) else None
dir_name = local_dir.name if local_dir else repo.split("/")[-1]
if not dir_name:
raise SystemExit("Could not determine a model directory name from the repo.")
system_list = find_system_model_list()
system_registry = load_json(system_list)
user_list = user_registry_path(args.config)
models_root = models_root_dir(args.models_root)
target = models_root / dir_name
tag = derive_tag(dir_name, args.tag)
bucket, size_token = tag.split(":", 1)
official = match_official_entry(system_registry, dir_name)
base_entry = official[3] if official else None
family = derive_family(system_registry, dir_name, args.family, base_entry)
size_value = (base_entry or {}).get("size") or size_from_tag(tag)
official, official_note = resolve_official(system_registry, dir_name, family, size_value)
base_entry = official[3] if official else None
src_tag = f"{official[1]}:{official[2]}" if official else None
xclbin_source = args.xclbin_from or (base_entry or {}).get("name")
if not args.dry_run:
if official:
note = f" ({official_note})" if official_note else ""
log(f"[INFO] xclbins from official {src_tag}{note}")
else:
log("[WARN] No official model matched; no xclbins link. Pass --xclbin-from NAME (or --no-xclbin).")
if args.dry_run:
print(f"repo directory : {dir_name}")
print(f"tag : {tag}")
print(f"details.family : {family}")
print(f"official match : {src_tag or '(none)'}")
print(f"xclbin source : {xclbin_source or '(none)'}")
print(f"models dir : {target}")
print(f"registry : {user_list}")
return
# --- acquire model files ---
if local_dir:
if not args.quiet:
log(f"[INFO] Using local model directory: {local_dir}")
target.mkdir(parents=True, exist_ok=True)
files = copy_from_dir(local_dir, target, force=args.force)
else:
snapshot = None if args.modelscope else hf_cache_snapshot(repo)
if snapshot:
if not args.quiet:
log(f"[INFO] Found local HF cache: {snapshot}")
target.mkdir(parents=True, exist_ok=True)
files = copy_from_dir(snapshot, target, force=args.force)
else:
if not args.quiet:
log(f"[INFO] Downloading model files from {'ModelScope' if args.modelscope else 'Hugging Face'}: {repo}")
target.mkdir(parents=True, exist_ok=True)
files = fetch_assets(repo, target, args.modelscope, verify=not args.no_verify, force=args.force, quiet=args.quiet)
missing = [f for f in REQUIRED_FILES if not (target / f).is_file()]
if missing:
raise SystemExit(f"Model is missing required files: {missing}")
if not size_value:
size_value = estimate_size(target / "config.json")
entry = build_entry(base_entry, dir_name, files, size_value)
entry.setdefault("details", {})["family"] = family
register(user_list, tag, entry, system_registry)
log(f"[INFO] Registered tag '{tag}' in {user_list}")
if not args.no_xclbin:
system_root = find_system_xclbin_root()
if system_root is None:
log("[WARN] Could not locate system xclbins; skipped symlink.")
else:
link_xclbins(
system_root,
user_xclbin_dir(args.xclbin_dir),
dir_name,
xclbin_source,
force=args.force,
quiet=args.quiet,
)
print()
print(f"Done: {dir_name} installed to {target}")
print(f"Run: flm run {tag} (or: flm serve {tag})")
print()
print("Make sure your shell has these exports (add to ~/.bashrc):")
print(' export FLM_CONFIG_PATH="$HOME/.config/flm/model_list.json"')
print(' export FLM_XCLBIN_PATH="$HOME/.config/flm"')
if __name__ == "__main__":
main()