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.
- infer_stack/__init__.py +2 -0
- infer_stack/backends/__init__.py +7 -0
- infer_stack/backends/compose_renderer.py +243 -0
- infer_stack/backends/kubeai_renderer.py +202 -0
- infer_stack/benchmark.py +38 -0
- infer_stack/catalog.py +438 -0
- infer_stack/cli/__init__.py +169 -0
- infer_stack/cli/__main__.py +4 -0
- infer_stack/cli/commands_profile.py +467 -0
- infer_stack/cli/commands_runtime.py +719 -0
- infer_stack/cli/commands_smoke.py +691 -0
- infer_stack/cli/compose.py +755 -0
- infer_stack/cli/context.py +471 -0
- infer_stack/cli/options.py +134 -0
- infer_stack/cli/probes.py +178 -0
- infer_stack/config.py +450 -0
- infer_stack/contracts.py +223 -0
- infer_stack/diff_prompt.py +117 -0
- infer_stack/docker_utils.py +230 -0
- infer_stack/env_utils.py +97 -0
- infer_stack/experimental/model_catalog_discover.py +1155 -0
- infer_stack/experimental/model_memory_estimator.py +1264 -0
- infer_stack/experimental/stress_test_long_context.py +397 -0
- infer_stack/hardware.py +70 -0
- infer_stack/kubeai_ops.py +76 -0
- infer_stack/paths.py +87 -0
- infer_stack/profile_runtime.py +46 -0
- infer_stack/renderer.py +19 -0
- infer_stack/resolver.py +1092 -0
- infer_stack/templates/default-models.yaml +674 -0
- infer_stack/templates/default-ollama-models.yaml +31 -0
- infer_stack/templates/default-profiles.yaml +1731 -0
- infer_stack/templates/default-vllm-models.yaml +714 -0
- infer_stack/templates/docker-compose.yml.j2 +430 -0
- infer_stack/templates/litellm_config.yaml.j2 +44 -0
- infer_stack/templates/nginx.conf.j2 +84 -0
- infer_stack/tuning.py +3 -0
- infer_stack/validator.py +314 -0
- infer_stack/verification.py +46 -0
- infer_stack-0.6.0.dist-info/METADATA +1034 -0
- infer_stack-0.6.0.dist-info/RECORD +44 -0
- infer_stack-0.6.0.dist-info/WHEEL +5 -0
- infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
- 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())
|