Atomic-Germ commited on
Commit
0a203b6
·
verified ·
1 Parent(s): 83d636a

Upload flm-add.py with huggingface_hub

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