Atomic-Germ commited on
Commit
f47fc40
·
verified ·
1 Parent(s): c11406f

Upload flm-add.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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()