infer-stack 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. infer_stack/__init__.py +2 -0
  2. infer_stack/backends/__init__.py +7 -0
  3. infer_stack/backends/compose_renderer.py +243 -0
  4. infer_stack/backends/kubeai_renderer.py +202 -0
  5. infer_stack/benchmark.py +38 -0
  6. infer_stack/catalog.py +438 -0
  7. infer_stack/cli/__init__.py +169 -0
  8. infer_stack/cli/__main__.py +4 -0
  9. infer_stack/cli/commands_profile.py +467 -0
  10. infer_stack/cli/commands_runtime.py +719 -0
  11. infer_stack/cli/commands_smoke.py +691 -0
  12. infer_stack/cli/compose.py +755 -0
  13. infer_stack/cli/context.py +471 -0
  14. infer_stack/cli/options.py +134 -0
  15. infer_stack/cli/probes.py +178 -0
  16. infer_stack/config.py +450 -0
  17. infer_stack/contracts.py +223 -0
  18. infer_stack/diff_prompt.py +117 -0
  19. infer_stack/docker_utils.py +230 -0
  20. infer_stack/env_utils.py +97 -0
  21. infer_stack/experimental/model_catalog_discover.py +1155 -0
  22. infer_stack/experimental/model_memory_estimator.py +1264 -0
  23. infer_stack/experimental/stress_test_long_context.py +397 -0
  24. infer_stack/hardware.py +70 -0
  25. infer_stack/kubeai_ops.py +76 -0
  26. infer_stack/paths.py +87 -0
  27. infer_stack/profile_runtime.py +46 -0
  28. infer_stack/renderer.py +19 -0
  29. infer_stack/resolver.py +1092 -0
  30. infer_stack/templates/default-models.yaml +674 -0
  31. infer_stack/templates/default-ollama-models.yaml +31 -0
  32. infer_stack/templates/default-profiles.yaml +1731 -0
  33. infer_stack/templates/default-vllm-models.yaml +714 -0
  34. infer_stack/templates/docker-compose.yml.j2 +430 -0
  35. infer_stack/templates/litellm_config.yaml.j2 +44 -0
  36. infer_stack/templates/nginx.conf.j2 +84 -0
  37. infer_stack/tuning.py +3 -0
  38. infer_stack/validator.py +314 -0
  39. infer_stack/verification.py +46 -0
  40. infer_stack-0.6.0.dist-info/METADATA +1034 -0
  41. infer_stack-0.6.0.dist-info/RECORD +44 -0
  42. infer_stack-0.6.0.dist-info/WHEEL +5 -0
  43. infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
  44. infer_stack-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1155 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone CLI to discover Hugging Face model metadata and append new model
3
+ entries into a infer_stack-style models.yaml manifest.
4
+
5
+ Highlights
6
+ ----------
7
+ - no-arg mode defaults to `refresh`
8
+ - uses the existing models.yaml as a cache of already-known hf_model_id values
9
+ and as a source of stems to expand latest-family siblings
10
+ - appends only new entries under top-level `models:`
11
+ - shows a unified diff and asks for confirmation unless `--yes` is passed
12
+ - refresh unions multiple ranking buckets (newest, updated, popular, downloads)
13
+ - quantized and unquantized are separate final buckets, and both are kept
14
+ - expands sibling variants for the latest stems so a hit like
15
+ `Qwen/Qwen3.6-35B-A3B-FP8` also pulls `Qwen/Qwen3.6-35B-A3B`
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import difflib
22
+ import io
23
+ import json
24
+ import os
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from rich.console import Console
31
+ from rich.panel import Panel
32
+ from rich.prompt import Confirm
33
+ from rich.syntax import Syntax
34
+ from rich.table import Table
35
+ from ruamel.yaml import YAML
36
+ from ruamel.yaml.comments import CommentedMap
37
+ from huggingface_hub import HfApi, hf_hub_download
38
+
39
+
40
+ PROGRAM_NAME = 'model_catalog_discover'
41
+ PROGRAM_VERSION = '0.6.2'
42
+
43
+ console = Console()
44
+ yaml_rt = YAML()
45
+ yaml_rt.preserve_quotes = True
46
+ yaml_rt.default_flow_style = False
47
+ yaml_rt.width = 1000
48
+
49
+ DEFAULT_AUTHORS = [
50
+ 'Qwen',
51
+ # "meta-llama",
52
+ # "mistralai",
53
+ # "google",
54
+ # "microsoft",
55
+ # "ibm-granite",
56
+ ]
57
+ DEFAULT_LIMIT = 0
58
+ DEFAULT_PER_CATEGORY_FETCH = 24
59
+ DEFAULT_FAMILY_EXPANSION_FETCH = 60
60
+ DEFAULT_PER_FAMILY_LIMIT = 0
61
+
62
+ TEXTY_PIPELINES = {
63
+ 'text-generation',
64
+ 'image-text-to-text',
65
+ 'text2text-generation',
66
+ 'conversational',
67
+ '',
68
+ None,
69
+ }
70
+ REJECT_NAME_PATTERNS = [
71
+ r'(^|[-_/])gguf($|[-_/])',
72
+ r'(^|[-_/])mlx($|[-_/])',
73
+ r'(^|[-_/])exl2($|[-_/])',
74
+ r'(^|[-_/])lora($|[-_/])',
75
+ r'(^|[-_/])adapter($|[-_/])',
76
+ r'(^|[-_/])merged?($|[-_/])',
77
+ ]
78
+ PREFER_NAME_PATTERNS = [
79
+ r'instruct',
80
+ r'chat',
81
+ r'assistant',
82
+ r'reason',
83
+ r'coder',
84
+ r'vl',
85
+ ]
86
+ QUANT_TOKENS = [
87
+ 'fp8',
88
+ 'awq',
89
+ 'gptq',
90
+ 'int8',
91
+ 'int4',
92
+ '4bit',
93
+ '8bit',
94
+ 'bnb',
95
+ 'bitsandbytes',
96
+ 'quantized',
97
+ ]
98
+ CATEGORY_SPECS = [
99
+ ('newest', 'created_at'),
100
+ ('updated', 'last_modified'),
101
+ ('popular', 'likes'),
102
+ ('downloads', 'downloads'),
103
+ ]
104
+
105
+
106
+ @dataclass
107
+ class Candidate:
108
+ repo_id: str
109
+ author: str | None
110
+ downloads: int | None
111
+ likes: int | None
112
+ last_modified: str | None
113
+ created_at: str | None
114
+ tags: list[str]
115
+ pipeline_tag: str | None
116
+ score: float
117
+ reasons: list[str]
118
+ family: str
119
+ quantization: str
120
+ variant_stem: str
121
+ bucket_hits: list[str] = field(default_factory=list)
122
+ rejected_reason: str | None = None
123
+
124
+
125
+ @dataclass
126
+ class DiscoverResult:
127
+ repo_id: str
128
+ model_key: str
129
+ entry: dict[str, Any]
130
+ facts: dict[str, Any]
131
+ warnings: list[str] = field(default_factory=list)
132
+
133
+
134
+ class HubInspector:
135
+ def __init__(self, token: str | None = None) -> None:
136
+ self.api = HfApi(token=token)
137
+ self.token = token
138
+
139
+ def list_models(
140
+ self,
141
+ *,
142
+ author: str | None = None,
143
+ search: str | None = None,
144
+ sort: str | None = None,
145
+ direction: int = -1,
146
+ limit: int = 20,
147
+ ) -> list[Any]:
148
+ kwargs: dict[str, Any] = {'limit': limit}
149
+ if author:
150
+ kwargs['author'] = author
151
+ if search:
152
+ kwargs['search'] = search
153
+ if sort:
154
+ kwargs['sort'] = sort
155
+ kwargs['direction'] = direction
156
+ try:
157
+ return list(self.api.list_models(**kwargs))
158
+ except TypeError:
159
+ fallback = {
160
+ k: v
161
+ for k, v in kwargs.items()
162
+ if k not in {'sort', 'direction'}
163
+ }
164
+ return list(self.api.list_models(**fallback))
165
+
166
+ def model_info(self, repo_id: str) -> Any:
167
+ attempts = [
168
+ {
169
+ 'expand': [
170
+ 'cardData',
171
+ 'config',
172
+ 'transformersInfo',
173
+ 'siblings',
174
+ 'tags',
175
+ 'pipeline_tag',
176
+ 'downloads',
177
+ 'likes',
178
+ 'createdAt',
179
+ 'lastModified',
180
+ 'safetensors',
181
+ ]
182
+ },
183
+ {'expand': ['cardData', 'config', 'transformersInfo', 'siblings']},
184
+ {'files_metadata': True},
185
+ {},
186
+ ]
187
+ last_error: Exception | None = None
188
+ for kwargs in attempts:
189
+ try:
190
+ return self.api.model_info(repo_id, **kwargs)
191
+ except (TypeError, ValueError) as ex:
192
+ last_error = ex
193
+ continue
194
+ except Exception as ex:
195
+ last_error = ex
196
+ continue
197
+ if last_error:
198
+ raise last_error
199
+ raise RuntimeError(f'Unable to query model info for {repo_id}')
200
+
201
+ def safetensors_metadata(self, repo_id: str) -> Any | None:
202
+ getter = getattr(self.api, 'get_safetensors_metadata', None)
203
+ if getter is None:
204
+ return None
205
+ try:
206
+ return getter(repo_id)
207
+ except Exception:
208
+ return None
209
+
210
+ def download_json(
211
+ self, repo_id: str, filename: str
212
+ ) -> dict[str, Any] | None:
213
+ try:
214
+ path = hf_hub_download(repo_id, filename=filename, token=self.token)
215
+ except Exception:
216
+ return None
217
+ try:
218
+ return json.loads(Path(path).read_text(encoding='utf-8'))
219
+ except Exception:
220
+ return None
221
+
222
+
223
+ def _to_dict(obj: Any) -> dict[str, Any]:
224
+ if isinstance(obj, dict):
225
+ return obj
226
+ data = getattr(obj, '__dict__', None)
227
+ if isinstance(data, dict):
228
+ return dict(data)
229
+ return {
230
+ name: getattr(obj, name)
231
+ for name in dir(obj)
232
+ if not name.startswith('_') and not callable(getattr(obj, name))
233
+ }
234
+
235
+
236
+ def _as_list(value: Any) -> list[Any]:
237
+ if value is None:
238
+ return []
239
+ if isinstance(value, list):
240
+ return value
241
+ if isinstance(value, tuple):
242
+ return list(value)
243
+ return [value]
244
+
245
+
246
+ def _slugify(text: str) -> str:
247
+ text = text.strip().lower()
248
+ text = re.sub(r'[^a-z0-9]+', '-', text)
249
+ return re.sub(r'-+', '-', text).strip('-')
250
+
251
+
252
+ def _strip_known_quant_suffix(model_name: str) -> str:
253
+ out = model_name
254
+ changed = True
255
+ while changed:
256
+ changed = False
257
+ for token in QUANT_TOKENS:
258
+ pattern = re.compile(rf'([._-]){re.escape(token)}$', flags=re.I)
259
+ newer = pattern.sub('', out)
260
+ if newer != out:
261
+ out = newer
262
+ changed = True
263
+ return out
264
+
265
+
266
+ def _is_quantized(repo_id: str, tags: list[str]) -> bool:
267
+ lowered = repo_id.lower()
268
+ tagset = {str(t).lower() for t in tags}
269
+ return any(token in lowered for token in QUANT_TOKENS) or any(
270
+ token in tagset for token in QUANT_TOKENS
271
+ )
272
+
273
+
274
+ def _infer_family(repo_id: str, tags: list[str], config: dict[str, Any]) -> str:
275
+ lowered = repo_id.lower()
276
+ patterns = [
277
+ r'(qwen\d+(?:\.\d+)?)',
278
+ r'(gemma\d+)',
279
+ r'(llama[- ]?\d+(?:\.\d+)?)',
280
+ r'(mistral)',
281
+ r'(granite)',
282
+ r'(gpt-oss)',
283
+ r'(phi[- ]?\d+)',
284
+ ]
285
+ for pattern in patterns:
286
+ match = re.search(pattern, lowered)
287
+ if match:
288
+ value = match.group(1)
289
+ return (
290
+ value.replace(' ', '').replace('-', '')
291
+ if 'llama' in value
292
+ else value
293
+ )
294
+ model_type = str(config.get('model_type') or '').strip().lower()
295
+ if model_type:
296
+ return model_type
297
+ return lowered.split('/')[-1].split('-')[0]
298
+
299
+
300
+ def _manifest_has_model(
301
+ doc: dict[str, Any], model_key: str, hf_model_id: str
302
+ ) -> bool:
303
+ models = doc.get('models') or {}
304
+ if model_key in models:
305
+ return True
306
+ for _, value in models.items():
307
+ if (
308
+ isinstance(value, dict)
309
+ and str(value.get('hf_model_id') or '') == hf_model_id
310
+ ):
311
+ return True
312
+ return False
313
+
314
+
315
+ def _load_manifest(path: Path) -> CommentedMap:
316
+ if path.exists():
317
+ data = yaml_rt.load(path.read_text(encoding='utf-8'))
318
+ if data is None:
319
+ data = CommentedMap()
320
+ if not isinstance(data, CommentedMap):
321
+ data = CommentedMap(data)
322
+ else:
323
+ data = CommentedMap()
324
+ if 'models' not in data or data['models'] is None:
325
+ data['models'] = CommentedMap()
326
+ if not isinstance(data['models'], CommentedMap):
327
+ data['models'] = CommentedMap(data['models'])
328
+ return data
329
+
330
+
331
+ def _manifest_repo_ids(doc: dict[str, Any]) -> set[str]:
332
+ found: set[str] = set()
333
+ for _, value in (doc.get('models') or {}).items():
334
+ if isinstance(value, dict):
335
+ repo_id = str(value.get('hf_model_id') or '')
336
+ if repo_id:
337
+ found.add(repo_id)
338
+ return found
339
+
340
+
341
+ def _manifest_variant_stems(doc: dict[str, Any]) -> set[tuple[str, str]]:
342
+ found: set[tuple[str, str]] = set()
343
+ for _, value in (doc.get('models') or {}).items():
344
+ if not isinstance(value, dict):
345
+ continue
346
+ repo_id = str(value.get('hf_model_id') or '')
347
+ if '/' not in repo_id:
348
+ continue
349
+ author, name = repo_id.split('/', 1)
350
+ found.add((author, _strip_known_quant_suffix(name)))
351
+ return found
352
+
353
+
354
+ def _render_yaml(doc: CommentedMap) -> str:
355
+ buf = io.StringIO()
356
+ yaml_rt.dump(doc, buf)
357
+ return buf.getvalue()
358
+
359
+
360
+ def _render_diff(before: str, after: str, manifest_path: Path) -> str:
361
+ return ''.join(
362
+ difflib.unified_diff(
363
+ before.splitlines(keepends=True),
364
+ after.splitlines(keepends=True),
365
+ fromfile=f'{manifest_path}.before',
366
+ tofile=str(manifest_path),
367
+ )
368
+ )
369
+
370
+
371
+ def _apply_with_confirmation(
372
+ *,
373
+ manifest_path: Path,
374
+ original_doc: CommentedMap,
375
+ updated_doc: CommentedMap,
376
+ yes: bool,
377
+ ) -> bool:
378
+ before = _render_yaml(original_doc)
379
+ after = _render_yaml(updated_doc)
380
+ if before == after:
381
+ console.print(
382
+ Panel('No changes to apply.', title='Diff', border_style='yellow')
383
+ )
384
+ return False
385
+ diff = _render_diff(before, after, manifest_path)
386
+ console.print(Syntax(diff, 'diff', theme='ansi_dark', word_wrap=False))
387
+ if not yes and not Confirm.ask('Apply this diff?'):
388
+ console.print(
389
+ Panel(
390
+ 'Aborted. Manifest not modified.',
391
+ title='Apply',
392
+ border_style='yellow',
393
+ )
394
+ )
395
+ return False
396
+ manifest_path.write_text(after, encoding='utf-8')
397
+ console.print(
398
+ Panel(f'Wrote {manifest_path}', title='Apply', border_style='green')
399
+ )
400
+ return True
401
+
402
+
403
+ def _score_candidate(
404
+ model: dict[str, Any], mode: str
405
+ ) -> tuple[float, list[str], str | None]:
406
+ repo_id = str(model.get('id') or model.get('modelId') or '')
407
+ lowered = repo_id.lower()
408
+ tags = [str(t).lower() for t in _as_list(model.get('tags'))]
409
+ pipeline_tag = model.get('pipeline_tag') or model.get('pipelineTag')
410
+ author = str(model.get('author') or '')
411
+ downloads = int(model.get('downloads') or 0)
412
+ likes = int(model.get('likes') or 0)
413
+
414
+ if pipeline_tag not in TEXTY_PIPELINES:
415
+ return (
416
+ 0.0,
417
+ [],
418
+ f"pipeline_tag '{pipeline_tag}' is outside default text-focused policy",
419
+ )
420
+
421
+ for pattern in REJECT_NAME_PATTERNS:
422
+ if re.search(pattern, lowered):
423
+ return (
424
+ 0.0,
425
+ [],
426
+ f"repo name matches excluded artifact pattern '{pattern}'",
427
+ )
428
+
429
+ reasons: list[str] = []
430
+ score = 0.0
431
+ if author:
432
+ reasons.append(f'author={author}')
433
+ if downloads:
434
+ score += min(downloads, 2_000_000) / 20_000.0
435
+ reasons.append(f'downloads={downloads}')
436
+ if likes:
437
+ score += min(likes, 100_000) / 1000.0
438
+ reasons.append(f'likes={likes}')
439
+ if pipeline_tag:
440
+ score += 10.0
441
+ if mode == 'frontier':
442
+ freshness = str(
443
+ model.get('created_at')
444
+ or model.get('createdAt')
445
+ or model.get('last_modified')
446
+ or model.get('lastModified')
447
+ or ''
448
+ )
449
+ if freshness:
450
+ score += 15.0
451
+ reasons.append('frontier+freshness')
452
+ for pattern in PREFER_NAME_PATTERNS:
453
+ if re.search(pattern, lowered):
454
+ score += 5.0
455
+ reasons.append(f'name~/{pattern}/')
456
+ if _is_quantized(repo_id, tags):
457
+ score += 1.0
458
+ reasons.append('quantized')
459
+ else:
460
+ score += 2.0
461
+ reasons.append('unquantized')
462
+ return score, reasons, None
463
+
464
+
465
+ def _candidate_from_model(
466
+ model: Any, mode: str, bucket_name: str | None = None
467
+ ) -> Candidate:
468
+ mdict = _to_dict(model)
469
+ repo_id = str(mdict.get('id') or mdict.get('modelId') or '')
470
+ tags = [str(t) for t in _as_list(mdict.get('tags'))]
471
+ _raw_config = mdict.get('config')
472
+ config: dict[str, Any] = _raw_config if isinstance(_raw_config, dict) else {}
473
+ score, reasons, rejected_reason = _score_candidate(mdict, mode)
474
+ author = str(mdict.get('author') or '') or None
475
+ family = _infer_family(repo_id, tags, config)
476
+ model_name = repo_id.split('/', 1)[-1]
477
+ return Candidate(
478
+ repo_id=repo_id,
479
+ author=author,
480
+ downloads=mdict.get('downloads'),
481
+ likes=mdict.get('likes'),
482
+ last_modified=str(
483
+ mdict.get('last_modified') or mdict.get('lastModified') or ''
484
+ )
485
+ or None,
486
+ created_at=str(mdict.get('created_at') or mdict.get('createdAt') or '')
487
+ or None,
488
+ tags=tags,
489
+ pipeline_tag=mdict.get('pipeline_tag') or mdict.get('pipelineTag'),
490
+ score=score,
491
+ reasons=reasons,
492
+ family=family,
493
+ quantization='quantized'
494
+ if _is_quantized(repo_id, tags)
495
+ else 'unquantized',
496
+ variant_stem=_strip_known_quant_suffix(model_name),
497
+ bucket_hits=[bucket_name] if bucket_name else [],
498
+ rejected_reason=rejected_reason,
499
+ )
500
+
501
+
502
+ def _merge_bucket_hits(into: Candidate, other: Candidate) -> None:
503
+ for hit in other.bucket_hits:
504
+ if hit not in into.bucket_hits:
505
+ into.bucket_hits.append(hit)
506
+ into.score = max(into.score, other.score)
507
+
508
+
509
+ def _pick_refresh_candidates(
510
+ inspector: HubInspector,
511
+ *,
512
+ authors: list[str],
513
+ limit: int,
514
+ mode: str,
515
+ per_category_fetch: int,
516
+ per_family_limit: int,
517
+ existing_repo_ids: set[str],
518
+ ) -> tuple[list[Candidate], list[Candidate]]:
519
+ accepted_by_repo: dict[str, Candidate] = {}
520
+ rejected: list[Candidate] = []
521
+ family_counts: dict[tuple[str | None, str, str], int] = {}
522
+
523
+ for bucket_name, sort_key in CATEGORY_SPECS:
524
+ for author in authors:
525
+ try:
526
+ models = inspector.list_models(
527
+ author=author,
528
+ sort=sort_key,
529
+ direction=-1,
530
+ limit=per_category_fetch,
531
+ )
532
+ except Exception as ex:
533
+ rejected.append(
534
+ Candidate(
535
+ repo_id=f'<search:{author}:{bucket_name}>',
536
+ author=author,
537
+ downloads=None,
538
+ likes=None,
539
+ last_modified=None,
540
+ created_at=None,
541
+ tags=[],
542
+ pipeline_tag=None,
543
+ score=0.0,
544
+ reasons=[],
545
+ family='',
546
+ quantization='unknown',
547
+ variant_stem='',
548
+ bucket_hits=[bucket_name],
549
+ rejected_reason=str(ex),
550
+ )
551
+ )
552
+ continue
553
+ for model in models:
554
+ cand = _candidate_from_model(model, mode, bucket_name)
555
+ if not cand.repo_id or cand.repo_id in existing_repo_ids:
556
+ continue
557
+ if cand.rejected_reason is not None:
558
+ rejected.append(cand)
559
+ continue
560
+ key = (cand.author, cand.family, cand.quantization)
561
+ if (
562
+ per_family_limit
563
+ and per_family_limit > 0
564
+ and family_counts.get(key, 0) >= per_family_limit
565
+ and cand.repo_id not in accepted_by_repo
566
+ ):
567
+ continue
568
+ if cand.repo_id in accepted_by_repo:
569
+ _merge_bucket_hits(accepted_by_repo[cand.repo_id], cand)
570
+ else:
571
+ accepted_by_repo[cand.repo_id] = cand
572
+ family_counts[key] = family_counts.get(key, 0) + 1
573
+
574
+ ordered = sorted(
575
+ accepted_by_repo.values(),
576
+ key=lambda c: (
577
+ len(c.bucket_hits),
578
+ c.score,
579
+ c.downloads or 0,
580
+ c.likes or 0,
581
+ c.created_at or '',
582
+ c.repo_id,
583
+ ),
584
+ reverse=True,
585
+ )
586
+ if limit and limit > 0:
587
+ by_lane: dict[str, list[Candidate]] = {
588
+ 'unquantized': [],
589
+ 'quantized': [],
590
+ }
591
+ for cand in ordered:
592
+ lane = by_lane.setdefault(cand.quantization, [])
593
+ if len(lane) < limit:
594
+ lane.append(cand)
595
+ accepted = by_lane.get('unquantized', []) + by_lane.get('quantized', [])
596
+ else:
597
+ accepted = ordered
598
+ return accepted, rejected
599
+
600
+
601
+ def _expand_family_variants(
602
+ inspector: HubInspector,
603
+ *,
604
+ seeds: list[Candidate],
605
+ cached_stems: set[tuple[str, str]],
606
+ existing_repo_ids: set[str],
607
+ mode: str,
608
+ family_expansion_fetch: int,
609
+ ) -> list[Candidate]:
610
+ expanded: dict[str, Candidate] = {c.repo_id: c for c in seeds}
611
+ search_jobs: set[tuple[str, str, str]] = set()
612
+
613
+ for cand in seeds:
614
+ if cand.author:
615
+ search_jobs.add((cand.author, cand.family, cand.variant_stem))
616
+
617
+ for author, stem in sorted(cached_stems):
618
+ inferred_family = _infer_family(f'{author}/{stem}', [], {})
619
+ search_jobs.add((author, inferred_family, stem))
620
+
621
+ for author, family, stem in sorted(search_jobs):
622
+ search_terms = []
623
+ for term in (stem, family):
624
+ term = (term or '').strip()
625
+ if term and term not in search_terms:
626
+ search_terms.append(term)
627
+
628
+ for term in search_terms:
629
+ try:
630
+ models = inspector.list_models(
631
+ author=author, search=term, limit=family_expansion_fetch
632
+ )
633
+ except Exception:
634
+ continue
635
+ for model in models:
636
+ cand = _candidate_from_model(
637
+ model, mode, bucket_name='family-expand'
638
+ )
639
+ if (
640
+ cand.repo_id in existing_repo_ids
641
+ or cand.rejected_reason is not None
642
+ or cand.author != author
643
+ ):
644
+ continue
645
+
646
+ repo_name = cand.repo_id.split('/', 1)[-1]
647
+ same_stem = (
648
+ cand.variant_stem == stem
649
+ or repo_name == stem
650
+ or repo_name.startswith(stem + '-')
651
+ or repo_name.startswith(stem + '.')
652
+ )
653
+ if cand.family != family or not same_stem:
654
+ continue
655
+
656
+ if cand.repo_id in expanded:
657
+ _merge_bucket_hits(expanded[cand.repo_id], cand)
658
+ else:
659
+ expanded[cand.repo_id] = cand
660
+
661
+ return sorted(
662
+ expanded.values(),
663
+ key=lambda c: (
664
+ c.author or '',
665
+ c.variant_stem,
666
+ 0 if c.quantization == 'unquantized' else 1,
667
+ -len(c.bucket_hits),
668
+ -(c.downloads or 0),
669
+ c.repo_id,
670
+ ),
671
+ )
672
+
673
+
674
+ def _print_candidate_table(candidates: list[Candidate], title: str) -> None:
675
+ table = Table(title=title)
676
+ table.add_column('repo')
677
+ table.add_column('family')
678
+ table.add_column('quant')
679
+ table.add_column('buckets')
680
+ table.add_column('downloads', justify='right')
681
+ table.add_column('likes', justify='right')
682
+ for item in candidates:
683
+ table.add_row(
684
+ item.repo_id,
685
+ item.family,
686
+ item.quantization,
687
+ ','.join(item.bucket_hits),
688
+ str(item.downloads or ''),
689
+ str(item.likes or ''),
690
+ )
691
+ console.print(table)
692
+
693
+
694
+ def _collect_numeric_candidates(
695
+ obj: Any, keys: set[str], out: list[int]
696
+ ) -> None:
697
+ if isinstance(obj, dict):
698
+ for k, v in obj.items():
699
+ if str(k).lower() in keys and isinstance(v, (int, float)):
700
+ out.append(int(v))
701
+ else:
702
+ _collect_numeric_candidates(v, keys, out)
703
+ elif isinstance(obj, list):
704
+ for item in obj:
705
+ _collect_numeric_candidates(item, keys, out)
706
+
707
+
708
+ def _infer_context_window(
709
+ config: dict[str, Any],
710
+ transformers_info: dict[str, Any],
711
+ generation_config: dict[str, Any],
712
+ ) -> int | None:
713
+ candidates: list[int] = []
714
+ _collect_numeric_candidates(
715
+ config,
716
+ {
717
+ 'max_position_embeddings',
718
+ 'n_positions',
719
+ 'seq_length',
720
+ 'context_length',
721
+ 'max_seq_len',
722
+ 'model_max_length',
723
+ },
724
+ candidates,
725
+ )
726
+ _collect_numeric_candidates(
727
+ transformers_info,
728
+ {'max_position_embeddings', 'model_max_length'},
729
+ candidates,
730
+ )
731
+ _collect_numeric_candidates(generation_config, {'max_length'}, candidates)
732
+ candidates = [x for x in candidates if x and x > 0]
733
+ return max(candidates) if candidates else None
734
+
735
+
736
+ def _infer_modalities(
737
+ *,
738
+ tags: list[str],
739
+ pipeline_tag: str | None,
740
+ config: dict[str, Any],
741
+ siblings: list[str],
742
+ ) -> list[str]:
743
+ tagset = {t.lower() for t in tags}
744
+ names = ' '.join(
745
+ [
746
+ str(pipeline_tag or '').lower(),
747
+ *sorted(tagset),
748
+ *(s.lower() for s in siblings),
749
+ ]
750
+ )
751
+ arch_names = ' '.join(
752
+ str(x).lower() for x in _as_list(config.get('architectures'))
753
+ )
754
+ modalities: list[str] = ['text']
755
+ if (
756
+ any(
757
+ tok in names
758
+ for tok in [
759
+ 'image-text-to-text',
760
+ 'vision',
761
+ 'vl',
762
+ 'multimodal',
763
+ 'image_processor',
764
+ 'processor_config.json',
765
+ ]
766
+ )
767
+ or 'vision' in arch_names
768
+ ):
769
+ modalities.insert(0, 'image')
770
+ return modalities
771
+
772
+
773
+ def _estimate_memory_hints(metadata: Any | None) -> dict[str, Any]:
774
+ if metadata is None:
775
+ return {}
776
+ mdict = _to_dict(metadata)
777
+ hints: dict[str, Any] = {}
778
+ total = mdict.get('total') or mdict.get('total_size')
779
+ if isinstance(total, (int, float)) and total > 0:
780
+ gib = max(1, round(float(total) / (1024**3)))
781
+ hints['min_vram_gib_per_replica'] = gib
782
+ hints['memory_class_gib'] = gib
783
+ return hints
784
+
785
+
786
+ def _discover_repo(
787
+ inspector: HubInspector,
788
+ repo_id: str,
789
+ *,
790
+ include_memory_hints: bool = False,
791
+ model_key_override: str | None = None,
792
+ ) -> DiscoverResult:
793
+ info = _to_dict(inspector.model_info(repo_id))
794
+ tags = [str(t) for t in _as_list(info.get('tags'))]
795
+ _raw_config = info.get('config')
796
+ config: dict[str, Any] = _raw_config if isinstance(_raw_config, dict) else {}
797
+ _raw_tinfo = info.get('transformersInfo')
798
+ transformers_info: dict[str, Any] = _raw_tinfo if isinstance(_raw_tinfo, dict) else {}
799
+ pipeline_tag = info.get('pipeline_tag') or info.get('pipelineTag')
800
+ siblings_raw = _as_list(info.get('siblings'))
801
+ siblings = []
802
+ for item in siblings_raw:
803
+ if isinstance(item, dict):
804
+ siblings.append(
805
+ str(item.get('rfilename') or item.get('path') or '')
806
+ )
807
+ else:
808
+ siblings.append(
809
+ str(
810
+ getattr(item, 'rfilename', None)
811
+ or getattr(item, 'path', None)
812
+ or item
813
+ )
814
+ )
815
+ generation_config = (
816
+ inspector.download_json(repo_id, 'generation_config.json') or {}
817
+ )
818
+ tokenizer_config = (
819
+ inspector.download_json(repo_id, 'tokenizer_config.json') or {}
820
+ )
821
+ safetensors = inspector.safetensors_metadata(repo_id)
822
+
823
+ family = _infer_family(repo_id, tags, config)
824
+ modalities = _infer_modalities(
825
+ tags=tags, pipeline_tag=pipeline_tag, config=config, siblings=siblings
826
+ )
827
+ context_window = _infer_context_window(
828
+ config, transformers_info, generation_config
829
+ )
830
+
831
+ author, name = repo_id.split('/', 1)
832
+ model_key = model_key_override or _slugify(name)
833
+ logical_model_name = f'{author.lower()}/{model_key}'
834
+ entry: dict[str, Any] = {
835
+ 'hf_model_id': repo_id,
836
+ 'url': f'hf://{repo_id}',
837
+ 'family': family,
838
+ 'modalities': modalities,
839
+ 'tokenizer_name': tokenizer_config.get('name_or_path') or repo_id,
840
+ 'logical_model_name': logical_model_name,
841
+ 'served_model_name': logical_model_name,
842
+ 'defaults': {
843
+ 'gpu_memory_utilization': 0.9,
844
+ 'enable_prefix_caching': True,
845
+ },
846
+ 'notes': [
847
+ f'Generated by {PROGRAM_NAME} {PROGRAM_VERSION} from Hugging Face metadata.',
848
+ 'Review local serving policy separately: resource profile, topology, VRAM, and runtime concurrency stay operator-owned.',
849
+ ],
850
+ }
851
+ license_hint = next(
852
+ (t for t in tags if t.lower().startswith('license:')), None
853
+ ) or config.get('license')
854
+ if license_hint:
855
+ entry['notes'].append(
856
+ f'Upstream license hint: {str(license_hint).split(":", 1)[-1]}.'
857
+ )
858
+ if context_window:
859
+ entry['context_window'] = context_window
860
+ if context_window >= 262144:
861
+ entry['defaults']['max_model_len'] = 65536
862
+ entry['defaults']['max_num_batched_tokens'] = 4096
863
+ entry['defaults']['max_num_seqs'] = 4
864
+ elif context_window >= 65536:
865
+ entry['defaults']['max_model_len'] = 65536
866
+ entry['defaults']['max_num_batched_tokens'] = 4096
867
+ entry['defaults']['max_num_seqs'] = 8
868
+ else:
869
+ entry['defaults']['max_model_len'] = min(context_window, 32768)
870
+ entry['defaults']['max_num_batched_tokens'] = 8192
871
+ entry['defaults']['max_num_seqs'] = 16
872
+ if include_memory_hints:
873
+ for k, v in _estimate_memory_hints(safetensors).items():
874
+ entry[k] = v
875
+
876
+ return DiscoverResult(
877
+ repo_id=repo_id,
878
+ model_key=model_key,
879
+ entry=entry,
880
+ facts={
881
+ 'repo_id': repo_id,
882
+ 'tags': tags,
883
+ 'pipeline_tag': pipeline_tag,
884
+ 'context_window': context_window,
885
+ 'siblings': siblings,
886
+ },
887
+ )
888
+
889
+
890
+ def _run_add(
891
+ *,
892
+ manifest_path: Path,
893
+ repo_ids: list[str],
894
+ token: str | None,
895
+ include_memory_hints: bool,
896
+ yes: bool,
897
+ model_key: str | None = None,
898
+ ) -> int:
899
+ inspector = HubInspector(token=token)
900
+ original_doc = _load_manifest(manifest_path)
901
+ updated_doc = _load_manifest(manifest_path)
902
+ added = 0
903
+ skipped = 0
904
+ for repo_id in repo_ids:
905
+ result = _discover_repo(
906
+ inspector,
907
+ repo_id,
908
+ include_memory_hints=include_memory_hints,
909
+ model_key_override=(model_key if len(repo_ids) == 1 else None),
910
+ )
911
+ if _manifest_has_model(updated_doc, result.model_key, result.repo_id):
912
+ console.print(f'[yellow]skip[/yellow] {repo_id} (already present)')
913
+ skipped += 1
914
+ continue
915
+ updated_doc['models'][result.model_key] = CommentedMap(result.entry)
916
+ added += 1
917
+ if added == 0:
918
+ console.print(
919
+ Panel(
920
+ f'No new models to add. Skipped {skipped}.',
921
+ title='Add',
922
+ border_style='yellow',
923
+ )
924
+ )
925
+ return 0
926
+ _apply_with_confirmation(
927
+ manifest_path=manifest_path,
928
+ original_doc=original_doc,
929
+ updated_doc=updated_doc,
930
+ yes=yes,
931
+ )
932
+ return 0
933
+
934
+
935
+ def cmd_search(args: argparse.Namespace) -> int:
936
+ token = getattr(args, 'token', None) or os.environ.get('HF_TOKEN')
937
+ inspector = HubInspector(token=token)
938
+ models = inspector.list_models(
939
+ author=args.author, search=args.search, limit=args.limit
940
+ )
941
+ table = Table(title='Hugging Face model search')
942
+ table.add_column('repo')
943
+ table.add_column('downloads', justify='right')
944
+ table.add_column('likes', justify='right')
945
+ for model in models:
946
+ mdict = _to_dict(model)
947
+ table.add_row(
948
+ str(mdict.get('id') or mdict.get('modelId') or ''),
949
+ str(mdict.get('downloads') or ''),
950
+ str(mdict.get('likes') or ''),
951
+ )
952
+ console.print(table)
953
+ return 0
954
+
955
+
956
+ def cmd_add(args: argparse.Namespace) -> int:
957
+ token = getattr(args, 'token', None) or os.environ.get('HF_TOKEN')
958
+ return _run_add(
959
+ manifest_path=Path(args.manifest),
960
+ repo_ids=args.repo_ids,
961
+ token=token,
962
+ include_memory_hints=args.include_memory_hints,
963
+ yes=args.yes,
964
+ model_key=args.model_key,
965
+ )
966
+
967
+
968
+ def cmd_refresh(args: argparse.Namespace) -> int:
969
+ token = getattr(args, 'token', None) or os.environ.get('HF_TOKEN')
970
+ inspector = HubInspector(token=token)
971
+ manifest_path = Path(getattr(args, 'manifest', 'models.yaml'))
972
+ manifest_doc = _load_manifest(manifest_path)
973
+ existing_repo_ids = _manifest_repo_ids(manifest_doc)
974
+ cached_stems = _manifest_variant_stems(manifest_doc)
975
+
976
+ accepted, rejected = _pick_refresh_candidates(
977
+ inspector,
978
+ authors=list(getattr(args, 'authors', None) or DEFAULT_AUTHORS),
979
+ limit=getattr(args, 'limit', DEFAULT_LIMIT),
980
+ mode='frontier' if getattr(args, 'frontier', False) else 'stable',
981
+ per_category_fetch=getattr(
982
+ args, 'per_category_fetch', DEFAULT_PER_CATEGORY_FETCH
983
+ ),
984
+ per_family_limit=getattr(
985
+ args, 'per_family_limit', DEFAULT_PER_FAMILY_LIMIT
986
+ ),
987
+ existing_repo_ids=existing_repo_ids,
988
+ )
989
+ expanded = _expand_family_variants(
990
+ inspector,
991
+ seeds=accepted,
992
+ cached_stems=cached_stems,
993
+ existing_repo_ids=existing_repo_ids,
994
+ mode='frontier' if getattr(args, 'frontier', False) else 'stable',
995
+ family_expansion_fetch=getattr(
996
+ args, 'family_expansion_fetch', DEFAULT_FAMILY_EXPANSION_FETCH
997
+ ),
998
+ )
999
+ if not expanded:
1000
+ console.print(
1001
+ Panel(
1002
+ 'No candidates passed the default refresh policy.',
1003
+ title='Refresh',
1004
+ border_style='yellow',
1005
+ )
1006
+ )
1007
+ return 0
1008
+ _print_candidate_table(expanded, 'Refresh candidates')
1009
+ if getattr(args, 'show_rejected', False) and rejected:
1010
+ _print_candidate_table(rejected[:20], 'Rejected candidates')
1011
+ return _run_add(
1012
+ manifest_path=manifest_path,
1013
+ repo_ids=[c.repo_id for c in expanded],
1014
+ token=token,
1015
+ include_memory_hints=args.include_memory_hints,
1016
+ yes=args.yes,
1017
+ model_key=None,
1018
+ )
1019
+
1020
+
1021
+ def _add_refresh_args(parser: argparse.ArgumentParser) -> None:
1022
+ parser.add_argument(
1023
+ '--manifest',
1024
+ default='models.yaml',
1025
+ help='Repo manifest to append to. Default: ./models.yaml',
1026
+ )
1027
+ parser.add_argument(
1028
+ '--limit',
1029
+ type=int,
1030
+ default=DEFAULT_LIMIT,
1031
+ help=f'Per quantization lane cap after ranking. Default: {DEFAULT_LIMIT} (0 means unlimited)',
1032
+ )
1033
+ parser.add_argument(
1034
+ '--authors',
1035
+ nargs='*',
1036
+ default=None,
1037
+ help='Optional allowlisted authors. Defaults to a curated built-in set.',
1038
+ )
1039
+ parser.add_argument(
1040
+ '--frontier',
1041
+ action='store_true',
1042
+ help='Bias candidate ranking toward newer releases.',
1043
+ )
1044
+ parser.add_argument(
1045
+ '--include-memory-hints',
1046
+ action='store_true',
1047
+ help='Emit rough VRAM-related hints from safetensors metadata.',
1048
+ )
1049
+ parser.add_argument(
1050
+ '--show-rejected',
1051
+ action='store_true',
1052
+ help='Also print a table of rejected refresh candidates.',
1053
+ )
1054
+ parser.add_argument(
1055
+ '--yes',
1056
+ action='store_true',
1057
+ help='Apply the diff without interactive confirmation.',
1058
+ )
1059
+ parser.add_argument(
1060
+ '--token', default=None, help='HF token; defaults to HF_TOKEN env var.'
1061
+ )
1062
+ parser.add_argument(
1063
+ '--per-category-fetch',
1064
+ type=int,
1065
+ default=DEFAULT_PER_CATEGORY_FETCH,
1066
+ help=f'How many repos to fetch per ranking category per author. Default: {DEFAULT_PER_CATEGORY_FETCH}',
1067
+ )
1068
+ parser.add_argument(
1069
+ '--per-family-limit',
1070
+ type=int,
1071
+ default=DEFAULT_PER_FAMILY_LIMIT,
1072
+ help=f'Limit initial seed models per (author,family,quantization). Default: {DEFAULT_PER_FAMILY_LIMIT} (0 means unlimited)',
1073
+ )
1074
+ parser.add_argument(
1075
+ '--family-expansion-fetch',
1076
+ type=int,
1077
+ default=DEFAULT_FAMILY_EXPANSION_FETCH,
1078
+ help=f'How many search results to inspect when expanding sibling variants for a stem. Default: {DEFAULT_FAMILY_EXPANSION_FETCH}',
1079
+ )
1080
+
1081
+
1082
+ def build_parser() -> argparse.ArgumentParser:
1083
+ parser = argparse.ArgumentParser(
1084
+ description='Discover Hugging Face model metadata and append conservative infer_stack-compatible model entries.'
1085
+ )
1086
+ parser.add_argument(
1087
+ '--version', action='version', version=f'%(prog)s {PROGRAM_VERSION}'
1088
+ )
1089
+ _add_refresh_args(parser)
1090
+ sub = parser.add_subparsers(dest='command')
1091
+
1092
+ s = sub.add_parser(
1093
+ 'search', help='Search the Hugging Face Hub for candidate models.'
1094
+ )
1095
+ s.add_argument('--search', default=None, help='Free-text search string.')
1096
+ s.add_argument('--author', default=None, help='Optional author/org filter.')
1097
+ s.add_argument('--limit', type=int, default=20)
1098
+ s.add_argument(
1099
+ '--token', default=None, help='HF token; defaults to HF_TOKEN env var.'
1100
+ )
1101
+ s.set_defaults(func=cmd_search)
1102
+
1103
+ s = sub.add_parser(
1104
+ 'add',
1105
+ help='Add one or more explicit Hugging Face repo IDs into the manifest.',
1106
+ )
1107
+ s.add_argument(
1108
+ 'repo_ids',
1109
+ nargs='+',
1110
+ help='One or more Hugging Face repo IDs like Qwen/Qwen3.6-35B-A3B.',
1111
+ )
1112
+ s.add_argument(
1113
+ '--model-key',
1114
+ default=None,
1115
+ help='Override the generated model key for a single repo.',
1116
+ )
1117
+ s.add_argument(
1118
+ '--manifest',
1119
+ default='models.yaml',
1120
+ help='Repo manifest to append to. Default: ./models.yaml',
1121
+ )
1122
+ s.add_argument(
1123
+ '--include-memory-hints',
1124
+ action='store_true',
1125
+ help='Emit rough VRAM-related hints from safetensors metadata.',
1126
+ )
1127
+ s.add_argument(
1128
+ '--yes',
1129
+ action='store_true',
1130
+ help='Apply the diff without interactive confirmation.',
1131
+ )
1132
+ s.add_argument(
1133
+ '--token', default=None, help='HF token; defaults to HF_TOKEN env var.'
1134
+ )
1135
+ s.set_defaults(func=cmd_add)
1136
+
1137
+ s = sub.add_parser(
1138
+ 'refresh',
1139
+ help='Refresh the manifest by discovering new high-signal models.',
1140
+ )
1141
+ _add_refresh_args(s)
1142
+ s.set_defaults(func=cmd_refresh)
1143
+
1144
+ parser.set_defaults(func=cmd_refresh)
1145
+ return parser
1146
+
1147
+
1148
+ def main(argv: list[str] | None = None) -> int:
1149
+ parser = build_parser()
1150
+ args = parser.parse_args(argv)
1151
+ return int(args.func(args))
1152
+
1153
+
1154
+ if __name__ == '__main__':
1155
+ raise SystemExit(main())