mirror of
https://github.com/AlexsJones/llmfit.git
synced 2026-10-19 07:23:24 +00:00
* fix(scraper): keep a rate-limited base lookup out of the params cache _repo_total_params swallowed every failure into None and cached it for the rest of the run, so a 429 that survived the retries on the base_model lookup was indistinguishable from a repo with no safetensors.total. check_gguf_repo_exists then returned False for the candidate, enrich_gguf_sources cached that miss for GGUF_CACHE_MAX_AGE_DAYS, and every later candidate declaring the same base was rejected until the next run. Only an answer is cached now: a total, or None when the repo is gone (401/403/404) or has no safetensors.total. A 429 past the retries, a 5xx or a transport error propagates, so the existing handler in check_gguf_repo_exists maps the 429 to "unknown" with nothing cached, and the next candidate on the same base asks again. Test: a 429 on the base lookup leaves the candidate unknown and the cache untouched, a second candidate on the same base re-asks and is accepted, and a base without a size is still cached as None. Fixes #1067 * fix(scraper): a failed GGUF lookup is unknown, not a miss check_gguf_repo_exists mapped only an exhausted 429 to None; a 5xx or a transport failure, on the candidate or (since the previous commit) on its base_model lookup, fell through to False, which enrich_gguf_sources cached as a miss for GGUF_CACHE_MAX_AGE_DAYS. The handler now returns None for anything HuggingFace did not answer: 429 past the retries, 5xx, network trouble, a truncated or non-JSON body. A 4xx and a repo without gguf tags are still a miss. Test: 503, a dropped connection and a truncated read on the base lookup, a 502 on the candidate, and a 503 through enrich_gguf_sources with nothing written to the cache. Refs #1067
3875 lines
164 KiB
Python
3875 lines
164 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Scraper for popular LLM models from Hugging Face.
|
||
Fetches model metadata and computes RAM/VRAM requirements from parameter counts.
|
||
Outputs a JSON file consumable by llmfit's models.rs.
|
||
|
||
Usage:
|
||
python3 scrape_hf_models.py # Curated + top 1000 by downloads
|
||
python3 scrape_hf_models.py --threads 8 # Same, with parallel fetches
|
||
python3 scrape_hf_models.py -n 500 # Curated + top 500 by downloads
|
||
python3 scrape_hf_models.py --no-discover # Curated list only
|
||
"""
|
||
|
||
import argparse
|
||
import concurrent.futures
|
||
import email.utils
|
||
import http.client
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.request
|
||
import urllib.error
|
||
from datetime import date, datetime, timedelta, timezone
|
||
|
||
HF_API = "https://huggingface.co/api/models"
|
||
|
||
# Global auth token, set from --token flag or HF_TOKEN / HUGGING_FACE_HUB_TOKEN env var
|
||
_hf_token: str | None = None
|
||
|
||
|
||
def _auth_headers() -> dict[str, str]:
|
||
"""Return HTTP headers with auth if a HuggingFace token is available."""
|
||
headers = {"User-Agent": "llmfit-scraper/1.0"}
|
||
if _hf_token:
|
||
headers["Authorization"] = f"Bearer {_hf_token}"
|
||
return headers
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HuggingFace rate limiting (#1039)
|
||
# ---------------------------------------------------------------------------
|
||
# HF answers every request with IETF draft ratelimit headers, one fixed
|
||
# window per bucket, and 429 once a window is spent. Values observed
|
||
# anonymous on 2026-09-15:
|
||
# ratelimit-policy: "fixed window";"api";q=500;w=300 (api/models/*)
|
||
# ratelimit-policy: "fixed window";"resolvers";q=3000;w=300 (*/resolve/*)
|
||
# ratelimit: "api";r=<remaining>;t=<seconds until the window resets>
|
||
# Nothing here used to read them: a `-n 5000 --threads 8` run fired thousands
|
||
# of requests in under five minutes, most came back 429, and every caller
|
||
# swallowed the error as "no data". Downloads went to 0, context lengths to
|
||
# the default and attention-head metadata disappeared, which the
|
||
# ARCH_METADATA_DROP_LIMIT guard (#963) then rightly refused to ship.
|
||
#
|
||
# `_hf_urlopen` is the single door to HF. It waits for a bucket's window when
|
||
# a 429 came back or `r=` hit zero, retries, and records what happened in
|
||
# RATE_LIMIT_STATS so the merge guard can say why it tripped.
|
||
|
||
RATE_LIMIT_MAX_RETRIES = 3
|
||
# `t=` is bounded by the 300 s window; anything larger is a mis-parsed
|
||
# header or an oversized Retry-After, not a real reset time.
|
||
RATE_LIMIT_MAX_WAIT_SECONDS = 600.0
|
||
# Used when a 429 carries neither Retry-After nor a usable `t=`.
|
||
RATE_LIMIT_DEFAULT_WAIT_SECONDS = 60.0
|
||
|
||
RATE_LIMIT_STATS: dict = {
|
||
"http_429": 0, # 429 responses received, all buckets
|
||
"http_429_by_kind": {}, # per caller: model_info, config_json, listing, gguf_probe
|
||
"pauses": 0, # times the pause window was opened or extended
|
||
"pause_seconds": 0.0, # wall-clock time the scraper held requests back
|
||
"gave_up": 0, # requests still 429 after RATE_LIMIT_MAX_RETRIES
|
||
}
|
||
|
||
_rate_limit_lock = threading.Lock()
|
||
# bucket name -> monotonic timestamp before which no request may be sent
|
||
_rate_limit_resume_at: dict[str, float] = {}
|
||
|
||
# Seams for the hermetic tests in test_preserve_catalog_metadata.py.
|
||
_urlopen = urllib.request.urlopen
|
||
_sleep = time.sleep
|
||
_now = time.monotonic
|
||
|
||
|
||
def _reset_rate_limit_state() -> None:
|
||
"""Forget pauses and counters (tests only)."""
|
||
with _rate_limit_lock:
|
||
_rate_limit_resume_at.clear()
|
||
RATE_LIMIT_STATS.update(
|
||
http_429=0, http_429_by_kind={}, pauses=0, pause_seconds=0.0, gave_up=0
|
||
)
|
||
|
||
|
||
def _rate_limit_bucket(url: str) -> str:
|
||
"""Bucket a URL is metered in, before the response tells us for sure."""
|
||
return "resolvers" if "/resolve/" in url else "api"
|
||
|
||
|
||
def _parse_ratelimit_header(
|
||
value: str | None,
|
||
) -> tuple[str | None, int | None, float | None]:
|
||
"""Parse `ratelimit: "api";r=499;t=106` into (bucket, remaining, reset_seconds)."""
|
||
if not value:
|
||
return None, None, None
|
||
bucket_match = re.match(r'\s*"([^"]*)"', value)
|
||
bucket = bucket_match.group(1) if bucket_match else None
|
||
fields = dict(re.findall(r";\s*([a-z]+)=(\d+)", value))
|
||
remaining = int(fields["r"]) if "r" in fields else None
|
||
reset = float(fields["t"]) if "t" in fields else None
|
||
return bucket, remaining, reset
|
||
|
||
|
||
def _retry_after_seconds(headers) -> float | None:
|
||
"""Retry-After as seconds, accepting both the delta and HTTP-date forms."""
|
||
value = headers.get("Retry-After") if headers is not None else None
|
||
if not value:
|
||
return None
|
||
value = value.strip()
|
||
if value.isdigit():
|
||
return float(value)
|
||
try:
|
||
when = email.utils.parsedate_to_datetime(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if when.tzinfo is None:
|
||
when = when.replace(tzinfo=timezone.utc)
|
||
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
|
||
|
||
|
||
def _schedule_rate_limit_pause(bucket: str, seconds: float, reason: str) -> None:
|
||
"""Hold every request on `bucket` for `seconds`, extending any current pause."""
|
||
seconds = min(max(seconds, 1.0), RATE_LIMIT_MAX_WAIT_SECONDS)
|
||
now = _now()
|
||
resume_at = now + seconds
|
||
with _rate_limit_lock:
|
||
current = _rate_limit_resume_at.get(bucket, 0.0)
|
||
if resume_at <= current + 1.0:
|
||
# Same pause already in place. Threads that were in flight when
|
||
# the window closed all report the same reset a few ms apart;
|
||
# that is one pause, not eight.
|
||
return
|
||
_rate_limit_resume_at[bucket] = resume_at
|
||
RATE_LIMIT_STATS["pauses"] += 1
|
||
RATE_LIMIT_STATS["pause_seconds"] += resume_at - max(current, now)
|
||
print(
|
||
f" ⏸ HF rate limit ({bucket}): {reason}, pausing {seconds:.0f}s "
|
||
f"until the window resets",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
|
||
def _wait_for_rate_limit_window(bucket: str) -> None:
|
||
"""Sleep until the bucket's pause, if any, is over. Re-checks after waking
|
||
because another thread may have extended it meanwhile."""
|
||
while True:
|
||
with _rate_limit_lock:
|
||
delay = _rate_limit_resume_at.get(bucket, 0.0) - _now()
|
||
if delay <= 0:
|
||
return
|
||
_sleep(delay)
|
||
|
||
|
||
def _record_429(kind: str) -> None:
|
||
with _rate_limit_lock:
|
||
RATE_LIMIT_STATS["http_429"] += 1
|
||
by_kind = RATE_LIMIT_STATS["http_429_by_kind"]
|
||
by_kind[kind] = by_kind.get(kind, 0) + 1
|
||
|
||
|
||
def _hf_urlopen(url: str, timeout: float, kind: str = "api"):
|
||
"""urlopen for HF with rate-limit handling. Returns the response, a
|
||
context manager like urlopen's.
|
||
|
||
Waits out the bucket's window when an earlier response asked for it,
|
||
retries a 429 up to RATE_LIMIT_MAX_RETRIES times after sleeping to the
|
||
reset, and re-raises the last 429 so callers keep their existing "no
|
||
data" path, but only after the pause and the count. Every other HTTP
|
||
error propagates untouched.
|
||
"""
|
||
bucket = _rate_limit_bucket(url)
|
||
retries = 0
|
||
while True:
|
||
_wait_for_rate_limit_window(bucket)
|
||
req = urllib.request.Request(url, headers=_auth_headers())
|
||
try:
|
||
resp = _urlopen(req, timeout=timeout)
|
||
except urllib.error.HTTPError as e:
|
||
if e.code != 429:
|
||
raise
|
||
_record_429(kind)
|
||
header_bucket, _, reset = _parse_ratelimit_header(
|
||
e.headers.get("ratelimit")
|
||
)
|
||
# The response names the bucket it was metered in; trust it over
|
||
# the URL guess, for this pause and for the wait before the retry.
|
||
bucket = header_bucket or bucket
|
||
wait = (
|
||
_retry_after_seconds(e.headers)
|
||
or reset
|
||
or RATE_LIMIT_DEFAULT_WAIT_SECONDS
|
||
)
|
||
_schedule_rate_limit_pause(bucket, wait, f"HTTP 429 on {kind}")
|
||
if retries == RATE_LIMIT_MAX_RETRIES:
|
||
with _rate_limit_lock:
|
||
RATE_LIMIT_STATS["gave_up"] += 1
|
||
raise
|
||
retries += 1
|
||
continue
|
||
header_bucket, remaining, reset = _parse_ratelimit_header(
|
||
resp.headers.get("ratelimit")
|
||
)
|
||
if remaining == 0 and reset:
|
||
# Window spent by this very response: hold the next request
|
||
# instead of letting it come back 429.
|
||
_schedule_rate_limit_pause(
|
||
header_bucket or bucket, reset, "window spent (r=0)"
|
||
)
|
||
return resp
|
||
|
||
|
||
def rate_limit_summary() -> str:
|
||
"""One line for the run log and the merge guard."""
|
||
s = RATE_LIMIT_STATS
|
||
if not s["http_429"] and not s["pauses"]:
|
||
return "HF rate limiting this run: none (no HTTP 429, no pause)"
|
||
by_kind = (
|
||
", ".join(
|
||
f"{kind} {count:,}"
|
||
for kind, count in sorted(
|
||
s["http_429_by_kind"].items(), key=lambda kv: -kv[1]
|
||
)
|
||
)
|
||
or "none"
|
||
)
|
||
return (
|
||
f"HF rate limiting this run: {s['http_429']:,} HTTP 429 ({by_kind}), "
|
||
f"{s['pauses']} pause(s) totalling {s['pause_seconds']:.0f}s, "
|
||
f"{s['gave_up']} request(s) still 429 after {RATE_LIMIT_MAX_RETRIES} retries"
|
||
)
|
||
|
||
|
||
# Top text-generation models to scrape (owner/repo)
|
||
TARGET_MODELS = [
|
||
# Meta Llama family
|
||
"meta-llama/Llama-3.1-8B",
|
||
"meta-llama/Llama-3.1-8B-Instruct",
|
||
"meta-llama/Llama-3.1-70B-Instruct",
|
||
"meta-llama/Llama-3.1-405B-Instruct",
|
||
"meta-llama/Llama-3.2-1B",
|
||
"meta-llama/Llama-3.2-3B",
|
||
"meta-llama/Llama-3.2-11B-Vision-Instruct", # NEW: Multimodal vision model
|
||
"meta-llama/Llama-3.3-70B-Instruct",
|
||
# Meta Llama 4 (MoE)
|
||
"meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
||
"meta-llama/Llama-4-Maverick-17B-128E-Instruct",
|
||
# Code Llama
|
||
"meta-llama/CodeLlama-7b-Instruct-hf", # NEW: Popular code model
|
||
"meta-llama/CodeLlama-13b-Instruct-hf", # NEW: Larger code model
|
||
"meta-llama/CodeLlama-34b-Instruct-hf", # NEW: Large code model
|
||
# Mistral
|
||
"mistralai/Mistral-7B-Instruct-v0.3",
|
||
"mistralai/Mixtral-8x7B-Instruct-v0.1",
|
||
"mistralai/Mixtral-8x22B-Instruct-v0.1",
|
||
"mistralai/Mistral-Large-Instruct-2407",
|
||
"mistralai/Mistral-Small-24B-Instruct-2501",
|
||
"mistralai/Mistral-Small-3.1-24B-Instruct-2503",
|
||
"mistralai/Ministral-8B-Instruct-2410",
|
||
"mistralai/Mistral-Nemo-Instruct-2407",
|
||
"mistralai/Devstral-Small-2505",
|
||
# Qwen
|
||
"Qwen/Qwen2.5-7B-Instruct",
|
||
"Qwen/Qwen2.5-14B-Instruct",
|
||
"Qwen/Qwen2.5-32B-Instruct",
|
||
"Qwen/Qwen2.5-72B-Instruct",
|
||
"Qwen/Qwen2.5-Coder-1.5B-Instruct", # NEW: Ultra-lightweight coder
|
||
"Qwen/Qwen2.5-Coder-7B-Instruct", # NEW: Popular coder
|
||
"Qwen/Qwen2.5-Coder-14B-Instruct", # NEW: Mid-size coder
|
||
"Qwen/Qwen2.5-Coder-32B-Instruct", # NEW: Large coder
|
||
"Qwen/Qwen2.5-VL-3B-Instruct", # NEW: Vision-language 3B
|
||
"Qwen/Qwen2.5-VL-7B-Instruct", # NEW: Vision-language 7B
|
||
"Qwen/Qwen3-0.6B",
|
||
"Qwen/Qwen3-1.7B",
|
||
"Qwen/Qwen3-4B",
|
||
"Qwen/Qwen3-8B",
|
||
"Qwen/Qwen3-14B",
|
||
"Qwen/Qwen3-32B",
|
||
"Qwen/Qwen3-30B-A3B",
|
||
"Qwen/Qwen3-235B-A22B",
|
||
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||
"Qwen/Qwen3-Coder-Next",
|
||
# Qwen 3.5 (native multimodal, Feb 2026)
|
||
"Qwen/Qwen3.5-27B",
|
||
"Qwen/Qwen3.5-35B-A3B",
|
||
"Qwen/Qwen3.5-122B-A10B",
|
||
"Qwen/Qwen3.5-397B-A17B",
|
||
# Qwen3.5 Small Series (Instruct)
|
||
"Qwen/Qwen3.5-0.8B",
|
||
"Qwen/Qwen3.5-2B",
|
||
"Qwen/Qwen3.5-4B",
|
||
"Qwen/Qwen3.5-9B",
|
||
# Qwen3.5 Small Series (Base)
|
||
"Qwen/Qwen3.5-0.8B-Base",
|
||
"Qwen/Qwen3.5-2B-Base",
|
||
"Qwen/Qwen3.5-4B-Base",
|
||
"Qwen/Qwen3.5-9B-Base",
|
||
# Qwen 3.5 (Claude Opus 4.6 reasoning, Feb 2026)
|
||
"Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled",
|
||
"Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-GGUF",
|
||
"Jackrong/Qwen3.5-9B-Claude-4.6-Opus-Reasoning-Distilled-v2",
|
||
"Jackrong/Qwen3.5-9B-Claude-4.6-Opus-Reasoning-Distilled-v2-GGUF",
|
||
"Jackrong/Qwen3.5-9B-Claude-4.6-Opus-Reasoning-Distilled-GGUF",
|
||
"Jackrong/Qwen3.5-35B-A3B-Claude-4.6-Opus-Reasoning-Distilled",
|
||
# Qwen 3.6 (native multimodal + hybrid attention, Apr 2026)
|
||
"Qwen/Qwen3.6-27B",
|
||
"Qwen/Qwen3.6-35B-A3B",
|
||
"huihui-ai/Huihui-Qwen3.6-35B-A3B-abliterated",
|
||
# Qwen 3.8 (hybrid attention, Aug 2026)
|
||
"Qwen/Qwen3.8-27B",
|
||
"Qwen/Qwen3.8-27B-FP8",
|
||
"Qwen/Qwen3.8-2.4T-A95B",
|
||
"Qwen/Qwen3.8-2.4T-A95B-FP8",
|
||
# Microsoft Phi
|
||
"microsoft/phi-3-mini-4k-instruct",
|
||
"microsoft/Phi-3-medium-14b-instruct",
|
||
"microsoft/Phi-3.5-mini-instruct", # NEW: Newer Phi variant
|
||
"microsoft/phi-4",
|
||
"microsoft/Phi-4-mini-instruct",
|
||
# Microsoft Orca
|
||
"microsoft/Orca-2-7b", # NEW: Reasoning model
|
||
"microsoft/Orca-2-13b", # NEW: Larger reasoning model
|
||
# Google Gemma
|
||
"google/gemma-2-2b-it", # NEW: Smaller variant for edge
|
||
"google/gemma-2-9b-it",
|
||
"google/gemma-2-27b-it",
|
||
"google/gemma-3-1b-it",
|
||
"google/gemma-3-4b-it",
|
||
"google/gemma-3-12b-it",
|
||
"google/gemma-3-27b-it",
|
||
# Google Gemma 4
|
||
"google/gemma-4-E2B-it",
|
||
"google/gemma-4-E4B-it",
|
||
"google/gemma-4-31B-it",
|
||
"google/gemma-4-26B-A4B-it",
|
||
# DeepSeek
|
||
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
|
||
"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
|
||
"deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct",
|
||
"deepseek-ai/DeepSeek-V3",
|
||
"deepseek-ai/DeepSeek-R1",
|
||
# DeepSeek V4 family (MoE, hybrid attention, Apr 2026)
|
||
"deepseek-ai/DeepSeek-V4-Pro",
|
||
"deepseek-ai/DeepSeek-V4-Pro-Base",
|
||
"deepseek-ai/DeepSeek-V4-Flash",
|
||
"deepseek-ai/DeepSeek-V4-Flash-Base",
|
||
# Cohere
|
||
"CohereForAI/c4ai-command-r-v01",
|
||
"CohereForAI/c4ai-command-r-plus-08-2024",
|
||
"CohereForAI/c4ai-command-a-03-2025",
|
||
# 01.ai Yi family
|
||
"01-ai/Yi-6B-Chat", # NEW: Popular multilingual 6B
|
||
"01-ai/Yi-34B-Chat", # NEW: Popular multilingual 34B
|
||
# Upstage Solar
|
||
"upstage/SOLAR-10.7B-Instruct-v1.0", # NEW: High-performance 10.7B
|
||
# TII Falcon
|
||
"tiiuae/falcon-7b-instruct", # NEW: Popular UAE model
|
||
"tiiuae/falcon-40b-instruct",
|
||
"tiiuae/falcon-180B-chat",
|
||
"tiiuae/Falcon3-3B-Instruct",
|
||
"tiiuae/Falcon3-7B-Instruct",
|
||
"tiiuae/Falcon3-10B-Instruct",
|
||
# HuggingFace Zephyr
|
||
"HuggingFaceH4/zephyr-7b-beta", # NEW: Very popular fine-tune
|
||
# OpenChat
|
||
"openchat/openchat-3.5-0106", # NEW: Popular alternative
|
||
# LMSYS Vicuna
|
||
"lmsys/vicuna-7b-v1.5", # NEW: Popular community model
|
||
"lmsys/vicuna-13b-v1.5", # NEW: Larger Vicuna
|
||
# NousResearch
|
||
"NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO", # NEW: Popular fine-tune
|
||
# WizardLM
|
||
"WizardLMTeam/WizardLM-13B-V1.2", # NEW: Popular instruction model
|
||
# Code models
|
||
"bigcode/starcoder2-7b",
|
||
"bigcode/starcoder2-15b",
|
||
"WizardLMTeam/WizardCoder-15B-V1.0", # NEW: Code specialist
|
||
# Small / edge models
|
||
"TinyLlama/TinyLlama-1.1B-Chat-v1.0",
|
||
"stabilityai/stablelm-2-1_6b-chat",
|
||
# IBM Granite
|
||
"ibm-granite/granite-3.1-8b-instruct",
|
||
"ibm-granite/granite-4.0-h-tiny",
|
||
"ibm-granite/granite-4.0-h-micro",
|
||
"ibm-granite/granite-4.0-h-small",
|
||
# Allen Institute OLMo
|
||
"allenai/OLMo-2-0325-32B-Instruct",
|
||
# Zhipu GLM
|
||
"THUDM/glm-4-9b-chat",
|
||
# xAI Grok
|
||
"xai-org/grok-1",
|
||
# Moonshot Kimi
|
||
"moonshotai/Kimi-K2-Instruct",
|
||
# BigScience BLOOM
|
||
"bigscience/bloom",
|
||
# Baidu ERNIE
|
||
"baidu/ERNIE-4.5-300B-A47B-Paddle",
|
||
# Rednote dots.llm
|
||
"rednote-hilab/dots.llm1.inst",
|
||
# Meituan LongCat
|
||
"meituan/LongCat-Flash",
|
||
# Ant Group Ling
|
||
"inclusionAI/Ling-lite",
|
||
# Liquid AI LFM2 (dense)
|
||
"LiquidAI/LFM2-350M",
|
||
"LiquidAI/LFM2-700M",
|
||
"LiquidAI/LFM2-1.2B",
|
||
"LiquidAI/LFM2-2.6B",
|
||
"LiquidAI/LFM2-2.6B-Exp",
|
||
# Liquid AI LFM2 (MoE)
|
||
"LiquidAI/LFM2-8B-A1B",
|
||
"LiquidAI/LFM2-24B-A2B",
|
||
# Liquid AI LFM2.5
|
||
"LiquidAI/LFM2.5-1.2B-Base",
|
||
"LiquidAI/LFM2.5-1.2B-Instruct",
|
||
"LiquidAI/LFM2.5-1.2B-Thinking",
|
||
"LiquidAI/LFM2.5-1.2B-JP",
|
||
# Liquid AI LFM2 Vision-Language
|
||
"LiquidAI/LFM2-VL-450M",
|
||
"LiquidAI/LFM2-VL-1.6B",
|
||
"LiquidAI/LFM2-VL-3B",
|
||
"LiquidAI/LFM2.5-VL-1.6B",
|
||
# Liquid AI LFM2 Audio
|
||
"LiquidAI/LFM2-Audio-1.5B",
|
||
"LiquidAI/LFM2.5-Audio-1.5B",
|
||
# Text-to-speech models
|
||
"hexgrad/Kokoro-82M",
|
||
"microsoft/speecht5_tts",
|
||
"facebook/mms-tts-eng",
|
||
"suno/bark",
|
||
"coqui/XTTS-v2",
|
||
# Liquid AI Liquid Nanos (task-specific fine-tunes)
|
||
"LiquidAI/LFM2-1.2B-Tool",
|
||
"LiquidAI/LFM2-1.2B-RAG",
|
||
"LiquidAI/LFM2-1.2B-Extract",
|
||
"LiquidAI/LFM2-350M-Extract",
|
||
"LiquidAI/LFM2-350M-Math",
|
||
"LiquidAI/LFM2-350M-ENJP-MT",
|
||
"LiquidAI/LFM2-350M-PII-Extract-JP",
|
||
"LiquidAI/LFM2-ColBERT-350M",
|
||
"LiquidAI/LFM2-2.6B-Transcript",
|
||
# Embeddings (useful for RAG sizing)
|
||
"nomic-ai/nomic-embed-text-v1.5",
|
||
"BAAI/bge-large-en-v1.5",
|
||
# --- New models added Feb 2026 ---
|
||
# DeepSeek V3.2 family
|
||
"deepseek-ai/DeepSeek-V3.2",
|
||
"deepseek-ai/DeepSeek-V3.2-Speciale",
|
||
# Zhipu/Z.ai GLM-5
|
||
"zai-org/GLM-5",
|
||
# Moonshot Kimi K2.5
|
||
"moonshotai/Kimi-K2.5",
|
||
# MiniMax M3 / M2.7
|
||
"MiniMaxAI/MiniMax-M3",
|
||
"MiniMaxAI/MiniMax-M2.7",
|
||
# Xiaomi MiMo
|
||
"XiaomiMiMo/MiMo-V2-Flash",
|
||
"XiaomiMiMo/MiMo-7B-RL",
|
||
# NVIDIA Nemotron
|
||
"nvidia/Llama-3.3-Nemotron-Super-49B-v1",
|
||
"nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
|
||
"nvidia/NVIDIA-Nemotron-Nano-9B-v2",
|
||
# Microsoft Phi-4 reasoning family
|
||
"microsoft/Phi-4-reasoning",
|
||
"microsoft/Phi-4-mini-reasoning",
|
||
"microsoft/Phi-4-multimodal-instruct",
|
||
# LG AI EXAONE Deep (reasoning)
|
||
"LGAI-EXAONE/EXAONE-Deep-2.4B",
|
||
"LGAI-EXAONE/EXAONE-Deep-32B",
|
||
# LG AI EXAONE 4.0
|
||
"LGAI-EXAONE/EXAONE-4.0-32B",
|
||
"LGAI-EXAONE/EXAONE-4.0-1.2B",
|
||
# HuggingFace SmolLM3
|
||
"HuggingFaceTB/SmolLM3-3B",
|
||
# Google Gemma 3n (effective parameter models)
|
||
"google/gemma-3n-E4B-it",
|
||
"google/gemma-3n-E2B-it",
|
||
# RWKV v7 — pure RNN/SSM, no KV cache (GGUF native via shoumenchougou)
|
||
"shoumenchougou/RWKV7-G1f-1.5B-GGUF",
|
||
"shoumenchougou/RWKV7-G1f-2.9B-GGUF",
|
||
"shoumenchougou/RWKV7-G1f-7.2B-GGUF",
|
||
"shoumenchougou/RWKV7-G1f-13.3B-GGUF",
|
||
# NCAI VAETKI
|
||
"nc-ai-consortium/VAETKI-7B-A1B",
|
||
"nc-ai-consortium/VAETKI-20B-A2B",
|
||
"NC-AI-consortium-VAETKI/VAETKI",
|
||
"nc-ai-consortium/VAETKI-VL-7B-A1B",
|
||
# --- New models added Sep 2026 ---
|
||
# Z.ai GLM-5.x
|
||
"zai-org/GLM-5.1",
|
||
"zai-org/GLM-5.2",
|
||
"zai-org/GLM-5.3",
|
||
"zai-org/GLM-5.3-Flash",
|
||
# Qwen 3.8 Flash-Next
|
||
"Qwen/Qwen3.8-Flash-Next",
|
||
"Qwen/Qwen3.8-Flash-Next-FP8",
|
||
# DeepSeek V4 refreshes / V4.1
|
||
"deepseek-ai/DeepSeek-V4-Flash-0731",
|
||
"deepseek-ai/DeepSeek-V4-Pro-0813",
|
||
"deepseek-ai/DeepSeek-V4.1-Flash",
|
||
# Google Gemma 4 12B
|
||
"google/gemma-4-12B-it",
|
||
"google/gemma-4-12B-it-qat-w4a16-ct",
|
||
# Moonshot Kimi K2.6 / K2.7 / K3
|
||
"moonshotai/Kimi-K2.6",
|
||
"moonshotai/Kimi-K2.7-Code",
|
||
"moonshotai/Kimi-K3",
|
||
# NVIDIA Nemotron 3.5
|
||
"nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16",
|
||
# Mistral (2025-12 onwards; repos carry no pipeline_tag so discovery skips them)
|
||
"mistralai/Devstral-Small-2-24B-Instruct-2512",
|
||
"mistralai/Mistral-Large-3-675B-Instruct-2512",
|
||
"mistralai/Mistral-Small-4-119B-2603",
|
||
"mistralai/Mistral-Medium-3.5-128B",
|
||
"mistralai/Leanstral-1.5-119B-A6B",
|
||
# IBM Granite 4.2
|
||
"ibm-granite/granite-4.2-3b",
|
||
"ibm-granite/granite-4.2-8b",
|
||
"ibm-granite/granite-4.2-30b",
|
||
# Ornith 1.5
|
||
"ornith-ai/Ornith-1.5-9B",
|
||
"ornith-ai/Ornith-1.5-35B-A3B",
|
||
# OpenBMB MiniCPM5
|
||
"openbmb/MiniCPM5-2B",
|
||
# Microsoft Fara 1.5 (computer-use agents)
|
||
"microsoft/Fara1.5-4B",
|
||
"microsoft/Fara1.5-27B",
|
||
# MiniMax
|
||
"MiniMaxAI/MiniMax-M2.5",
|
||
"MiniMaxAI/MiniMax-M3-MXFP8",
|
||
# StepFun / Xiaomi / Tencent / IFM
|
||
"stepfun-ai/Step-3.7-Flash",
|
||
"XiaomiMiMo/MiMo-V2.5",
|
||
"tencent/Hy4-preview",
|
||
"IFM/K2-Horizon-7B",
|
||
]
|
||
|
||
# Bytes-per-parameter for different quantization levels
|
||
QUANT_BPP = {
|
||
"F32": 4.0,
|
||
"F16": 2.0,
|
||
"BF16": 2.0,
|
||
"Q8_0": 1.0,
|
||
"Q6_K": 0.75,
|
||
"Q5_K_M": 0.625,
|
||
"Q4_K_M": 0.5,
|
||
"Q4_0": 0.5,
|
||
"Q3_K_M": 0.4375,
|
||
"Q2_K": 0.3125,
|
||
"AWQ-4bit": 0.5,
|
||
"AWQ-8bit": 1.0,
|
||
"GPTQ-Int4": 0.5,
|
||
"GPTQ-Int8": 1.0,
|
||
}
|
||
|
||
# Overhead multiplier for runtime memory beyond just model weights
|
||
RUNTIME_OVERHEAD = 1.2 # ~20% overhead for KV cache, activations, OS
|
||
|
||
# Known MoE (Mixture of Experts) architecture configurations
|
||
MOE_CONFIGS = {
|
||
"mixtral": {"num_experts": 8, "active_experts": 2},
|
||
"deepseek_v2": {"num_experts": 64, "active_experts": 6},
|
||
"deepseek_v3": {"num_experts": 256, "active_experts": 8},
|
||
"deepseek_v4": {"num_experts": 384, "active_experts": 6},
|
||
"qwen3_moe": {"num_experts": 128, "active_experts": 8},
|
||
"llama4": {"num_experts": 16, "active_experts": 1},
|
||
"grok": {"num_experts": 8, "active_experts": 2},
|
||
"glm5": {"num_experts": 256, "active_experts": 8},
|
||
"minimax_m2": {"num_experts": 32, "active_experts": 2},
|
||
"mimo_v2": {"num_experts": 128, "active_experts": 8},
|
||
"nemotron3_nano": {"num_experts": 128, "active_experts": 6},
|
||
"qwen3_5_moe": {"num_experts": 256, "active_experts": 8},
|
||
"qwen3_vl_moe": {"num_experts": 256, "active_experts": 8},
|
||
}
|
||
|
||
# Published active parameter counts for well-known MoE models
|
||
MOE_ACTIVE_PARAMS = {
|
||
"mistralai/Mixtral-8x7B-Instruct-v0.1": 12_900_000_000,
|
||
"mistralai/Mixtral-8x22B-Instruct-v0.1": 39_100_000_000,
|
||
"NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": 12_900_000_000,
|
||
"deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct": 2_400_000_000,
|
||
"deepseek-ai/DeepSeek-V3": 37_000_000_000,
|
||
"deepseek-ai/DeepSeek-R1": 37_000_000_000,
|
||
"deepseek-ai/DeepSeek-V3.2": 37_000_000_000,
|
||
"deepseek-ai/DeepSeek-V3.2-Speciale": 37_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4-Pro": 49_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4-Pro-Base": 49_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4-Flash": 13_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4-Flash-Base": 13_000_000_000,
|
||
"Qwen/Qwen3-30B-A3B": 3_300_000_000,
|
||
"Qwen/Qwen3-235B-A22B": 22_000_000_000,
|
||
"Qwen/Qwen3-Coder-480B-A35B-Instruct": 35_000_000_000,
|
||
"Qwen/Qwen3-Coder-Next": 3_000_000_000,
|
||
"Qwen/Qwen3.5-35B-A3B": 3_000_000_000,
|
||
"Qwen/Qwen3.5-122B-A10B": 10_000_000_000,
|
||
"Qwen/Qwen3.5-397B-A17B": 17_000_000_000,
|
||
"Qwen/Qwen3.6-35B-A3B": 3_000_000_000,
|
||
"huihui-ai/Huihui-Qwen3.6-35B-A3B-abliterated": 3_000_000_000, # Qwen3.6-35B-A3B finetune
|
||
"Qwen/Qwen3.8-2.4T-A95B": 95_000_000_000,
|
||
"Qwen/Qwen3.8-2.4T-A95B-FP8": 95_000_000_000,
|
||
"meta-llama/Llama-4-Scout-17B-16E-Instruct": 17_000_000_000,
|
||
"meta-llama/Llama-4-Maverick-17B-128E-Instruct": 17_000_000_000,
|
||
"xai-org/grok-1": 86_000_000_000,
|
||
"moonshotai/Kimi-K2-Instruct": 32_000_000_000,
|
||
"moonshotai/Kimi-K2.5": 32_000_000_000,
|
||
"zai-org/GLM-5": 40_000_000_000,
|
||
"MiniMaxAI/MiniMax-M3": 23_000_000_000, # ~428B total, ~23B active
|
||
"MiniMaxAI/MiniMax-M2.7": 10_000_000_000,
|
||
"XiaomiMiMo/MiMo-V2-Flash": 15_000_000_000,
|
||
"nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16": 3_000_000_000,
|
||
"LiquidAI/LFM2-8B-A1B": 1_500_000_000,
|
||
"LiquidAI/LFM2-24B-A2B": 2_300_000_000, # 23.8B total, 2.3B active
|
||
"google/gemma-4-26B-A4B-it": 4_000_000_000,
|
||
"nc-ai-consortium/VAETKI-7B-A1B": 1_200_000_000,
|
||
"nc-ai-consortium/VAETKI-20B-A2B": 2_200_000_000,
|
||
"NC-AI-consortium-VAETKI/VAETKI": 10_100_000_000,
|
||
"nc-ai-consortium/VAETKI-VL-7B-A1B": 1_200_000_000,
|
||
# GLM-5.1/5.2/5.3 cards publish no active count; config matches GLM-5
|
||
# (78 layers, 256 routed experts, top-8), so they share its 40B.
|
||
"zai-org/GLM-5.1": 40_000_000_000,
|
||
"zai-org/GLM-5.2": 40_000_000_000,
|
||
"zai-org/GLM-5.3": 40_000_000_000,
|
||
"zai-org/GLM-5.3-Flash": 18_000_000_000, # 320B total, 18B active
|
||
"Qwen/Qwen3.8-Flash-Next": 6_000_000_000, # 125B + 51B n-gram embedding
|
||
"Qwen/Qwen3.8-Flash-Next-FP8": 6_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4-Flash-0731": 13_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4-Pro-0813": 49_000_000_000,
|
||
"deepseek-ai/DeepSeek-V4.1-Flash": 16_000_000_000, # 8B prefill / 16B decode
|
||
"moonshotai/Kimi-K2.6": 32_000_000_000,
|
||
"moonshotai/Kimi-K2.7-Code": 32_000_000_000,
|
||
"moonshotai/Kimi-K3": 104_000_000_000, # 2.8T total
|
||
"nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16": 3_000_000_000,
|
||
"mistralai/Mistral-Large-3-675B-Instruct-2512": 41_000_000_000,
|
||
"mistralai/Mistral-Small-4-119B-2603": 6_500_000_000,
|
||
"mistralai/Leanstral-1.5-119B-A6B": 6_500_000_000,
|
||
"ornith-ai/Ornith-1.5-35B-A3B": 3_000_000_000,
|
||
"MiniMaxAI/MiniMax-M2.5": 10_000_000_000,
|
||
"MiniMaxAI/MiniMax-M3-MXFP8": 23_000_000_000, # ~428B total
|
||
"stepfun-ai/Step-3.7-Flash": 11_000_000_000,
|
||
"XiaomiMiMo/MiMo-V2.5": 15_000_000_000, # 310B total
|
||
"tencent/Hy4-preview": 49_000_000_000, # 770B total
|
||
}
|
||
|
||
# Model card lists 32k context; config.json exposes max_position_embeddings=131072.
|
||
CONTEXT_LENGTH_OVERRIDES = {
|
||
"NC-AI-consortium-VAETKI/VAETKI": 32_768,
|
||
}
|
||
|
||
|
||
def fetch_model_info(repo_id: str) -> dict | None:
|
||
"""Fetch model info from HuggingFace API."""
|
||
url = f"{HF_API}/{repo_id}"
|
||
try:
|
||
with _hf_urlopen(url, timeout=30, kind="model_info") as resp:
|
||
return json.loads(resp.read().decode())
|
||
except urllib.error.HTTPError as e:
|
||
if e.code == 401 and not _hf_token:
|
||
print(f" ⚠ HTTP 401 for {repo_id} — model is gated, set HF_TOKEN to access",
|
||
file=sys.stderr)
|
||
elif e.code == 429:
|
||
print(f" ⚠ HTTP 429 for {repo_id}: still rate limited after "
|
||
f"{RATE_LIMIT_MAX_RETRIES} retries, skipping", file=sys.stderr)
|
||
else:
|
||
print(f" ⚠ HTTP {e.code} for {repo_id} — skipping", file=sys.stderr)
|
||
return None
|
||
except Exception as e:
|
||
print(f" ⚠ Error fetching {repo_id}: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
|
||
def extract_license(info: dict | None) -> str | None:
|
||
"""Extract normalized license metadata from HuggingFace model info."""
|
||
if not info:
|
||
return None
|
||
|
||
card_data = info.get("cardData") or {}
|
||
license_value = card_data.get("license")
|
||
license_name = card_data.get("license_name")
|
||
|
||
if isinstance(license_name, str) and license_name.strip():
|
||
license_name = license_name.strip().lower()
|
||
else:
|
||
license_name = None
|
||
|
||
if isinstance(license_value, str) and license_value.strip():
|
||
license_value = license_value.strip().lower()
|
||
return license_name if license_value == "other" and license_name else license_value
|
||
if isinstance(license_value, list):
|
||
licenses = [str(item).strip().lower() for item in license_value if str(item).strip()]
|
||
if licenses:
|
||
return ",".join(licenses)
|
||
|
||
for tag in info.get("tags", []):
|
||
if isinstance(tag, str) and tag.startswith("license:"):
|
||
license_tag = tag.removeprefix("license:").strip().lower()
|
||
if license_tag:
|
||
return license_name if license_tag == "other" and license_name else license_tag
|
||
|
||
return None
|
||
|
||
|
||
def format_param_count(total_params: int) -> str:
|
||
"""Convert raw parameter count into human-readable string."""
|
||
if total_params >= 1_000_000_000:
|
||
val = total_params / 1_000_000_000
|
||
return f"{val:.1f}B" if val != int(val) else f"{int(val)}B"
|
||
elif total_params >= 1_000_000:
|
||
val = total_params / 1_000_000
|
||
return f"{val:.0f}M"
|
||
else:
|
||
return f"{total_params / 1_000:.0f}K"
|
||
|
||
|
||
def estimate_ram(total_params: int, quant: str) -> tuple[float, float]:
|
||
"""
|
||
Estimate min RAM (Q4 quantized) and recommended RAM (comfortable headroom).
|
||
Returns (min_ram_gb, recommended_ram_gb).
|
||
"""
|
||
bpp = QUANT_BPP.get(quant, 0.5)
|
||
model_size_gb = (total_params * bpp) / (1024**3)
|
||
min_ram_gb = model_size_gb * RUNTIME_OVERHEAD
|
||
# Recommended: enough for Q4 + generous KV cache + OS headroom
|
||
recommended_ram_gb = model_size_gb * 2.0
|
||
|
||
# Apply sensible floor
|
||
min_ram_gb = max(min_ram_gb, 1.0)
|
||
recommended_ram_gb = max(recommended_ram_gb, 2.0)
|
||
|
||
return round(min_ram_gb, 1), round(recommended_ram_gb, 1)
|
||
|
||
|
||
def estimate_vram(total_params: int, quant: str) -> float:
|
||
"""Estimate minimum VRAM to fit model weights on GPU."""
|
||
bpp = QUANT_BPP.get(quant, 0.5)
|
||
model_size_gb = (total_params * bpp) / (1024**3)
|
||
# VRAM needs to hold weights + some activation memory
|
||
vram_gb = model_size_gb * 1.1
|
||
return round(max(vram_gb, 0.5), 1)
|
||
|
||
|
||
|
||
# Architecture fields filled from config.json. A failed fetch returns all-null
|
||
# and used to overwrite good catalog values on the weekly merge. Keep the old
|
||
# numbers when the fresh scrape did not produce them.
|
||
ARCH_METADATA_KEYS = (
|
||
"num_hidden_layers",
|
||
"num_attention_heads",
|
||
"num_key_value_heads",
|
||
"head_dim",
|
||
"hidden_size",
|
||
"vocab_size",
|
||
"moe_intermediate_size",
|
||
"shared_expert_intermediate_size",
|
||
)
|
||
|
||
# A missed config.json fetch falls back to 4096. Restoring prior context is
|
||
# only safe when architecture metadata was also dropped in the same pass.
|
||
DEFAULT_CONTEXT_LENGTH = 4096
|
||
|
||
# Mass config-fetch failure. One or two models can lose config.json for real;
|
||
# 25+ in a single scrape is the 2026-08-28 incident (1,764 models).
|
||
ARCH_METADATA_DROP_LIMIT = 25
|
||
|
||
|
||
def preserve_existing_metadata(old_model: dict, fresh_model: dict) -> list[str]:
|
||
"""Copy catalog fields the fresh scrape dropped. Returns restored keys.
|
||
|
||
Mutates ``fresh_model``. Used both to keep the weekly merge additive and
|
||
to count how many models needed an architecture-metadata rescue.
|
||
"""
|
||
restored: list[str] = []
|
||
if old_model.get("license") and not fresh_model.get("license"):
|
||
fresh_model["license"] = old_model["license"]
|
||
restored.append("license")
|
||
if old_model.get("gguf_sources") and not fresh_model.get("gguf_sources"):
|
||
fresh_model["gguf_sources"] = old_model["gguf_sources"]
|
||
restored.append("gguf_sources")
|
||
for key in ("hf_downloads", "hf_likes", "release_date", "languages"):
|
||
if old_model.get(key) and not fresh_model.get(key):
|
||
fresh_model[key] = old_model[key]
|
||
restored.append(key)
|
||
for key in ARCH_METADATA_KEYS:
|
||
if old_model.get(key) is not None and fresh_model.get(key) is None:
|
||
fresh_model[key] = old_model[key]
|
||
restored.append(key)
|
||
if (
|
||
fresh_model.get("context_length") == DEFAULT_CONTEXT_LENGTH
|
||
and old_model.get("context_length") not in (None, DEFAULT_CONTEXT_LENGTH)
|
||
and "num_attention_heads" in restored
|
||
):
|
||
fresh_model["context_length"] = old_model["context_length"]
|
||
restored.append("context_length")
|
||
return restored
|
||
|
||
|
||
def extract_arch_metadata(config: dict | None) -> dict:
|
||
"""Extract architecture fields for precise KV cache and MoE speed estimation.
|
||
|
||
Checks top-level config and falls back to ``text_config`` for multimodal
|
||
models (e.g. Llama 4 Scout, Qwen-VL). Returns a dict with architecture
|
||
fields (any may be ``None``).
|
||
"""
|
||
cfg = config or {}
|
||
# Try top-level first, then text_config for multimodal wrappers.
|
||
sources = [cfg]
|
||
if isinstance(cfg.get("text_config"), dict):
|
||
sources.append(cfg["text_config"])
|
||
|
||
num_hidden_layers = None
|
||
num_attention_heads = None
|
||
num_key_value_heads = None
|
||
head_dim = None
|
||
hidden_size = None
|
||
vocab_size = None
|
||
moe_intermediate_size = None
|
||
shared_expert_intermediate_size = None
|
||
|
||
for src in sources:
|
||
if num_hidden_layers is None:
|
||
num_hidden_layers = src.get("num_hidden_layers")
|
||
if num_attention_heads is None:
|
||
num_attention_heads = src.get("num_attention_heads")
|
||
if num_key_value_heads is None:
|
||
num_key_value_heads = src.get("num_key_value_heads")
|
||
if head_dim is None:
|
||
head_dim = src.get("head_dim")
|
||
if hidden_size is None:
|
||
hidden_size = src.get("hidden_size")
|
||
if head_dim is None and num_attention_heads and hidden_size:
|
||
head_dim = hidden_size // num_attention_heads
|
||
if vocab_size is None:
|
||
vocab_size = src.get("vocab_size")
|
||
if moe_intermediate_size is None:
|
||
# Prefer explicit moe_intermediate_size (Qwen, DeepSeek), fall
|
||
# back to intermediate_size which is the per-expert FFN dim in
|
||
# Mixtral-style MoE models that don't use a separate key.
|
||
v = src.get("moe_intermediate_size") or src.get("intermediate_size")
|
||
# Some models (e.g. ERNIE-4.5-VL) use a list; take first element.
|
||
if isinstance(v, list):
|
||
v = v[0] if v else None
|
||
moe_intermediate_size = v
|
||
if shared_expert_intermediate_size is None:
|
||
v = src.get("shared_expert_intermediate_size")
|
||
if isinstance(v, list):
|
||
v = v[0] if v else None
|
||
shared_expert_intermediate_size = v
|
||
|
||
# GQA default: if num_key_value_heads missing, assume MHA
|
||
if num_key_value_heads is None:
|
||
num_key_value_heads = num_attention_heads
|
||
|
||
arch = {
|
||
"num_hidden_layers": num_hidden_layers,
|
||
"num_attention_heads": num_attention_heads,
|
||
"num_key_value_heads": num_key_value_heads,
|
||
"head_dim": head_dim,
|
||
"hidden_size": hidden_size,
|
||
"vocab_size": vocab_size,
|
||
"moe_intermediate_size": moe_intermediate_size,
|
||
"shared_expert_intermediate_size": shared_expert_intermediate_size,
|
||
}
|
||
# Configs use 0 and -1 as "unset" sentinels (inclusionAI/LLaDA-UI ships
|
||
# shared_expert_intermediate_size=-1, Asilarkness/testgeniy vocab_size=0).
|
||
# The catalog reads these fields as u32, so a single negative fails the
|
||
# whole embedded parse. Bounds follow data/schema.json: the two MoE sizes
|
||
# may be 0 (no shared expert), every other field is positive or null.
|
||
zero_is_valid = {"moe_intermediate_size", "shared_expert_intermediate_size"}
|
||
for key, value in arch.items():
|
||
floor = 0 if key in zero_is_valid else 1
|
||
if isinstance(value, bool) or not isinstance(value, int) or value < floor:
|
||
arch[key] = None
|
||
# head_dim=0 (GLM-5.3-Flash) would zero the KV cache estimate.
|
||
if not arch["head_dim"] and arch["num_attention_heads"] and arch["hidden_size"]:
|
||
# A quotient below one is as unusable as the 0 it replaces.
|
||
arch["head_dim"] = arch["hidden_size"] // arch["num_attention_heads"] or None
|
||
return arch
|
||
|
||
|
||
# Expert-count key names vary by family: Kimi-K3 spells the active count
|
||
# num_experts_per_token, Step-3.x uses moe_num_experts / moe_top_k. Detection
|
||
# and parameter estimation share these so they cannot disagree on whether a
|
||
# config is MoE.
|
||
def _first_positive_int(*values) -> int | None:
|
||
"""First value that is, or starts with, a positive int.
|
||
|
||
ERNIE-4.5-VL declares per-modality lists (moe_num_experts: [64, 64]).
|
||
Left as a list it survives `list * int` silently, crashes the estimator
|
||
one line later, and would reach the catalog's integer num_experts field.
|
||
"""
|
||
for value in values:
|
||
if isinstance(value, (list, tuple)):
|
||
value = value[0] if value else None
|
||
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
|
||
return value
|
||
return None
|
||
|
||
|
||
def _config_num_experts(src: dict) -> int | None:
|
||
return _first_positive_int(
|
||
src.get("num_local_experts"), src.get("num_experts"),
|
||
src.get("n_routed_experts"), src.get("moe_num_experts"))
|
||
|
||
|
||
def _config_active_experts(src: dict) -> int | None:
|
||
return _first_positive_int(
|
||
src.get("num_experts_per_tok"), src.get("num_experts_per_token"),
|
||
src.get("top_k_experts"), src.get("moe_top_k"))
|
||
|
||
|
||
def detect_moe(repo_id: str, config: dict | None, architecture: str,
|
||
total_params: int) -> dict:
|
||
"""Detect MoE architecture and compute active parameters."""
|
||
result = {
|
||
"is_moe": False,
|
||
"num_experts": None,
|
||
"active_experts": None,
|
||
"active_parameters": None,
|
||
}
|
||
|
||
# Check config.json for MoE indicators (also check text_config for
|
||
# multimodal models like Llama 4 that nest MoE fields there)
|
||
num_experts = None
|
||
active_experts = None
|
||
if config:
|
||
num_experts = _config_num_experts(config)
|
||
active_experts = _config_active_experts(config)
|
||
if (not num_experts or not active_experts) and isinstance(config.get("text_config"), dict):
|
||
tc = config["text_config"]
|
||
num_experts = num_experts or _config_num_experts(tc)
|
||
active_experts = active_experts or _config_active_experts(tc)
|
||
|
||
# Check if architecture is in known MoE configs
|
||
if architecture in MOE_CONFIGS:
|
||
moe = MOE_CONFIGS[architecture]
|
||
num_experts = num_experts or moe["num_experts"]
|
||
active_experts = active_experts or moe["active_experts"]
|
||
|
||
if num_experts and active_experts:
|
||
result["is_moe"] = True
|
||
result["num_experts"] = num_experts
|
||
result["active_experts"] = active_experts
|
||
|
||
# Use published active params if known, otherwise estimate
|
||
if repo_id in MOE_ACTIVE_PARAMS:
|
||
result["active_parameters"] = MOE_ACTIVE_PARAMS[repo_id]
|
||
else:
|
||
result["active_parameters"] = estimate_active_params(
|
||
total_params, num_experts, active_experts)
|
||
|
||
return result
|
||
|
||
|
||
def estimate_active_params(total_params: int, num_experts: int,
|
||
active_experts: int) -> int:
|
||
"""Estimate active parameters for MoE models.
|
||
|
||
Assumes expert MLP layers are ~95% of total params and
|
||
shared attention/embedding layers are ~5%.
|
||
"""
|
||
shared_fraction = 0.05
|
||
shared = int(total_params * shared_fraction)
|
||
expert_pool = total_params - shared
|
||
per_expert = expert_pool // num_experts
|
||
return shared + active_experts * per_expert
|
||
|
||
|
||
def estimate_params_from_arch(config: dict | None) -> int | None:
|
||
"""Estimate total parameter count from architecture metadata.
|
||
|
||
Uses the transformer parameter formula accounting for MoE expert weights.
|
||
Returns None if insufficient metadata is available.
|
||
"""
|
||
cfg = config or {}
|
||
# Check text_config for multimodal wrappers
|
||
for src in [cfg, cfg.get("text_config", {})]:
|
||
hidden = src.get("hidden_size")
|
||
layers = src.get("num_hidden_layers")
|
||
vocab = src.get("vocab_size")
|
||
if hidden and layers and vocab:
|
||
break
|
||
else:
|
||
return None
|
||
|
||
n_heads = src.get("num_attention_heads") or 1
|
||
n_kv = src.get("num_key_value_heads") or n_heads
|
||
head_dim = src.get("head_dim") or (hidden // n_heads if n_heads else hidden)
|
||
|
||
# Attention: Q + K + V + O projections per layer
|
||
attn = 2 * n_heads * head_dim * hidden + 2 * n_kv * head_dim * hidden
|
||
|
||
# FFN / expert weights
|
||
def _scalar(v, default=None):
|
||
"""Coerce list values (e.g. ERNIE-4.5-VL) to a single int."""
|
||
if isinstance(v, list):
|
||
return v[0] if v else default
|
||
return v if v is not None else default
|
||
|
||
num_experts = _config_num_experts(src)
|
||
moe_inter = _scalar(src.get("moe_intermediate_size"))
|
||
shared_inter = _scalar(src.get("shared_expert_intermediate_size"), 0)
|
||
intermediate = _scalar(src.get("intermediate_size"))
|
||
|
||
if num_experts and moe_inter:
|
||
# MoE: per-expert FFN + shared expert
|
||
expert_ffn = num_experts * 3 * hidden * moe_inter
|
||
shared_ffn = 3 * hidden * shared_inter if shared_inter else 0
|
||
router = num_experts * hidden
|
||
ffn_total = expert_ffn + shared_ffn + router
|
||
elif intermediate:
|
||
# Dense: standard SwiGLU FFN (gate + up + down)
|
||
ffn_total = 3 * hidden * intermediate
|
||
else:
|
||
# Fallback: assume 4x hidden
|
||
ffn_total = 4 * hidden * hidden
|
||
|
||
per_layer = attn + ffn_total
|
||
embedding = 2 * vocab * hidden # embedding + lm_head
|
||
|
||
total = layers * per_layer + embedding
|
||
return total if total > 1_000_000 else None
|
||
|
||
|
||
def infer_use_case(repo_id: str, pipeline_tag: str | None, config: dict | None) -> str:
|
||
"""Infer a brief use-case description from model metadata."""
|
||
rid = repo_id.lower()
|
||
if pipeline_tag == "text-to-speech":
|
||
return "Text-to-speech"
|
||
if "embed" in rid or "bge" in rid:
|
||
return "Text embeddings for RAG"
|
||
if "coder" in rid or "starcoder" in rid or "code" in rid:
|
||
return "Code generation and completion"
|
||
if "r1" in rid or "reason" in rid:
|
||
return "Advanced reasoning, chain-of-thought"
|
||
if pipeline_tag in ("image-text-to-text", "any-to-any") or "-vl-" in rid:
|
||
return "Multimodal, vision and text"
|
||
if "instruct" in rid or "chat" in rid:
|
||
return "Instruction following, chat"
|
||
if "tiny" in rid or "small" in rid or "mini" in rid:
|
||
return "Lightweight, edge deployment"
|
||
if pipeline_tag == "text-generation":
|
||
return "General purpose text generation"
|
||
return "General purpose"
|
||
|
||
|
||
def infer_context_length(config: dict | None) -> int:
|
||
"""Try to extract context length from model config."""
|
||
if not config:
|
||
return 4096
|
||
|
||
# Common config keys for max sequence length
|
||
keys_to_check = [
|
||
"max_position_embeddings",
|
||
"max_sequence_length",
|
||
"seq_length",
|
||
"n_positions",
|
||
"sliding_window",
|
||
]
|
||
|
||
def _extract_from(cfg: dict) -> int | None:
|
||
for key in keys_to_check:
|
||
if key in cfg:
|
||
val = cfg[key]
|
||
if isinstance(val, int) and val > 0:
|
||
return val
|
||
return None
|
||
|
||
def _apply_rope_scaling(val: int, cfg: dict) -> int:
|
||
"""Apply RoPE scaling factor when present (e.g., Llama 4 Maverick
|
||
has max_position_embeddings=4096 but a rope_scaling factor of 256,
|
||
giving an effective context of 1M tokens)."""
|
||
rope = cfg.get("rope_scaling")
|
||
if isinstance(rope, dict) and isinstance(rope.get("factor"), (int, float)):
|
||
# YaRN/llama3-style configs name the pre-scaling window in
|
||
# original_max_position_embeddings, and max_position_embeddings
|
||
# is then already the scaled context (DeepSeek-V4: 65536 * 16 =
|
||
# 1048576). Multiplying val again reported 16M for a 1M model.
|
||
original = rope.get("original_max_position_embeddings")
|
||
if isinstance(original, int) and original > 0:
|
||
return max(val, int(original * rope["factor"]))
|
||
scaled = int(val * rope["factor"])
|
||
if scaled > val:
|
||
return scaled
|
||
return val
|
||
|
||
# Check top-level config
|
||
val = _extract_from(config)
|
||
if val is not None:
|
||
return _apply_rope_scaling(val, config)
|
||
|
||
# For multimodal models (e.g., Qwen3.5), check text_config
|
||
if "text_config" in config and isinstance(config["text_config"], dict):
|
||
tc = config["text_config"]
|
||
val = _extract_from(tc)
|
||
if val is not None:
|
||
return _apply_rope_scaling(val, tc)
|
||
|
||
return 4096
|
||
|
||
|
||
def fetch_config_json(repo_id: str) -> dict | None:
|
||
"""Fetch the full config.json from a HF repo (has max_position_embeddings)."""
|
||
url = f"https://huggingface.co/{repo_id}/resolve/main/config.json"
|
||
try:
|
||
with _hf_urlopen(url, timeout=15, kind="config_json") as resp:
|
||
return json.loads(resp.read().decode())
|
||
except urllib.error.HTTPError as e:
|
||
# 429s are waited out and counted inside _hf_urlopen. One that
|
||
# still lands here exhausted the retries, so say so instead of
|
||
# letting it look like a repo without config.json. Everything else
|
||
# (404, gated 401) is the legitimate "no config" case.
|
||
if e.code == 429:
|
||
print(f" ⚠ HTTP 429 for {repo_id}/config.json: still rate limited "
|
||
f"after {RATE_LIMIT_MAX_RETRIES} retries", file=sys.stderr)
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def extract_provider(repo_id: str) -> str:
|
||
"""Map HF org name to a friendly provider name."""
|
||
org = repo_id.split("/")[0].lower()
|
||
mapping = {
|
||
"meta-llama": "Meta",
|
||
"mistralai": "Mistral AI",
|
||
"qwen": "Alibaba",
|
||
"microsoft": "Microsoft",
|
||
"google": "Google",
|
||
"deepseek-ai": "DeepSeek",
|
||
"bigcode": "BigCode",
|
||
"cohereforai": "Cohere",
|
||
"tinyllama": "Community",
|
||
"stabilityai": "Stability AI",
|
||
"nomic-ai": "Nomic",
|
||
"baai": "BAAI",
|
||
"01-ai": "01.ai", # NEW
|
||
"upstage": "Upstage", # NEW
|
||
"tiiuae": "TII", # NEW
|
||
"huggingfaceh4": "HuggingFace", # NEW
|
||
"openchat": "OpenChat", # NEW
|
||
"lmsys": "LMSYS", # NEW
|
||
"nousresearch": "NousResearch", # NEW
|
||
"wizardlmteam": "WizardLM", # NEW
|
||
"liquidai": "Liquid AI",
|
||
"nc-ai-consortium-vaetki": "NCAI",
|
||
"nc-ai-consortium": "NCAI",
|
||
}
|
||
return mapping.get(org, org)
|
||
|
||
|
||
def infer_capabilities(repo_id: str, pipeline_tag: str | None, use_case: str) -> list[str]:
|
||
"""Infer model capabilities like vision and tool use."""
|
||
caps: list[str] = []
|
||
rid = repo_id.lower()
|
||
uc = use_case.lower()
|
||
|
||
if pipeline_tag == "text-to-speech":
|
||
caps.extend(["audio", "tts"])
|
||
|
||
# Vision
|
||
if (
|
||
pipeline_tag == "image-text-to-text"
|
||
or pipeline_tag == "any-to-any"
|
||
or "vision" in rid
|
||
or "-vl-" in rid
|
||
or rid.endswith("-vl")
|
||
or "llava" in rid
|
||
or "onevision" in rid
|
||
or "pixtral" in rid
|
||
or "vision" in uc
|
||
or "multimodal" in uc
|
||
):
|
||
caps.append("vision")
|
||
|
||
# Tool use (known families)
|
||
if (
|
||
"tool" in uc
|
||
or "function call" in uc
|
||
or "qwen3" in rid
|
||
or "qwen2.5" in rid
|
||
or "command-r" in rid
|
||
or ("llama-3" in rid and "instruct" in rid)
|
||
or ("mistral" in rid and "instruct" in rid)
|
||
or "hermes" in rid
|
||
or ("gemma-3" in rid and rid.endswith("-it"))
|
||
or ("gemma-4" in rid and rid.endswith("-it"))
|
||
):
|
||
caps.append("tool_use")
|
||
|
||
return caps
|
||
|
||
|
||
def _looks_like_language_tag(value: str, allow_bare_iso3: bool) -> bool:
|
||
parts = value.split("-")
|
||
primary = parts[0]
|
||
if not primary.isalpha():
|
||
return False
|
||
if len(primary) == 3 and not allow_bare_iso3:
|
||
return False
|
||
if len(primary) not in (2, 3):
|
||
return False
|
||
return all(2 <= len(part) <= 8 and part.isalnum() for part in parts[1:])
|
||
|
||
|
||
def _normalize_language(value: object, explicit_field: bool = False) -> str | None:
|
||
"""Return an explicit HF language tag, or None for non-language metadata."""
|
||
if not isinstance(value, str):
|
||
return None
|
||
lang = value.strip().lower().replace("_", "-")
|
||
prefixed = False
|
||
for prefix in ("language:", "languages:", "lang:"):
|
||
if lang.startswith(prefix):
|
||
lang = lang[len(prefix):]
|
||
prefixed = True
|
||
break
|
||
if _looks_like_language_tag(lang, allow_bare_iso3=prefixed or explicit_field):
|
||
return lang
|
||
return None
|
||
|
||
|
||
def infer_languages(info: dict | None, config: dict | None) -> list[str]:
|
||
"""Extract explicitly declared language metadata from HF fields."""
|
||
values: list[object] = []
|
||
for source in (info or {}, config or {}):
|
||
for key in ("language", "languages", "language_code", "language_codes"):
|
||
val = source.get(key)
|
||
if isinstance(val, list):
|
||
values.extend((item, True) for item in val)
|
||
elif val is not None:
|
||
values.append((val, True))
|
||
values.extend((tag, False) for tag in (info or {}).get("tags", []))
|
||
|
||
# Meta MMS per-language models (facebook/mms-tts-eng, facebook/mms-tts-deu,
|
||
# ...) declare no language metadata via the API; the target language is
|
||
# only encoded as an ISO-639-3 suffix in the repo name.
|
||
repo_id = (info or {}).get("id", "") or (info or {}).get("modelId", "")
|
||
repo_lower = repo_id.lower()
|
||
if "/mms-tts-" in repo_lower:
|
||
suffix = repo_lower.rsplit("mms-tts-", 1)[1]
|
||
if suffix:
|
||
values.append((suffix, True))
|
||
|
||
languages: list[str] = []
|
||
for value, explicit_field in values:
|
||
lang = _normalize_language(value, explicit_field=explicit_field)
|
||
if lang and lang not in languages:
|
||
languages.append(lang)
|
||
return languages
|
||
|
||
|
||
def detect_quant_format(repo_id: str, config: dict | None) -> tuple[str, str]:
|
||
"""Detect quantization format and label from config.json.
|
||
|
||
Returns (format, quant_label) where:
|
||
- format: "gguf", "awq", "gptq", "mlx", or "safetensors"
|
||
- quant_label: e.g. "AWQ-4bit", "GPTQ-Int4", "Q4_K_M"
|
||
"""
|
||
if not config:
|
||
return _detect_format_from_name(repo_id)
|
||
|
||
quant_config = config.get("quantization_config", {})
|
||
if not quant_config:
|
||
return _detect_format_from_name(repo_id)
|
||
|
||
quant_method = quant_config.get("quant_method", "")
|
||
bits = quant_config.get("bits", quant_config.get("num_bits", 4))
|
||
|
||
# AWQ
|
||
if quant_method == "awq":
|
||
label = f"AWQ-{bits}bit"
|
||
return ("awq", label)
|
||
|
||
# GPTQ (including gptq_marlin)
|
||
if quant_method.startswith("gptq"):
|
||
label = f"GPTQ-Int{bits}"
|
||
return ("gptq", label)
|
||
|
||
# AutoRound — pre-quantized safetensors, cannot be dynamically re-quantized
|
||
if quant_method == "auto-round":
|
||
label = f"AutoRound-{bits}bit"
|
||
return ("autoround", label)
|
||
|
||
# compressed-tensors: dig into config_groups for bits, check name for format
|
||
if quant_method == "compressed-tensors":
|
||
# Try to extract bits from config_groups
|
||
config_groups = quant_config.get("config_groups", {})
|
||
for group in config_groups.values():
|
||
if isinstance(group, dict):
|
||
weights = group.get("weights", {})
|
||
if "num_bits" in weights:
|
||
bits = weights["num_bits"]
|
||
break
|
||
|
||
name_upper = repo_id.upper()
|
||
if "-AWQ" in name_upper:
|
||
label = f"AWQ-{bits}bit"
|
||
return ("awq", label)
|
||
elif "-GPTQ" in name_upper:
|
||
label = f"GPTQ-Int{bits}"
|
||
return ("gptq", label)
|
||
elif "-AUTOROUND" in name_upper:
|
||
label = f"AutoRound-{bits}bit"
|
||
return ("autoround", label)
|
||
|
||
return _detect_format_from_name(repo_id)
|
||
|
||
|
||
def _detect_format_from_name(repo_id: str) -> tuple[str, str]:
|
||
"""Fallback: detect format from model name patterns."""
|
||
name_upper = repo_id.upper()
|
||
|
||
if "-AWQ-8BIT" in name_upper:
|
||
return ("awq", "AWQ-8bit")
|
||
if "-AWQ" in name_upper:
|
||
return ("awq", "AWQ-4bit")
|
||
if "-GPTQ-INT8" in name_upper or "-GPTQ-8BIT" in name_upper:
|
||
return ("gptq", "GPTQ-Int8")
|
||
if "-GPTQ" in name_upper:
|
||
return ("gptq", "GPTQ-Int4")
|
||
if "-AUTOROUND" in name_upper:
|
||
return ("autoround", "AutoRound-4bit")
|
||
if "-MLX-" in name_upper or name_upper.endswith("-MLX"):
|
||
return ("mlx", "Q4_K_M") # MLX uses its own quant scheme handled elsewhere
|
||
|
||
return ("gguf", "Q4_K_M")
|
||
|
||
|
||
def scrape_model(repo_id: str) -> dict | None:
|
||
"""Scrape a single model and return an LlmModel-compatible dict."""
|
||
if is_test_stub(repo_id):
|
||
print(f" ⚠ Skipping test stub {repo_id}", file=sys.stderr)
|
||
return None
|
||
info = fetch_model_info(repo_id)
|
||
if not info:
|
||
return None
|
||
|
||
# Extract parameter count from safetensors metadata
|
||
safetensors = info.get("safetensors", {})
|
||
total_params = safetensors.get("total")
|
||
if not total_params:
|
||
params_by_dtype = safetensors.get("parameters", {})
|
||
if params_by_dtype:
|
||
total_params = max(params_by_dtype.values())
|
||
|
||
if not total_params:
|
||
print(f" ⚠ No parameter count found for {repo_id}", file=sys.stderr)
|
||
return None
|
||
|
||
config = info.get("config", {})
|
||
pipeline_tag = info.get("pipeline_tag")
|
||
|
||
# Fetch full config.json for accurate context length
|
||
full_config = fetch_config_json(repo_id)
|
||
|
||
# Detect quantization format from config.json
|
||
model_format, default_quant = detect_quant_format(repo_id, full_config)
|
||
if pipeline_tag == "text-to-speech":
|
||
model_format, default_quant = ("safetensors", "F16")
|
||
context_length = CONTEXT_LENGTH_OVERRIDES.get(
|
||
repo_id,
|
||
infer_context_length(full_config) if full_config else infer_context_length(config),
|
||
)
|
||
|
||
total_params = correct_packed_param_count(repo_id, total_params, full_config)
|
||
|
||
min_ram, rec_ram = estimate_ram(total_params, default_quant)
|
||
min_vram = estimate_vram(total_params, default_quant)
|
||
|
||
architecture = config.get("model_type", "unknown")
|
||
|
||
# Detect MoE architecture
|
||
moe_info = detect_moe(repo_id, full_config, architecture, total_params)
|
||
|
||
use_case_str = infer_use_case(repo_id, pipeline_tag, config)
|
||
|
||
# Architecture metadata for the precise KV cache formula. All optional;
|
||
# absent fields cause the Rust side to fall back to the linear approx.
|
||
arch_meta = extract_arch_metadata(full_config)
|
||
|
||
license_name = extract_license(info)
|
||
|
||
result = {
|
||
"name": repo_id,
|
||
"provider": extract_provider(repo_id),
|
||
"parameter_count": format_param_count(total_params),
|
||
"parameters_raw": total_params,
|
||
"min_ram_gb": min_ram,
|
||
"recommended_ram_gb": rec_ram,
|
||
"min_vram_gb": min_vram,
|
||
"quantization": default_quant,
|
||
"format": model_format,
|
||
"context_length": context_length,
|
||
"use_case": use_case_str,
|
||
"capabilities": infer_capabilities(repo_id, pipeline_tag, use_case_str),
|
||
"languages": infer_languages(info, full_config or config),
|
||
"pipeline_tag": pipeline_tag or "unknown",
|
||
"architecture": architecture,
|
||
"hf_downloads": info.get("downloads", 0),
|
||
"hf_likes": info.get("likes", 0),
|
||
"release_date": (info.get("createdAt") or "")[:10] or None,
|
||
**arch_meta,
|
||
}
|
||
|
||
if license_name:
|
||
result["license"] = license_name
|
||
|
||
# Add MoE fields if detected
|
||
if moe_info["is_moe"]:
|
||
result["is_moe"] = True
|
||
result["num_experts"] = moe_info["num_experts"]
|
||
result["active_experts"] = moe_info["active_experts"]
|
||
result["active_parameters"] = moe_info["active_parameters"]
|
||
|
||
return result
|
||
|
||
|
||
# Retained entries re-fetched per run. The merge is additive, so a model that
|
||
# drops out of discovery keeps whatever the scraper believed when it was last
|
||
# seen, including values later scraper fixes would correct. Two requests per
|
||
# entry keeps the default inside one HF api window.
|
||
RETAINED_REVALIDATION_BUDGET = 250
|
||
|
||
# Days before an attempted entry is eligible again, whether the attempt
|
||
# succeeded or not.
|
||
REVALIDATION_COOLDOWN_DAYS = 90
|
||
|
||
# Context windows above this are rare enough to be worth a second look; the
|
||
# YaRN double-scaling bug produced 16M-167M values.
|
||
SUSPECT_CONTEXT_LENGTH = 2_097_152
|
||
|
||
# Name markers of a pre-quantized safetensors repo. HF reports the packed
|
||
# element count as safetensors.total for these, which understates parameters
|
||
# 3-6x unless config.json is available to correct it.
|
||
_PREQUANTIZED_NAME = re.compile(
|
||
r"(?<![a-z0-9])(awq|gptq|autoround|auto-round|int4|int8|w4a16|w8a8|w8a16|w4a8"
|
||
r"|fp8|nvfp4|mxfp4|mxfp8|bnb|[48]bit)(?![a-z0-9])",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _name_says_prequantized(repo_id: str) -> bool:
|
||
base = repo_id.split("/")[-1]
|
||
# google/gemma-3-1b-it-qat-int4-unquantized names the recipe it was
|
||
# trained for, but ships full-precision weights with an exact count.
|
||
if "unquantized" in base.lower():
|
||
return False
|
||
return bool(_PREQUANTIZED_NAME.search(base))
|
||
|
||
|
||
def is_prequantized_repo(repo_id: str, config: dict | None) -> bool:
|
||
"""True when the repo ships packed/quantized weights rather than full
|
||
precision: config.json says so, or the name does."""
|
||
cfg = config or {}
|
||
if cfg.get("quantization_config") or cfg.get("quantization"):
|
||
return True
|
||
return _name_says_prequantized(repo_id)
|
||
|
||
|
||
# Architectures estimate_params_from_arch cannot size: it prices every layer
|
||
# as attention + (MoE) MLP, but these interleave Mamba/SSM or linear-attention
|
||
# layers. It reported 101.6B for the 31.6B Nemotron-3-Nano and 17.0B for the
|
||
# 8.1B Nemotron-H-8B.
|
||
_HYBRID_SSM_MODEL_TYPE = re.compile(
|
||
r"mamba|rwkv|zamba|jamba|bamba|hyena|nemotron_h|falcon_h1|granitemoehybrid"
|
||
r"|lfm2|plamo2|recurrent_gemma|hybrid|(?:^|[_-])ssm(?:$|[_-])"
|
||
)
|
||
|
||
# A speculative-decoding draft head is named after its target model but is a
|
||
# fraction of its size; the safetensors count is right and the name is not.
|
||
_DRAFT_HEAD_NAME = re.compile(
|
||
r"(?<![a-z])(dflash|dspark|eagle\d?|medusa|speculator|draft)(?![a-z])", re.IGNORECASE)
|
||
|
||
_DECLARED_SIZE = re.compile(r"(?<![A-Za-z0-9.])(\d+(?:\.\d+)?)([BT])(?![A-Za-z0-9])")
|
||
|
||
|
||
def name_declared_params(repo_id: str) -> int | None:
|
||
"""Total parameters the repo name declares ("Qwen3-235B-A22B" -> 235e9).
|
||
|
||
None when the name declares nothing usable: no size token, an "NxMB"
|
||
expert product (Mixtral-8x7B is 46.7B, not 7B or 56B), or a draft head.
|
||
Active counts ("A22B") and effective sizes ("E4B") are not matched.
|
||
"""
|
||
base = repo_id.split("/")[-1]
|
||
if _DRAFT_HEAD_NAME.search(base) or re.search(r"\d+x\d", base, re.IGNORECASE):
|
||
return None
|
||
# "1.58B" is BitNet's bit width, not a size.
|
||
found = [(float(v), u) for v, u in _DECLARED_SIZE.findall(base.upper()) if v != "1.58"]
|
||
billions = [v * 1e9 for v, u in found if u == "B"]
|
||
# Next to a B size, a T figure is training tokens (bitnet-b1.58-2B-4T).
|
||
# On its own it is the size (Qwen3.8-2.4T-A95B).
|
||
sizes = billions or [v * 1e12 for v, u in found if u == "T"]
|
||
return int(max(sizes)) if sizes else None
|
||
|
||
|
||
def _is_hybrid_ssm(config: dict | None) -> bool:
|
||
cfg = config or {}
|
||
for src in (cfg, cfg.get("text_config") or {}):
|
||
# Families, not exact names: the catalog already holds variants such
|
||
# as nemotron_h_puzzle, hybrid_mamba_attn, rwkv7_native and lfm2_vl.
|
||
# Over-matching is the safe direction, since it only means keeping the
|
||
# reported count or the declared size instead of an estimate.
|
||
if _HYBRID_SSM_MODEL_TYPE.search(str(src.get("model_type", "")).lower()):
|
||
return True
|
||
if "hybrid_override_pattern" in src or "layers_block_type" in src:
|
||
return True
|
||
return False
|
||
|
||
|
||
def correct_packed_param_count(repo_id: str, total_params: int,
|
||
config: dict | None) -> int:
|
||
"""Replace an understated safetensors count with a better figure.
|
||
|
||
safetensors.total counts tensor elements, so int4 weights packed into
|
||
int32 report ~1/6 of the real parameters (#1045), and some repos publish
|
||
a placeholder (ornith-ai/Ornith-1.0-35B reports ~0). The architecture
|
||
estimate rescues both when it is more than twice the reported count.
|
||
|
||
Two guards, because the estimate is not always right:
|
||
- hybrid SSM architectures are never estimated (see
|
||
_HYBRID_SSM_MODEL_TYPE); an understated count falls back to the size
|
||
the name declares instead.
|
||
- any replacement more than 1.5x the name-declared size is capped to it.
|
||
A draft head keeps its own count: its name describes another model.
|
||
"""
|
||
base = repo_id.split("/")[-1]
|
||
if _DRAFT_HEAD_NAME.search(base):
|
||
return total_params
|
||
declared = name_declared_params(repo_id)
|
||
|
||
if _is_hybrid_ssm(config):
|
||
# A quantized repo's count is packed, so it only ever understates:
|
||
# NVFP4 stores two weights per element (17.8B for a 30B model), which
|
||
# the 2x margin misses. FP8 counts are exact and stay within 1.25x.
|
||
margin = 1.25 if is_prequantized_repo(repo_id, config) else 2.0
|
||
if declared and declared > total_params * margin:
|
||
return declared
|
||
return total_params
|
||
|
||
try:
|
||
arch_params = estimate_params_from_arch(config)
|
||
except (TypeError, ValueError, AttributeError, KeyError, IndexError,
|
||
ZeroDivisionError) as e:
|
||
# One repo's odd config.json must not abort a 6,000-model scrape;
|
||
# without an estimate the reported count simply stands.
|
||
print(f" ⚠ {repo_id}: architecture estimate failed ({e}), "
|
||
f"keeping the reported parameter count", file=sys.stderr)
|
||
return total_params
|
||
if not arch_params or arch_params <= total_params * 2:
|
||
return total_params
|
||
if declared and arch_params > declared * 1.5:
|
||
return declared
|
||
return arch_params
|
||
|
||
|
||
def revalidation_priority(model: dict) -> int | None:
|
||
"""Rank how likely a retained entry is to carry a stale, wrong value.
|
||
|
||
Lower is more urgent; None means there is no specific reason to re-fetch.
|
||
"""
|
||
name = model.get("name", "")
|
||
prequantized = (
|
||
model.get("format") in ("awq", "gptq", "autoround")
|
||
or _name_says_prequantized(name)
|
||
)
|
||
if prequantized and not model.get("hidden_size"):
|
||
return 0 # packed parameter count nothing has been able to correct
|
||
if (model.get("context_length") or 0) > SUSPECT_CONTEXT_LENGTH:
|
||
return 1
|
||
if not model.get("release_date"):
|
||
return 2
|
||
return None
|
||
|
||
|
||
def revalidation_lost_parameters(before: dict, after: dict) -> bool:
|
||
"""True when a re-fetch reports far fewer parameters than the retained
|
||
entry. Revalidation exists to fix understated counts, so a sharp drop is
|
||
more likely a packed count the estimator could not correct (gpt-oss-20b
|
||
3-bit repos: 22.3B retained, 2.9B re-fetched) than a real correction."""
|
||
old = before.get("parameters_raw") or 0
|
||
new = after.get("parameters_raw") or 0
|
||
return old > 0 and new < old / 1.5
|
||
|
||
|
||
def _revalidated_recently(model: dict, today: date) -> bool:
|
||
stamp = model.get("_revalidated")
|
||
if not stamp:
|
||
return False
|
||
try:
|
||
attempted = date.fromisoformat(stamp)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return (today - attempted).days < REVALIDATION_COOLDOWN_DAYS
|
||
|
||
|
||
def select_retained_for_revalidation(
|
||
existing: list[dict], fresh_names: set[str], budget: int,
|
||
today: date | None = None,
|
||
) -> list[str]:
|
||
"""Pick the retained entries most worth re-fetching this run.
|
||
|
||
Ordered by priority, then by downloads so the entries users actually see
|
||
are corrected first. Entries scraped this run are never selected, and
|
||
neither is one attempted within the cooldown: a repo that is gone or
|
||
gated fails the same way every week, and without the cooldown those
|
||
failures would hold the top of the ranking and starve everything below.
|
||
"""
|
||
if budget <= 0:
|
||
return []
|
||
today = today or date.today()
|
||
ranked = []
|
||
for model in existing:
|
||
name = model.get("name", "")
|
||
if not name or name in fresh_names:
|
||
continue
|
||
if _revalidated_recently(model, today):
|
||
continue
|
||
priority = revalidation_priority(model)
|
||
if priority is not None:
|
||
ranked.append((priority, -(model.get("hf_downloads") or 0), name))
|
||
ranked.sort()
|
||
return [name for _, _, name in ranked[:budget]]
|
||
|
||
|
||
def scrape_models_parallel(repo_ids: list[str], threads: int) -> tuple[list[dict], set[str]]:
|
||
"""Scrape a batch of models with optional parallelism.
|
||
|
||
Returns (results, scraped_names).
|
||
"""
|
||
results: list[dict] = []
|
||
scraped_names: set[str] = set()
|
||
total = len(repo_ids)
|
||
|
||
if threads <= 1:
|
||
for i, repo_id in enumerate(repo_ids, 1):
|
||
print(f"[{i}/{total}] {repo_id}...")
|
||
model = scrape_model(repo_id)
|
||
if model:
|
||
print(f" ✓ {model['parameter_count']} params, "
|
||
f"min {model['min_ram_gb']} GB RAM, "
|
||
f"ctx {model['context_length']}")
|
||
results.append(model)
|
||
scraped_names.add(repo_id)
|
||
# Be polite to the API in single-thread mode.
|
||
time.sleep(0.3)
|
||
return results, scraped_names
|
||
|
||
print(f"Using {threads} threads for model scraping")
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
|
||
# executor.map keeps output aligned with input ordering while running concurrently.
|
||
for i, (repo_id, model) in enumerate(
|
||
zip(repo_ids, executor.map(scrape_model, repo_ids)),
|
||
1,
|
||
):
|
||
print(f"[{i}/{total}] {repo_id}...")
|
||
if model:
|
||
print(f" ✓ {model['parameter_count']} params, "
|
||
f"min {model['min_ram_gb']} GB RAM, "
|
||
f"ctx {model['context_length']}")
|
||
results.append(model)
|
||
scraped_names.add(repo_id)
|
||
|
||
return results, scraped_names
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GGUF source enrichment — find pre-quantized GGUF repos for known models
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Providers known to publish high-quality GGUF quantizations
|
||
GGUF_PROVIDERS = ["unsloth", "bartowski", "ggml-org", "TheBloke", "mradermacher"]
|
||
|
||
GGUF_CACHE_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "gguf_sources_cache.json")
|
||
# Re-check repos older than this. Must exceed the weekly CI cadence (7 days)
|
||
# or every cached entry is already expired by the next scheduled run and the
|
||
# cache restored in CI is useless; 13 days staggers re-checks across runs
|
||
# (roughly half the entries each week).
|
||
GGUF_CACHE_MAX_AGE_DAYS = 13
|
||
|
||
|
||
def _load_gguf_cache() -> dict:
|
||
"""Load the GGUF source cache from disk.
|
||
|
||
Returns dict mapping model repo_id -> {"sources": [...], "checked": ISO timestamp}
|
||
"""
|
||
try:
|
||
with open(GGUF_CACHE_FILE) as f:
|
||
return json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
return {}
|
||
|
||
|
||
def _save_gguf_cache(cache: dict):
|
||
"""Save the GGUF source cache to disk."""
|
||
os.makedirs(os.path.dirname(GGUF_CACHE_FILE), exist_ok=True)
|
||
with open(GGUF_CACHE_FILE, "w") as f:
|
||
json.dump(cache, f, indent=2)
|
||
|
||
|
||
def _cache_entry_fresh(entry: dict) -> bool:
|
||
"""Check if a cache entry is still valid."""
|
||
try:
|
||
checked = datetime.fromisoformat(entry["checked"])
|
||
return (datetime.now(timezone.utc) - checked) < timedelta(days=GGUF_CACHE_MAX_AGE_DAYS)
|
||
except (KeyError, ValueError):
|
||
return False
|
||
|
||
|
||
def _model_gguf_repo_candidates(repo_id: str) -> list[tuple[str, str]]:
|
||
"""Generate candidate GGUF repo names for a model.
|
||
|
||
Returns list of (provider, candidate_repo_id) tuples.
|
||
e.g. for "meta-llama/Llama-3.1-8B-Instruct" →
|
||
[("unsloth", "unsloth/Llama-3.1-8B-Instruct-GGUF"),
|
||
("bartowski", "bartowski/Llama-3.1-8B-Instruct-GGUF")]
|
||
"""
|
||
model_name = repo_id.split("/")[-1]
|
||
candidates = []
|
||
for provider in GGUF_PROVIDERS:
|
||
candidates.append((provider, f"{provider}/{model_name}-GGUF"))
|
||
return candidates
|
||
|
||
|
||
def _base_models_from_tags(tags: list) -> list[str]:
|
||
"""Extract base-model repo ids from HF tags.
|
||
|
||
Quant repos carry tags like `base_model:tomaszki/gemma-3` and
|
||
`base_model:quantized:tomaszki/gemma-3` — the repo id is always the
|
||
segment after the last colon.
|
||
"""
|
||
return [
|
||
t.rsplit(":", 1)[-1].lower()
|
||
for t in tags
|
||
if isinstance(t, str) and t.startswith("base_model:")
|
||
]
|
||
|
||
|
||
_REPO_PARAMS_CACHE: dict[str, int | None] = {}
|
||
_MIRROR_PARAMS_TOLERANCE = 0.30
|
||
|
||
|
||
# Answers that settle a base lookup: the repo is gone or cannot be read.
|
||
# Anything else (a 429 past the retries, a 5xx, a transport error) says
|
||
# nothing about the repo and must not be remembered for the rest of the run.
|
||
_NO_REPO_HTTP_CODES = frozenset({401, 403, 404})
|
||
|
||
|
||
def _repo_total_params(repo_id: str) -> int | None:
|
||
"""Total parameter count of a repo from its safetensors metadata.
|
||
|
||
Cached for the rest of the run only when HuggingFace answered: a repo
|
||
without safetensors.total, or one that is gone, is a real None. A 429
|
||
that survived the retries, a 5xx or a transport failure propagates
|
||
instead, so check_gguf_repo_exists keeps its own handling (a 429 is
|
||
"unknown", nothing cached) and the next candidate declaring the same
|
||
base asks again (#1067).
|
||
"""
|
||
if repo_id in _REPO_PARAMS_CACHE:
|
||
return _REPO_PARAMS_CACHE[repo_id]
|
||
url = f"{HF_API}/{repo_id}"
|
||
try:
|
||
with _hf_urlopen(url, timeout=10, kind="gguf_probe") as resp:
|
||
info = json.loads(resp.read().decode())
|
||
except urllib.error.HTTPError as e:
|
||
if e.code not in _NO_REPO_HTTP_CODES:
|
||
raise
|
||
info = {}
|
||
if not isinstance(info, dict):
|
||
raise ValueError(f"{repo_id}: expected an object, got {type(info).__name__}")
|
||
st = info.get("safetensors")
|
||
raw = st.get("total") if isinstance(st, dict) else None
|
||
try:
|
||
total = int(raw) if raw else None
|
||
except (TypeError, ValueError):
|
||
total = None
|
||
_REPO_PARAMS_CACHE[repo_id] = total
|
||
return total
|
||
|
||
|
||
def check_gguf_repo_exists(
|
||
repo_id: str,
|
||
source_repo_id: str | None = None,
|
||
source_params: int | None = None,
|
||
) -> bool | None:
|
||
"""Check that a HuggingFace repo exists, has GGUF files, and — when the
|
||
repo declares `base_model` tags — was actually quantized from
|
||
`source_repo_id`.
|
||
|
||
Candidate repo names are built from the bare model name only, so different
|
||
orgs' models with the same name (e.g. `tiny-random/gemma-3` vs
|
||
`tomaszki/gemma-3`) would otherwise be linked to the wrong quant.
|
||
|
||
A base_model mismatch is still accepted when the declared base has ~the
|
||
same parameter count as the source model (`source_params`): that's a
|
||
mirror/re-upload of the same weights (e.g. unsloth re-uploads pointing at
|
||
the canonical upstream). Repos without base_model tags are accepted as
|
||
before (unverifiable).
|
||
|
||
Returns None when HuggingFace did not answer, for the candidate or for a
|
||
base_model lookup: rate limited after the retries, a 5xx, or a transport
|
||
failure. The answer is unknown, and enrich_gguf_sources must not cache
|
||
it as a miss.
|
||
"""
|
||
url = f"{HF_API}/{repo_id}"
|
||
try:
|
||
with _hf_urlopen(url, timeout=10, kind="gguf_probe") as resp:
|
||
info = json.loads(resp.read().decode())
|
||
tags = info.get("tags", [])
|
||
if "gguf" not in tags:
|
||
return False
|
||
if source_repo_id:
|
||
bases = _base_models_from_tags(tags)
|
||
if bases and source_repo_id.lower() not in bases:
|
||
if not source_params:
|
||
return False
|
||
base_params = next(
|
||
(p for p in (_repo_total_params(b) for b in bases) if p),
|
||
None,
|
||
)
|
||
if not base_params:
|
||
return False
|
||
ratio = base_params / source_params
|
||
return abs(ratio - 1.0) <= _MIRROR_PARAMS_TOLERANCE
|
||
return True
|
||
except urllib.error.HTTPError as e:
|
||
if e.code == 429 or e.code >= 500:
|
||
return None
|
||
return False
|
||
except (OSError, ValueError, http.client.HTTPException):
|
||
# HF did not answer: unknown, never cached as a miss
|
||
return None
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _resolve_gguf_sources(
|
||
repo_id: str, source_params: int | None = None
|
||
) -> tuple[list[dict], list[tuple[str, bool | None]]]:
|
||
"""Resolve GGUF sources for a single model repo.
|
||
|
||
Returns (sources, checks) where checks is [(candidate_repo, exists), ...]
|
||
and exists is None for a probe HuggingFace did not answer.
|
||
"""
|
||
sources: list[dict] = []
|
||
checks: list[tuple[str, bool | None]] = []
|
||
for provider, candidate_repo in _model_gguf_repo_candidates(repo_id):
|
||
exists = check_gguf_repo_exists(
|
||
candidate_repo, source_repo_id=repo_id, source_params=source_params
|
||
)
|
||
checks.append((candidate_repo, exists))
|
||
if exists:
|
||
sources.append({"repo": candidate_repo, "provider": provider})
|
||
time.sleep(0.15) # Be polite to the API
|
||
return sources, checks
|
||
|
||
|
||
# Models probed for GGUF sources per run. Each costs one request per provider
|
||
# in GGUF_PROVIDERS against HF's 500-per-5-minutes api window, so a cold pass
|
||
# over the ~12k GGUF-format entries is ~5 hours: the 2026-09-19 run probed
|
||
# 3,604 in 93 minutes and was killed by the job timeout with nothing saved.
|
||
# 600 is ~30 minutes of probing; the rest waits for later runs.
|
||
GGUF_PROBE_BUDGET = 600
|
||
|
||
# Probes between cache writes, so a killed run keeps what it learned.
|
||
GGUF_CACHE_SAVE_EVERY = 100
|
||
|
||
|
||
def order_gguf_probe_queue(
|
||
queue: list[tuple[int, str, int | None]], models: list[dict]
|
||
) -> list[tuple[int, str, int | None]]:
|
||
"""Order uncached models so a limited budget is spent where it matters.
|
||
|
||
Models with no known GGUF source come first (a probe can only add
|
||
information there; one that already carries sources from the prior
|
||
catalog loses nothing by waiting), then by downloads.
|
||
"""
|
||
def key(item: tuple[int, str, int | None]):
|
||
model = models[item[0] - 1]
|
||
return (bool(model.get("gguf_sources")), -(model.get("hf_downloads") or 0))
|
||
|
||
return sorted(queue, key=key)
|
||
|
||
|
||
def enrich_gguf_sources(models: list[dict], threads: int = 1,
|
||
budget: int | None = None) -> int:
|
||
"""Add gguf_sources to models by checking GGUF provider repos.
|
||
|
||
Uses a persistent cache to avoid re-checking repos on every scrape, and
|
||
probes at most `budget` uncached models (None for no limit).
|
||
Returns the number of models enriched.
|
||
"""
|
||
cache = _load_gguf_cache()
|
||
enriched = 0
|
||
cache_hits = 0
|
||
total = len(models)
|
||
|
||
to_check: list[tuple[int, str, int | None]] = []
|
||
left_uncached = 0
|
||
since_save = 0
|
||
|
||
for i, model in enumerate(models, 1):
|
||
repo_id = model["name"]
|
||
|
||
# Skip non-GGUF models (AWQ/GPTQ don't use GGUF sources)
|
||
if model.get("format", "gguf") != "gguf":
|
||
continue
|
||
|
||
# Check cache first
|
||
if repo_id in cache and _cache_entry_fresh(cache[repo_id]):
|
||
sources = cache[repo_id]["sources"]
|
||
cache_hits += 1
|
||
else:
|
||
# An expired entry is still the best answer available if this
|
||
# model does not make the budget.
|
||
stale = cache.get(repo_id, {}).get("sources")
|
||
if stale and not model.get("gguf_sources"):
|
||
model["gguf_sources"] = stale
|
||
to_check.append((i, repo_id, model.get("parameters_raw")))
|
||
continue
|
||
|
||
if sources:
|
||
model["gguf_sources"] = sources
|
||
enriched += 1
|
||
|
||
to_check = order_gguf_probe_queue(to_check, models)
|
||
deferred = 0
|
||
if budget is not None:
|
||
# A negative value would slice from the end and probe nearly
|
||
# everything, the pass this budget exists to prevent.
|
||
budget = max(budget, 0)
|
||
if budget is not None and len(to_check) > budget:
|
||
deferred = len(to_check) - budget
|
||
to_check = to_check[:budget]
|
||
print(f" Probing {len(to_check)} of {len(to_check) + deferred} uncached models "
|
||
f"(budget {budget}); {deferred} deferred to later runs")
|
||
|
||
# Resolve cache misses, optionally in parallel.
|
||
if to_check:
|
||
def _apply_checked_sources(
|
||
idx: int,
|
||
repo_id: str,
|
||
sources: list[dict],
|
||
checks: list[tuple[str, bool | None]],
|
||
):
|
||
nonlocal enriched, left_uncached, since_save
|
||
if sources:
|
||
models[idx - 1]["gguf_sources"] = sources
|
||
enriched += 1
|
||
if any(exists is None for _, exists in checks):
|
||
# A probe stayed rate limited, so the answer is unknown. Leave
|
||
# the cache alone and re-check next run rather than store a
|
||
# miss for GGUF_CACHE_MAX_AGE_DAYS (#1047 review).
|
||
left_uncached += 1
|
||
return
|
||
cache[repo_id] = {
|
||
"sources": sources,
|
||
"checked": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
# Checkpoint after recording this result, so it is in the file.
|
||
since_save += 1
|
||
if since_save >= GGUF_CACHE_SAVE_EVERY:
|
||
_save_gguf_cache(cache)
|
||
since_save = 0
|
||
|
||
if threads <= 1:
|
||
for idx, repo_id, params_raw in to_check:
|
||
sources, checks = _resolve_gguf_sources(repo_id, params_raw)
|
||
print(f" [{idx}/{total}] {repo_id}")
|
||
for candidate_repo, exists in checks:
|
||
mark = "?" if exists is None else ("✓" if exists else "✗")
|
||
print(f" {mark} {candidate_repo}")
|
||
print(f" -> {len(sources)} source(s)")
|
||
_apply_checked_sources(idx, repo_id, sources, checks)
|
||
else:
|
||
print(f" Using {threads} threads for GGUF source checks")
|
||
future_to_meta: dict[concurrent.futures.Future, tuple[int, str]] = {}
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
|
||
for idx, repo_id, params_raw in to_check:
|
||
future = executor.submit(_resolve_gguf_sources, repo_id, params_raw)
|
||
future_to_meta[future] = (idx, repo_id)
|
||
|
||
for future in concurrent.futures.as_completed(future_to_meta):
|
||
idx, repo_id = future_to_meta[future]
|
||
sources, checks = future.result()
|
||
print(f" [{idx}/{total}] {repo_id}")
|
||
for candidate_repo, exists in checks:
|
||
mark = "?" if exists is None else ("✓" if exists else "✗")
|
||
print(f" {mark} {candidate_repo}")
|
||
print(f" -> {len(sources)} source(s)")
|
||
_apply_checked_sources(idx, repo_id, sources, checks)
|
||
|
||
_save_gguf_cache(cache)
|
||
print(f" Cache: {cache_hits} hits, {len(to_check)} API checks, "
|
||
f"{left_uncached} left uncached (rate limited), {deferred} deferred")
|
||
return enriched
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Auto-discovery from HuggingFace trending / most-downloaded
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Pipeline tags to search for discoverable models
|
||
DISCOVER_PIPELINES = [
|
||
"text-generation",
|
||
"text2text-generation",
|
||
"image-text-to-text",
|
||
"feature-extraction", # Embedding models (useful for RAG sizing)
|
||
"text-to-speech",
|
||
]
|
||
|
||
PRIMARY_DISCOVER_PIPELINE = "text-generation"
|
||
|
||
# Orgs to skip — test fixtures and legacy mirrors only.
|
||
# Quantization/repack orgs (TheBloke, bartowski, unsloth, etc.) are kept
|
||
# because they provide popular quantised variants users actually run.
|
||
SKIP_ORGS = {
|
||
"trl-internal-testing", # Test fixtures
|
||
}
|
||
|
||
# Markers of CI/test-stub repos: randomly initialized micro-models that look
|
||
# like real model families by name (e.g. `tiny-random/gemma-3` is 9M params).
|
||
# They poison installed-detection and throughput estimates, so they never
|
||
# belong in the catalog regardless of download counts.
|
||
TEST_STUB_MARKERS = ("tiny-random", "tiny-dummy", "-random-init", "ci-random-")
|
||
|
||
|
||
def is_test_stub(repo_id: str) -> bool:
|
||
rid = repo_id.lower()
|
||
if any(marker in rid for marker in TEST_STUB_MARKERS):
|
||
return True
|
||
name = rid.split("/")[-1]
|
||
return name.startswith(("test-", "testing-", "test_")) or bool(re.match(r"^test\d", name))
|
||
|
||
# Sort strategies to query — results are merged and deduplicated.
|
||
# Each strategy surfaces models that the others might miss.
|
||
DISCOVER_SORT_STRATEGIES = [
|
||
"downloads", # All-time most downloaded
|
||
"trendingScore", # Currently trending (recent velocity)
|
||
"likes30d", # Most liked in the last 30 days
|
||
]
|
||
|
||
|
||
def _fetch_models_page(url: str) -> tuple[list[dict], str | None]:
|
||
"""Fetch a page of models from the HuggingFace API.
|
||
|
||
Returns (models, next_url) where next_url is parsed from the Link header
|
||
for cursor-based pagination, or None if there are no more pages.
|
||
"""
|
||
with _hf_urlopen(url, timeout=60, kind="listing") as resp:
|
||
# Parse cursor-based pagination from Link header
|
||
next_url = None
|
||
link_header = resp.headers.get("Link", "")
|
||
if 'rel="next"' in link_header:
|
||
# Format: <url>; rel="next"
|
||
next_url = link_header.split(">")[0].lstrip("<")
|
||
models = json.loads(resp.read().decode())
|
||
return models, next_url
|
||
|
||
|
||
def _build_first_page_url(pipeline: str, sort: str, page_size: int) -> str:
|
||
"""Build the initial API URL for a pipeline query."""
|
||
return (
|
||
f"{HF_API}?"
|
||
f"pipeline_tag={pipeline}&"
|
||
f"sort={sort}&"
|
||
f"direction=-1&"
|
||
f"limit={page_size}&"
|
||
f"expand[]=safetensors&"
|
||
f"expand[]=config&"
|
||
f"expand[]=cardData"
|
||
)
|
||
|
||
|
||
def _estimate_params_from_config(config: dict) -> int | None:
|
||
"""Try to estimate parameter count from model config fields.
|
||
|
||
This is a fallback for models that don't expose safetensors metadata
|
||
in the listing API. Uses common config.json fields to estimate.
|
||
"""
|
||
# Some configs directly state the param count
|
||
for key in ("num_parameters", "n_params", "total_params"):
|
||
val = config.get(key)
|
||
if val and isinstance(val, (int, float)) and val > 1000:
|
||
return int(val)
|
||
|
||
# Estimate from architecture dimensions (rough but useful)
|
||
hidden = config.get("hidden_size") or config.get("d_model")
|
||
layers = config.get("num_hidden_layers") or config.get("n_layer")
|
||
vocab = config.get("vocab_size")
|
||
intermediate = config.get("intermediate_size") or config.get("d_ff")
|
||
|
||
if hidden and layers and vocab:
|
||
# Rough transformer parameter estimate:
|
||
# ~12 * L * H^2 (attention + FFN) + V * H (embeddings)
|
||
ffn_factor = (intermediate / hidden) if intermediate else 4.0
|
||
params = int(layers * hidden * hidden * (4 + 2 * ffn_factor) + vocab * hidden)
|
||
if params > 1_000_000: # sanity check: at least 1M params
|
||
return params
|
||
|
||
return None
|
||
|
||
|
||
def _process_listing(
|
||
m: dict,
|
||
pipeline: str,
|
||
curated: set[str],
|
||
seen_ids: set[str],
|
||
min_downloads: int,
|
||
require_downloads_floor: bool,
|
||
stats: dict,
|
||
) -> dict | None:
|
||
"""Check a single model listing against filters.
|
||
|
||
Returns the listing with _total_params attached if accepted, else None.
|
||
Mutates seen_ids and stats as side effects.
|
||
"""
|
||
repo_id = m.get("id", "")
|
||
if not repo_id or "/" not in repo_id:
|
||
return None
|
||
stats["total_seen"] += 1
|
||
|
||
if repo_id in curated:
|
||
stats["skip_curated"] += 1
|
||
return None
|
||
|
||
if repo_id in seen_ids:
|
||
stats["skip_duplicate"] += 1
|
||
return None
|
||
seen_ids.add(repo_id)
|
||
|
||
org = repo_id.split("/")[0]
|
||
if org in SKIP_ORGS:
|
||
stats["skip_org"] += 1
|
||
return None
|
||
|
||
if is_test_stub(repo_id):
|
||
stats["skip_test_stub"] += 1
|
||
return None
|
||
|
||
downloads_raw = m.get("downloads")
|
||
downloads = downloads_raw or 0
|
||
if downloads < min_downloads and (require_downloads_floor or downloads_raw is not None):
|
||
stats["skip_downloads"] += 1
|
||
return None
|
||
|
||
tags = set(m.get("tags", []))
|
||
if tags & {"adapter", "merge", "lora", "qlora"}:
|
||
stats["skip_tags"] += 1
|
||
return None
|
||
|
||
# Try safetensors metadata first
|
||
safetensors = m.get("safetensors", {})
|
||
total_params = safetensors.get("total")
|
||
if not total_params:
|
||
params_by_dtype = safetensors.get("parameters", {})
|
||
if params_by_dtype:
|
||
total_params = max(params_by_dtype.values())
|
||
|
||
param_source = "safetensors"
|
||
|
||
# Fallback: fetch full config.json and estimate from arch dims.
|
||
# Cap config fetches to avoid excessive network calls during discovery.
|
||
config_attempts = stats["params_from_config"] + stats.get("skip_no_params", 0)
|
||
if not total_params and config_attempts < 500:
|
||
full_cfg = fetch_config_json(repo_id)
|
||
if full_cfg:
|
||
total_params = _estimate_params_from_config(full_cfg)
|
||
param_source = "config"
|
||
|
||
if not total_params:
|
||
stats["skip_no_params"] += 1
|
||
return None
|
||
|
||
if param_source == "safetensors":
|
||
stats["params_from_safetensors"] += 1
|
||
else:
|
||
stats["params_from_config"] += 1
|
||
|
||
m["_total_params"] = total_params
|
||
m["_pipeline_tag"] = m.get("pipeline_tag") or pipeline
|
||
stats["accepted"] += 1
|
||
return m
|
||
|
||
|
||
def discover_trending_models(limit: int = 30, min_downloads: int = 10000) -> list[dict]:
|
||
"""Discover popular models from HuggingFace using multiple sort strategies.
|
||
|
||
Queries the HF API with three sort strategies (all-time downloads,
|
||
trending score, and 30-day likes) across all pipeline types, then
|
||
merges and deduplicates the results. This surfaces both established
|
||
popular models and newly trending ones.
|
||
|
||
Uses cursor-based pagination and falls back to estimating params from
|
||
config.json when safetensors metadata is unavailable.
|
||
|
||
Returns a list of dicts with model listing data for models NOT already
|
||
in TARGET_MODELS.
|
||
"""
|
||
curated = set(TARGET_MODELS)
|
||
discovered = []
|
||
seen_ids = set()
|
||
|
||
# Keep --discover-limit as the mainstream LLM discovery budget. Other
|
||
# pipelines are additive so TTS/audio discovery does not take slots away
|
||
# from text-generation coverage.
|
||
side_quota = max(1, limit // len(DISCOVER_PIPELINES))
|
||
pipeline_limits = {
|
||
pipeline: limit if pipeline == PRIMARY_DISCOVER_PIPELINE else side_quota
|
||
for pipeline in DISCOVER_PIPELINES
|
||
}
|
||
pipeline_counts = {pipeline: 0 for pipeline in DISCOVER_PIPELINES}
|
||
|
||
def _quotas_full() -> bool:
|
||
return all(pipeline_counts[p] >= pipeline_limits[p] for p in DISCOVER_PIPELINES)
|
||
|
||
PAGE_SIZE = 1000
|
||
|
||
stats = {
|
||
"total_seen": 0,
|
||
"skip_curated": 0,
|
||
"skip_duplicate": 0,
|
||
"skip_org": 0,
|
||
"skip_test_stub": 0,
|
||
"skip_downloads": 0,
|
||
"skip_tags": 0,
|
||
"skip_no_params": 0,
|
||
"params_from_safetensors": 0,
|
||
"params_from_config": 0,
|
||
"accepted": 0,
|
||
}
|
||
|
||
for sort_strategy in DISCOVER_SORT_STRATEGIES:
|
||
strategy_accepted = 0
|
||
# Trending/likes sorts surface newly popular models that may not
|
||
# have high all-time downloads yet — use a lower floor for them.
|
||
effective_min = (min_downloads if sort_strategy == "downloads"
|
||
else max(1000, min_downloads // 10))
|
||
# Cap pages for non-download sorts since they aren't ordered by
|
||
# downloads and would otherwise scan endlessly.
|
||
max_pages = 50 if sort_strategy == "downloads" else 5
|
||
|
||
for pipeline in DISCOVER_PIPELINES:
|
||
if pipeline_counts[pipeline] >= pipeline_limits[pipeline]:
|
||
continue
|
||
|
||
next_url: str | None = _build_first_page_url(
|
||
pipeline, sort_strategy, PAGE_SIZE
|
||
)
|
||
pipeline_accepted = 0
|
||
hit_floor = False
|
||
page_num = 0
|
||
|
||
while (pipeline_counts[pipeline] < pipeline_limits[pipeline]
|
||
and next_url and page_num < max_pages):
|
||
page_num += 1
|
||
try:
|
||
models, next_url = _fetch_models_page(next_url)
|
||
except Exception as e:
|
||
print(f" ⚠ {pipeline} page {page_num}: {e}",
|
||
file=sys.stderr)
|
||
break
|
||
|
||
if not models:
|
||
break
|
||
|
||
below_min_this_page = 0
|
||
|
||
for m in models:
|
||
result = _process_listing(
|
||
m,
|
||
pipeline,
|
||
curated,
|
||
seen_ids,
|
||
effective_min,
|
||
sort_strategy == "downloads",
|
||
stats,
|
||
)
|
||
if result is None:
|
||
# Track download-floor hits for early stop
|
||
downloads = m.get("downloads")
|
||
repo_id = m.get("id", "")
|
||
if (repo_id and "/" in repo_id
|
||
and repo_id not in curated
|
||
and downloads is not None
|
||
and downloads < effective_min):
|
||
below_min_this_page += 1
|
||
continue
|
||
|
||
discovered.append(result)
|
||
pipeline_counts[pipeline] += 1
|
||
pipeline_accepted += 1
|
||
strategy_accepted += 1
|
||
if pipeline_counts[pipeline] >= pipeline_limits[pipeline]:
|
||
break
|
||
|
||
# For download-sorted queries, stop when most results are
|
||
# below the threshold. For trending/likes sorts, always
|
||
# exhaust pages since ordering isn't by downloads.
|
||
if sort_strategy == "downloads":
|
||
if below_min_this_page > len(models) * 0.8:
|
||
hit_floor = True
|
||
break
|
||
|
||
if len(models) < PAGE_SIZE:
|
||
break
|
||
|
||
time.sleep(0.2)
|
||
|
||
suffix = f", hit download floor" if hit_floor else ""
|
||
if pipeline_accepted > 0 or page_num > 0:
|
||
print(f" {pipeline}: +{pipeline_accepted}"
|
||
f" (pages: {page_num}{suffix})")
|
||
|
||
if _quotas_full():
|
||
break
|
||
|
||
print(f" sort={sort_strategy} (min_dl={effective_min:,}): "
|
||
f"+{strategy_accepted} new models")
|
||
|
||
if _quotas_full():
|
||
break
|
||
|
||
# Print filter statistics
|
||
print(f"\n Discovery filter stats:")
|
||
print(f" Total listings seen: {stats['total_seen']:>6}")
|
||
print(f" Skipped (curated dupe): {stats['skip_curated']:>6}")
|
||
print(f" Skipped (seen/duplicate):{stats['skip_duplicate']:>6}")
|
||
print(f" Skipped (skip org): {stats['skip_org']:>6}")
|
||
print(f" Skipped (low downloads): {stats['skip_downloads']:>6}")
|
||
print(f" Skipped (adapter/merge): {stats['skip_tags']:>6}")
|
||
print(f" Skipped (no params): {stats['skip_no_params']:>6}")
|
||
print(f" Params from safetensors: {stats['params_from_safetensors']:>6}")
|
||
print(f" Params from config est.: {stats['params_from_config']:>6}")
|
||
print(f" Accepted: {stats['accepted']:>6}")
|
||
print(f" Pipeline quotas: {pipeline_limits}")
|
||
|
||
return discovered
|
||
|
||
|
||
# Repos skipped by the discovery boundary this run. A handful of malformed
|
||
# configs is expected; more than the limit means the builder itself is broken,
|
||
# and the run must fail rather than ship a quietly thinned catalog.
|
||
DISCOVERY_SKIPPED: list[str] = []
|
||
DISCOVERY_SKIP_LIMIT = 25
|
||
|
||
|
||
def _build_discovered_model_safely(listing: dict) -> dict | None:
|
||
"""Per-model boundary for discovery: a repo whose metadata breaks the
|
||
builder is logged and skipped (its retained catalog entry stands), so it
|
||
cannot discard the thousands of models scraped around it."""
|
||
try:
|
||
return _build_discovered_model(listing)
|
||
except Exception as e: # noqa: BLE001 - any one repo may be malformed
|
||
DISCOVERY_SKIPPED.append(str(listing.get("id", "?")))
|
||
print(f" ⚠ {listing.get('id', '?')}: skipped, metadata could not be "
|
||
f"processed ({type(e).__name__}: {e})", file=sys.stderr)
|
||
return None
|
||
|
||
|
||
def _build_discovered_model(listing: dict) -> dict | None:
|
||
"""Build model dict from a listing returned by discover_trending_models.
|
||
|
||
Only fetches config.json for accurate context length; all other metadata
|
||
comes from the listing data already obtained via expand fields.
|
||
"""
|
||
repo_id = listing["id"]
|
||
total_params = listing["_total_params"]
|
||
config = listing.get("config", {})
|
||
pipeline_tag = listing.get("pipeline_tag") or listing.get("_pipeline_tag")
|
||
|
||
# Listings from non-download sort strategies (trending, likes) omit
|
||
# downloads/likes/tags, which would zero out popularity metadata and
|
||
# lose language tags. Backfill those fields with a full info fetch.
|
||
if listing.get("downloads") is None or listing.get("likes") is None:
|
||
info = fetch_model_info(repo_id)
|
||
if info:
|
||
for key in ("downloads", "likes", "createdAt", "tags"):
|
||
if listing.get(key) is None and info.get(key) is not None:
|
||
listing[key] = info[key]
|
||
|
||
full_config = fetch_config_json(repo_id)
|
||
|
||
model_format, default_quant = detect_quant_format(repo_id, full_config)
|
||
if pipeline_tag == "text-to-speech":
|
||
model_format, default_quant = ("safetensors", "F16")
|
||
context_length = CONTEXT_LENGTH_OVERRIDES.get(
|
||
repo_id,
|
||
infer_context_length(full_config) if full_config else infer_context_length(config),
|
||
)
|
||
|
||
total_params = correct_packed_param_count(repo_id, total_params, full_config)
|
||
|
||
min_ram, rec_ram = estimate_ram(total_params, default_quant)
|
||
min_vram = estimate_vram(total_params, default_quant)
|
||
|
||
architecture = config.get("model_type", "unknown")
|
||
moe_info = detect_moe(repo_id, full_config, architecture, total_params)
|
||
use_case_str = infer_use_case(repo_id, pipeline_tag, config)
|
||
|
||
# Architecture metadata for the precise KV cache formula.
|
||
arch_meta = extract_arch_metadata(full_config)
|
||
|
||
license_name = extract_license(listing)
|
||
|
||
model = {
|
||
"name": repo_id,
|
||
"provider": extract_provider(repo_id),
|
||
"parameter_count": format_param_count(total_params),
|
||
"parameters_raw": total_params,
|
||
"min_ram_gb": min_ram,
|
||
"recommended_ram_gb": rec_ram,
|
||
"min_vram_gb": min_vram,
|
||
"quantization": default_quant,
|
||
"format": model_format,
|
||
"context_length": context_length,
|
||
"use_case": use_case_str,
|
||
"capabilities": infer_capabilities(repo_id, pipeline_tag, use_case_str),
|
||
"languages": infer_languages(listing, full_config or config),
|
||
"pipeline_tag": pipeline_tag or "unknown",
|
||
"architecture": architecture,
|
||
"hf_downloads": listing.get("downloads", 0),
|
||
"hf_likes": listing.get("likes", 0),
|
||
"release_date": (listing.get("createdAt") or "")[:10] or None,
|
||
**arch_meta,
|
||
"_discovered": True,
|
||
}
|
||
|
||
if license_name:
|
||
model["license"] = license_name
|
||
|
||
if moe_info["is_moe"]:
|
||
model["is_moe"] = True
|
||
model["num_experts"] = moe_info["num_experts"]
|
||
model["active_experts"] = moe_info["active_experts"]
|
||
model["active_parameters"] = moe_info["active_parameters"]
|
||
|
||
return model
|
||
|
||
|
||
# Token formats that upstream metadata has been seen to carry (repo names,
|
||
# card text). Keep the patterns specific enough not to match real model
|
||
# names — the lookbehind stops names like "deepseek-…" tripping the sk- rule.
|
||
_SECRET_PATTERNS = re.compile(
|
||
r"hf_[A-Za-z0-9]{28,}" # Hugging Face access token
|
||
r"|ghp_[A-Za-z0-9]{30,}" # GitHub PAT (classic)
|
||
r"|gho_[A-Za-z0-9]{30,}" # GitHub OAuth token
|
||
r"|github_pat_[A-Za-z0-9_]{22,}" # GitHub fine-grained PAT
|
||
r"|(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{32,}" # common API-key prefix
|
||
)
|
||
|
||
|
||
def drop_secret_bearing_models(models: list[dict]) -> tuple[list[dict], list[str]]:
|
||
"""Split out models whose serialized entry contains a token-like string.
|
||
|
||
Returns (kept, dropped_redacted_names); dropped names have the matched
|
||
secret replaced so callers can log them without re-leaking it.
|
||
"""
|
||
kept: list[dict] = []
|
||
dropped: list[str] = []
|
||
for model in models:
|
||
if _SECRET_PATTERNS.search(json.dumps(model)):
|
||
dropped.append(
|
||
_SECRET_PATTERNS.sub("<redacted-secret>", model.get("name", "?"))
|
||
)
|
||
else:
|
||
kept.append(model)
|
||
return kept, dropped
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Scrape LLM model metadata from HuggingFace for llmfit."
|
||
)
|
||
parser.add_argument(
|
||
"--discover", action="store_true", default=True,
|
||
help="Auto-discover top models by download count from HuggingFace "
|
||
"in addition to the curated TARGET_MODELS list (default: enabled)."
|
||
)
|
||
parser.add_argument(
|
||
"--no-discover", action="store_false", dest="discover",
|
||
help="Disable auto-discovery, only scrape curated TARGET_MODELS list."
|
||
)
|
||
parser.add_argument(
|
||
"-n", "--discover-limit", type=int, default=1000,
|
||
help="Max number of top-downloaded models to discover (default: 1000). "
|
||
"Duplicates of curated models are skipped automatically."
|
||
)
|
||
parser.add_argument(
|
||
"--gguf-probe-budget", type=int, default=GGUF_PROBE_BUDGET,
|
||
help="Max uncached models to probe for GGUF sources per run, those "
|
||
"without a known source and the most downloaded first "
|
||
f"(default: {GGUF_PROBE_BUDGET}). The rest are deferred to later runs."
|
||
)
|
||
parser.add_argument(
|
||
"--revalidate", type=int, default=RETAINED_REVALIDATION_BUDGET,
|
||
help="Max retained (not re-discovered) entries to re-fetch per run, "
|
||
"most suspect first: pre-quantized repos with no architecture "
|
||
"metadata, implausible context windows, missing release dates "
|
||
f"(default: {RETAINED_REVALIDATION_BUDGET}, 0 to disable)."
|
||
)
|
||
parser.add_argument(
|
||
"--min-downloads", type=int, default=10000,
|
||
help="Minimum download count for discovered models (default: 10000)."
|
||
)
|
||
parser.add_argument(
|
||
"--gguf-sources", action="store_true", default=True,
|
||
help="Enrich models with known GGUF download sources from "
|
||
"providers like unsloth and bartowski on HuggingFace (default: enabled)."
|
||
)
|
||
parser.add_argument(
|
||
"--no-gguf-sources", action="store_false", dest="gguf_sources",
|
||
help="Skip GGUF download source enrichment (faster scrape)."
|
||
)
|
||
parser.add_argument(
|
||
"--token", type=str, default=None,
|
||
help="HuggingFace API token for accessing gated models. "
|
||
"Can also be set via HF_TOKEN or HUGGING_FACE_HUB_TOKEN env var."
|
||
)
|
||
parser.add_argument(
|
||
"--threads", type=int, default=1,
|
||
help="Number of worker threads for parallel model metadata scraping "
|
||
"(default: 1, which preserves current sequential behavior)."
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if args.threads < 1:
|
||
parser.error("--threads must be >= 1")
|
||
|
||
# Resolve auth token: CLI flag > HF_TOKEN > HUGGING_FACE_HUB_TOKEN
|
||
global _hf_token
|
||
_hf_token = (
|
||
args.token
|
||
or os.environ.get("HF_TOKEN")
|
||
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
||
)
|
||
if _hf_token:
|
||
print(f"🔑 Authenticated with HuggingFace token ({_hf_token[:4]}...{_hf_token[-4:]})")
|
||
else:
|
||
print("ℹ No HF token set. Gated models will use fallback data.")
|
||
print(" Set HF_TOKEN env var or pass --token to access gated models.\n")
|
||
|
||
# Fallback entries for gated/auth-required models where the API
|
||
# doesn't return safetensors metadata without a token.
|
||
FALLBACKS = [
|
||
{
|
||
"name": "meta-llama/Llama-3.3-70B-Instruct",
|
||
"provider": "Meta", "parameter_count": "70.6B",
|
||
"parameters_raw": 70_553_706_496,
|
||
"min_ram_gb": 39.4, "recommended_ram_gb": 65.7, "min_vram_gb": 36.1,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "mistralai/Mistral-Small-24B-Instruct-2501",
|
||
"provider": "Mistral AI", "parameter_count": "24B",
|
||
"parameters_raw": 24_000_000_000,
|
||
"min_ram_gb": 13.4, "recommended_ram_gb": 22.4, "min_vram_gb": 12.3,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "mistral",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-14B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "14.8B",
|
||
"parameters_raw": 14_770_000_000,
|
||
"min_ram_gb": 8.2, "recommended_ram_gb": 13.7, "min_vram_gb": 7.6,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-32B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "32.5B",
|
||
"parameters_raw": 32_510_000_000,
|
||
"min_ram_gb": 18.2, "recommended_ram_gb": 30.3, "min_vram_gb": 16.7,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/phi-3-mini-4k-instruct",
|
||
"provider": "Microsoft", "parameter_count": "3.8B",
|
||
"parameters_raw": 3_821_000_000,
|
||
"min_ram_gb": 2.1, "recommended_ram_gb": 3.6, "min_vram_gb": 2.0,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Lightweight, edge deployment",
|
||
"pipeline_tag": "text-generation", "architecture": "phi3",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/phi-4",
|
||
"provider": "Microsoft", "parameter_count": "14B",
|
||
"parameters_raw": 14_000_000_000,
|
||
"min_ram_gb": 7.8, "recommended_ram_gb": 13.0, "min_vram_gb": 7.2,
|
||
"quantization": "Q4_K_M", "context_length": 16384,
|
||
"use_case": "Reasoning, STEM, code generation",
|
||
"pipeline_tag": "text-generation", "architecture": "phi",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "google/gemma-3-12b-it",
|
||
"provider": "Google", "parameter_count": "12B",
|
||
"parameters_raw": 12_000_000_000,
|
||
"min_ram_gb": 6.7, "recommended_ram_gb": 11.2, "min_vram_gb": 6.1,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, vision and text",
|
||
"capabilities": ["vision", "tool_use"],
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma3",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "deepseek-ai/DeepSeek-V3",
|
||
"provider": "DeepSeek", "parameter_count": "685B",
|
||
"parameters_raw": 685_000_000_000,
|
||
"min_ram_gb": 382.8, "recommended_ram_gb": 638.0, "min_vram_gb": 351.3,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "State-of-the-art, MoE architecture",
|
||
"pipeline_tag": "text-generation", "architecture": "deepseek_v3",
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 37_000_000_000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "CohereForAI/c4ai-command-r-v01",
|
||
"provider": "Cohere", "parameter_count": "35B",
|
||
"parameters_raw": 35_000_000_000,
|
||
"min_ram_gb": 19.5, "recommended_ram_gb": 32.6, "min_vram_gb": 17.9,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "RAG, tool use, agents",
|
||
"pipeline_tag": "text-generation", "architecture": "cohere",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "bigcode/starcoder2-15b",
|
||
"provider": "BigCode", "parameter_count": "15.7B",
|
||
"parameters_raw": 15_700_000_000,
|
||
"min_ram_gb": 8.8, "recommended_ram_gb": 14.6, "min_vram_gb": 8.0,
|
||
"quantization": "Q4_K_M", "context_length": 16384,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "starcoder2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "nomic-ai/nomic-embed-text-v1.5",
|
||
"provider": "Nomic", "parameter_count": "137M",
|
||
"parameters_raw": 137_000_000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "F16", "context_length": 8192,
|
||
"use_case": "Text embeddings for RAG",
|
||
"pipeline_tag": "feature-extraction", "architecture": "nomic_bert",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct",
|
||
"provider": "DeepSeek", "parameter_count": "16B",
|
||
"parameters_raw": 15_700_000_000,
|
||
"min_ram_gb": 8.8, "recommended_ram_gb": 14.6, "min_vram_gb": 8.0,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "deepseek_v2",
|
||
"is_moe": True, "num_experts": 64, "active_experts": 6,
|
||
"active_parameters": 2_400_000_000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/Phi-3-medium-14b-instruct",
|
||
"provider": "Microsoft", "parameter_count": "14B",
|
||
"parameters_raw": 14_000_000_000,
|
||
"min_ram_gb": 7.8, "recommended_ram_gb": 13.0, "min_vram_gb": 7.2,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Balanced performance and size",
|
||
"pipeline_tag": "text-generation", "architecture": "phi3",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
# NEW FALLBACKS for popular models
|
||
{
|
||
"name": "google/gemma-2-2b-it",
|
||
"provider": "Google", "parameter_count": "2.6B",
|
||
"parameters_raw": 2614341376,
|
||
"min_ram_gb": 1.5, "recommended_ram_gb": 2.4, "min_vram_gb": 1.3,
|
||
"quantization": "Q4_K_M", "context_length": 8192,
|
||
"use_case": "Lightweight, edge deployment",
|
||
"pipeline_tag": "text-generation", "architecture": "gemma2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "meta-llama/CodeLlama-7b-Instruct-hf",
|
||
"provider": "Meta", "parameter_count": "7.0B",
|
||
"parameters_raw": 7016400896,
|
||
"min_ram_gb": 3.9, "recommended_ram_gb": 6.5, "min_vram_gb": 3.6,
|
||
"quantization": "Q4_K_M", "context_length": 16384,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "meta-llama/CodeLlama-13b-Instruct-hf",
|
||
"provider": "Meta", "parameter_count": "13.0B",
|
||
"parameters_raw": 13015864320,
|
||
"min_ram_gb": 7.3, "recommended_ram_gb": 12.1, "min_vram_gb": 6.7,
|
||
"quantization": "Q4_K_M", "context_length": 16384,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "meta-llama/CodeLlama-34b-Instruct-hf",
|
||
"provider": "Meta", "parameter_count": "34.0B",
|
||
"parameters_raw": 34018971648,
|
||
"min_ram_gb": 19.0, "recommended_ram_gb": 31.7, "min_vram_gb": 17.4,
|
||
"quantization": "Q4_K_M", "context_length": 16384,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "meta-llama/Llama-3.2-11B-Vision-Instruct",
|
||
"provider": "Meta", "parameter_count": "11.0B",
|
||
"parameters_raw": 10665463808,
|
||
"min_ram_gb": 6.0, "recommended_ram_gb": 9.9, "min_vram_gb": 5.5,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "mistralai/Ministral-8B-Instruct-2410",
|
||
"provider": "Mistral AI", "parameter_count": "8.0B",
|
||
"parameters_raw": 8030261248,
|
||
"min_ram_gb": 4.5, "recommended_ram_gb": 7.5, "min_vram_gb": 4.1,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "mistral",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "mistralai/Mistral-Nemo-Instruct-2407",
|
||
"provider": "Mistral AI", "parameter_count": "12.2B",
|
||
"parameters_raw": 12247076864,
|
||
"min_ram_gb": 6.8, "recommended_ram_gb": 11.4, "min_vram_gb": 6.3,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "mistral",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/Phi-3.5-mini-instruct",
|
||
"provider": "Microsoft", "parameter_count": "3.8B",
|
||
"parameters_raw": 3821000000,
|
||
"min_ram_gb": 2.1, "recommended_ram_gb": 3.6, "min_vram_gb": 2.0,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Lightweight, long context",
|
||
"pipeline_tag": "text-generation", "architecture": "phi3",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/Orca-2-7b",
|
||
"provider": "Microsoft", "parameter_count": "7.0B",
|
||
"parameters_raw": 7016400896,
|
||
"min_ram_gb": 3.9, "recommended_ram_gb": 6.5, "min_vram_gb": 3.6,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Reasoning, step-by-step solutions",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/Orca-2-13b",
|
||
"provider": "Microsoft", "parameter_count": "13.0B",
|
||
"parameters_raw": 13015864320,
|
||
"min_ram_gb": 7.3, "recommended_ram_gb": 12.1, "min_vram_gb": 6.7,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Reasoning, step-by-step solutions",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "01-ai/Yi-6B-Chat",
|
||
"provider": "01.ai", "parameter_count": "6.1B",
|
||
"parameters_raw": 6061356032,
|
||
"min_ram_gb": 3.4, "recommended_ram_gb": 5.6, "min_vram_gb": 3.1,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Multilingual, Chinese/English chat",
|
||
"pipeline_tag": "text-generation", "architecture": "yi",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "01-ai/Yi-34B-Chat",
|
||
"provider": "01.ai", "parameter_count": "34.4B",
|
||
"parameters_raw": 34386780160,
|
||
"min_ram_gb": 19.2, "recommended_ram_gb": 32.0, "min_vram_gb": 17.6,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Multilingual, Chinese/English chat",
|
||
"pipeline_tag": "text-generation", "architecture": "yi",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "upstage/SOLAR-10.7B-Instruct-v1.0",
|
||
"provider": "Upstage", "parameter_count": "10.7B",
|
||
"parameters_raw": 10700000000,
|
||
"min_ram_gb": 6.0, "recommended_ram_gb": 10.0, "min_vram_gb": 5.5,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "High-performance instruction following",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "tiiuae/falcon-7b-instruct",
|
||
"provider": "TII", "parameter_count": "7.0B",
|
||
"parameters_raw": 7000000000,
|
||
"min_ram_gb": 3.9, "recommended_ram_gb": 6.5, "min_vram_gb": 3.6,
|
||
"quantization": "Q4_K_M", "context_length": 2048,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "falcon",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "tiiuae/falcon-40b-instruct",
|
||
"provider": "TII", "parameter_count": "40.0B",
|
||
"parameters_raw": 40000000000,
|
||
"min_ram_gb": 22.4, "recommended_ram_gb": 37.3, "min_vram_gb": 20.5,
|
||
"quantization": "Q4_K_M", "context_length": 2048,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "falcon",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "HuggingFaceH4/zephyr-7b-beta",
|
||
"provider": "HuggingFace", "parameter_count": "7.2B",
|
||
"parameters_raw": 7241732096,
|
||
"min_ram_gb": 4.0, "recommended_ram_gb": 6.7, "min_vram_gb": 3.7,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "mistral",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "openchat/openchat-3.5-0106",
|
||
"provider": "OpenChat", "parameter_count": "7.0B",
|
||
"parameters_raw": 7000000000,
|
||
"min_ram_gb": 3.9, "recommended_ram_gb": 6.5, "min_vram_gb": 3.6,
|
||
"quantization": "Q4_K_M", "context_length": 8192,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "mistral",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "lmsys/vicuna-7b-v1.5",
|
||
"provider": "LMSYS", "parameter_count": "7.0B",
|
||
"parameters_raw": 6738415616,
|
||
"min_ram_gb": 3.8, "recommended_ram_gb": 6.3, "min_vram_gb": 3.4,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "lmsys/vicuna-13b-v1.5",
|
||
"provider": "LMSYS", "parameter_count": "13.0B",
|
||
"parameters_raw": 13015864320,
|
||
"min_ram_gb": 7.3, "recommended_ram_gb": 12.1, "min_vram_gb": 6.7,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO",
|
||
"provider": "NousResearch", "parameter_count": "46.7B",
|
||
"parameters_raw": 46702792704,
|
||
"min_ram_gb": 26.1, "recommended_ram_gb": 43.5, "min_vram_gb": 23.9,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "mixtral",
|
||
"is_moe": True, "num_experts": 8, "active_experts": 2,
|
||
"active_parameters": 12900000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "WizardLMTeam/WizardLM-13B-V1.2",
|
||
"provider": "WizardLM", "parameter_count": "13.0B",
|
||
"parameters_raw": 13015864320,
|
||
"min_ram_gb": 7.3, "recommended_ram_gb": 12.1, "min_vram_gb": 6.7,
|
||
"quantization": "Q4_K_M", "context_length": 4096,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "llama",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "WizardLMTeam/WizardCoder-15B-V1.0",
|
||
"provider": "WizardLM", "parameter_count": "15.5B",
|
||
"parameters_raw": 15515334656,
|
||
"min_ram_gb": 8.7, "recommended_ram_gb": 14.5, "min_vram_gb": 7.9,
|
||
"quantization": "Q4_K_M", "context_length": 8192,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "starcoder",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-Coder-1.5B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "1.5B",
|
||
"parameters_raw": 1539938304,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.8,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-Coder-7B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "7.6B",
|
||
"parameters_raw": 7615616000,
|
||
"min_ram_gb": 4.3, "recommended_ram_gb": 7.1, "min_vram_gb": 3.9,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-Coder-14B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "14.7B",
|
||
"parameters_raw": 14770000000,
|
||
"min_ram_gb": 8.2, "recommended_ram_gb": 13.7, "min_vram_gb": 7.6,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-Coder-32B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "32.5B",
|
||
"parameters_raw": 32510000000,
|
||
"min_ram_gb": 18.2, "recommended_ram_gb": 30.3, "min_vram_gb": 16.7,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Code generation and completion",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-VL-3B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "3.8B",
|
||
"parameters_raw": 3821000000,
|
||
"min_ram_gb": 2.1, "recommended_ram_gb": 3.6, "min_vram_gb": 2.0,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "qwen2_vl",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen2.5-VL-7B-Instruct",
|
||
"provider": "Alibaba", "parameter_count": "8.3B",
|
||
"parameters_raw": 8290000000,
|
||
"min_ram_gb": 4.6, "recommended_ram_gb": 7.7, "min_vram_gb": 4.2,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "qwen2_vl",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen3-14B",
|
||
"provider": "Alibaba", "parameter_count": "14.8B",
|
||
"parameters_raw": 14770000000,
|
||
"min_ram_gb": 8.2, "recommended_ram_gb": 13.7, "min_vram_gb": 7.6,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "General purpose text generation",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen3",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
# --- New fallbacks added Feb 2026 ---
|
||
{
|
||
"name": "deepseek-ai/DeepSeek-V3.2",
|
||
"provider": "DeepSeek", "parameter_count": "685B",
|
||
"parameters_raw": 685000000000,
|
||
"min_ram_gb": 383.2, "recommended_ram_gb": 638.7, "min_vram_gb": 351.3,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "State-of-the-art, MoE architecture",
|
||
"pipeline_tag": "text-generation", "architecture": "deepseek_v3",
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 37000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-12-01",
|
||
},
|
||
{
|
||
"name": "deepseek-ai/DeepSeek-V3.2-Speciale",
|
||
"provider": "DeepSeek", "parameter_count": "685B",
|
||
"parameters_raw": 685000000000,
|
||
"min_ram_gb": 383.2, "recommended_ram_gb": 638.7, "min_vram_gb": 351.3,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Advanced reasoning, chain-of-thought",
|
||
"pipeline_tag": "text-generation", "architecture": "deepseek_v3",
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 37000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-12-01",
|
||
},
|
||
{
|
||
"name": "zai-org/GLM-5",
|
||
"provider": "Zhipu AI", "parameter_count": "744B",
|
||
"parameters_raw": 744000000000,
|
||
"min_ram_gb": 416.2, "recommended_ram_gb": 693.6, "min_vram_gb": 381.4,
|
||
"quantization": "Q4_K_M", "context_length": 200000,
|
||
"use_case": "State-of-the-art, MoE architecture",
|
||
"pipeline_tag": "text-generation", "architecture": "glm",
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 40000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2026-02-11",
|
||
},
|
||
{
|
||
"name": "moonshotai/Kimi-K2.5",
|
||
"provider": "Moonshot", "parameter_count": "171B",
|
||
"parameters_raw": 171000000000,
|
||
"min_ram_gb": 95.6, "recommended_ram_gb": 159.4, "min_vram_gb": 87.7,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "kimi",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2026-01-26",
|
||
},
|
||
{
|
||
"name": "MiniMaxAI/MiniMax-M3",
|
||
"provider": "MiniMax", "parameter_count": "230B",
|
||
"parameters_raw": 230000000000,
|
||
"min_ram_gb": 128.6, "recommended_ram_gb": 214.4, "min_vram_gb": 117.9,
|
||
"quantization": "Q4_K_M", "context_length": 1000000,
|
||
"use_case": "Latest flagship: multimodal (text + image input), 1M context, 128K max output",
|
||
"pipeline_tag": "text-generation", "architecture": "minimax",
|
||
"is_moe": True, "num_experts": 32, "active_experts": 2,
|
||
"active_parameters": 10000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2026-06-03",
|
||
},
|
||
{
|
||
"name": "MiniMaxAI/MiniMax-M2.7",
|
||
"provider": "MiniMax", "parameter_count": "230B",
|
||
"parameters_raw": 230000000000,
|
||
"min_ram_gb": 128.6, "recommended_ram_gb": 214.4, "min_vram_gb": 117.9,
|
||
"quantization": "Q4_K_M", "context_length": 204800,
|
||
"use_case": "Previous flagship with enhanced reasoning and coding",
|
||
"pipeline_tag": "text-generation", "architecture": "minimax",
|
||
"is_moe": True, "num_experts": 32, "active_experts": 2,
|
||
"active_parameters": 10000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2026-03-18",
|
||
},
|
||
{
|
||
"name": "XiaomiMiMo/MiMo-V2-Flash",
|
||
"provider": "Xiaomi", "parameter_count": "309B",
|
||
"parameters_raw": 309000000000,
|
||
"min_ram_gb": 172.8, "recommended_ram_gb": 288.0, "min_vram_gb": 158.4,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Efficient reasoning, coding",
|
||
"pipeline_tag": "text-generation", "architecture": "mimo",
|
||
"is_moe": True, "num_experts": 128, "active_experts": 8,
|
||
"active_parameters": 15000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-12-01",
|
||
},
|
||
{
|
||
"name": "XiaomiMiMo/MiMo-7B-RL",
|
||
"provider": "Xiaomi", "parameter_count": "7.0B",
|
||
"parameters_raw": 7000000000,
|
||
"min_ram_gb": 3.9, "recommended_ram_gb": 6.5, "min_vram_gb": 3.6,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Advanced reasoning, math and code",
|
||
"pipeline_tag": "text-generation", "architecture": "mimo",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-05-01",
|
||
},
|
||
{
|
||
"name": "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
|
||
"provider": "NVIDIA", "parameter_count": "30B",
|
||
"parameters_raw": 30000000000,
|
||
"min_ram_gb": 16.8, "recommended_ram_gb": 28.0, "min_vram_gb": 15.4,
|
||
"quantization": "Q4_K_M", "context_length": 1048576,
|
||
"use_case": "Efficient MoE, agentic tasks",
|
||
"pipeline_tag": "text-generation", "architecture": "nemotron",
|
||
"is_moe": True, "num_experts": 128, "active_experts": 6,
|
||
"active_parameters": 3000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-06-01",
|
||
},
|
||
{
|
||
"name": "nvidia/NVIDIA-Nemotron-Nano-9B-v2",
|
||
"provider": "NVIDIA", "parameter_count": "9B",
|
||
"parameters_raw": 9000000000,
|
||
"min_ram_gb": 5.0, "recommended_ram_gb": 8.4, "min_vram_gb": 4.6,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Hybrid Mamba2, reasoning",
|
||
"pipeline_tag": "text-generation", "architecture": "nemotron",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-06-01",
|
||
},
|
||
{
|
||
"name": "microsoft/Phi-4-reasoning",
|
||
"provider": "Microsoft", "parameter_count": "14B",
|
||
"parameters_raw": 14000000000,
|
||
"min_ram_gb": 7.8, "recommended_ram_gb": 13.0, "min_vram_gb": 7.2,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Advanced reasoning, math and code",
|
||
"pipeline_tag": "text-generation", "architecture": "phi4",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-04-01",
|
||
},
|
||
{
|
||
"name": "microsoft/Phi-4-mini-reasoning",
|
||
"provider": "Microsoft", "parameter_count": "3.8B",
|
||
"parameters_raw": 3800000000,
|
||
"min_ram_gb": 2.1, "recommended_ram_gb": 3.5, "min_vram_gb": 1.9,
|
||
"quantization": "Q4_K_M", "context_length": 16384,
|
||
"use_case": "Lightweight reasoning",
|
||
"pipeline_tag": "text-generation", "architecture": "phi4",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-04-01",
|
||
},
|
||
{
|
||
"name": "microsoft/Phi-4-multimodal-instruct",
|
||
"provider": "Microsoft", "parameter_count": "14B",
|
||
"parameters_raw": 14000000000,
|
||
"min_ram_gb": 7.8, "recommended_ram_gb": 13.0, "min_vram_gb": 7.2,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, vision and audio",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "phi4",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-04-01",
|
||
},
|
||
{
|
||
"name": "LGAI-EXAONE/EXAONE-4.0-32B",
|
||
"provider": "LG AI", "parameter_count": "32B",
|
||
"parameters_raw": 32000000000,
|
||
"min_ram_gb": 17.9, "recommended_ram_gb": 29.8, "min_vram_gb": 16.4,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Hybrid reasoning, multilingual",
|
||
"pipeline_tag": "text-generation", "architecture": "exaone",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-15",
|
||
},
|
||
{
|
||
"name": "LGAI-EXAONE/EXAONE-4.0-1.2B",
|
||
"provider": "LG AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1200000000,
|
||
"min_ram_gb": 0.7, "recommended_ram_gb": 1.1, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Lightweight, on-device",
|
||
"pipeline_tag": "text-generation", "architecture": "exaone",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-15",
|
||
},
|
||
{
|
||
"name": "HuggingFaceTB/SmolLM3-3B",
|
||
"provider": "HuggingFace", "parameter_count": "3B",
|
||
"parameters_raw": 3000000000,
|
||
"min_ram_gb": 1.7, "recommended_ram_gb": 2.8, "min_vram_gb": 1.5,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Lightweight, multilingual reasoning",
|
||
"pipeline_tag": "text-generation", "architecture": "smollm",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-08",
|
||
},
|
||
{
|
||
"name": "google/gemma-3n-E4B-it",
|
||
"provider": "Google", "parameter_count": "8B",
|
||
"parameters_raw": 8000000000,
|
||
"min_ram_gb": 4.5, "recommended_ram_gb": 7.5, "min_vram_gb": 4.1,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, on-device (effective 4B)",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma3n",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-06-25",
|
||
},
|
||
{
|
||
"name": "google/gemma-3n-E2B-it",
|
||
"provider": "Google", "parameter_count": "4B",
|
||
"parameters_raw": 4000000000,
|
||
"min_ram_gb": 2.2, "recommended_ram_gb": 3.7, "min_vram_gb": 2.1,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, on-device (effective 2B)",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma3n",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-06-25",
|
||
},
|
||
# Google Gemma 4 family
|
||
{
|
||
"name": "google/gemma-4-E2B-it",
|
||
"provider": "Google", "parameter_count": "5.1B",
|
||
"parameters_raw": 5100000000,
|
||
"min_ram_gb": 2.9, "recommended_ram_gb": 4.8, "min_vram_gb": 2.6,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, on-device (effective 2B)",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma4",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-30",
|
||
},
|
||
{
|
||
"name": "google/gemma-4-E4B-it",
|
||
"provider": "Google", "parameter_count": "8B",
|
||
"parameters_raw": 8000000000,
|
||
"min_ram_gb": 4.5, "recommended_ram_gb": 7.5, "min_vram_gb": 4.1,
|
||
"quantization": "Q4_K_M", "context_length": 131072,
|
||
"use_case": "Multimodal, on-device (effective 4B)",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma4",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-30",
|
||
},
|
||
{
|
||
"name": "google/gemma-4-31B-it",
|
||
"provider": "Google", "parameter_count": "31B",
|
||
"parameters_raw": 31000000000,
|
||
"min_ram_gb": 17.3, "recommended_ram_gb": 28.9, "min_vram_gb": 15.9,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma4",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-30",
|
||
},
|
||
{
|
||
"name": "google/gemma-4-26B-A4B-it",
|
||
"provider": "Google", "parameter_count": "26B",
|
||
"parameters_raw": 26000000000,
|
||
"min_ram_gb": 14.5, "recommended_ram_gb": 24.2, "min_vram_gb": 13.3,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "gemma4",
|
||
"is_moe": True, "num_experts": 128, "active_experts": 8,
|
||
"active_parameters": 4_000_000_000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-07-30",
|
||
},
|
||
# Qwen3-Coder-Next (80B MoE, 3B active, Jan 2026)
|
||
{
|
||
"name": "Qwen/Qwen3-Coder-Next",
|
||
"provider": "Alibaba", "parameter_count": "80B",
|
||
"parameters_raw": 80000000000,
|
||
"min_ram_gb": 44.8, "recommended_ram_gb": 74.6, "min_vram_gb": 41.0,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Code generation, agentic coding",
|
||
"pipeline_tag": "text-generation", "architecture": "qwen3_next",
|
||
"is_moe": True, "num_experts": 64, "active_experts": 4,
|
||
"active_parameters": 3000000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2026-01-30",
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen3.5-27B",
|
||
"provider": "Alibaba", "parameter_count": "27.8B",
|
||
"parameters_raw": 27781427952,
|
||
"min_ram_gb": 15.5, "recommended_ram_gb": 25.9, "min_vram_gb": 14.2,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "qwen3_5",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen3.5-35B-A3B",
|
||
"provider": "Alibaba", "parameter_count": "36.0B",
|
||
"parameters_raw": 35951822704,
|
||
"min_ram_gb": 20.1, "recommended_ram_gb": 33.5, "min_vram_gb": 18.4,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "qwen3_5_moe",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 3_000_000_000,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen3.5-122B-A10B",
|
||
"provider": "Alibaba", "parameter_count": "125.1B",
|
||
"parameters_raw": 125086497008,
|
||
"min_ram_gb": 69.9, "recommended_ram_gb": 116.5, "min_vram_gb": 64.1,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "qwen3_5_moe",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 10_000_000_000,
|
||
},
|
||
{
|
||
"name": "Qwen/Qwen3.5-397B-A17B",
|
||
"provider": "Alibaba", "parameter_count": "403.4B",
|
||
"parameters_raw": 403397928944,
|
||
"min_ram_gb": 225.4, "recommended_ram_gb": 375.7, "min_vram_gb": 206.6,
|
||
"quantization": "Q4_K_M", "context_length": 262144,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "qwen3_5_moe",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
"is_moe": True, "num_experts": 256, "active_experts": 8,
|
||
"active_parameters": 17_000_000_000,
|
||
},
|
||
# Liquid AI LFM2 dense models
|
||
{
|
||
"name": "LiquidAI/LFM2-350M",
|
||
"provider": "Liquid AI", "parameter_count": "354M",
|
||
"parameters_raw": 354483968,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Lightweight, edge deployment",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-700M",
|
||
"provider": "Liquid AI", "parameter_count": "742M",
|
||
"parameters_raw": 742489344,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Lightweight, edge deployment",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-1.2B",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "General purpose text generation",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-2.6B",
|
||
"provider": "Liquid AI", "parameter_count": "2.6B",
|
||
"parameters_raw": 2569272320,
|
||
"min_ram_gb": 1.4, "recommended_ram_gb": 2.4, "min_vram_gb": 1.3,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "General purpose text generation",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-2.6B-Exp",
|
||
"provider": "Liquid AI", "parameter_count": "2.6B",
|
||
"parameters_raw": 2569272320,
|
||
"min_ram_gb": 1.4, "recommended_ram_gb": 2.4, "min_vram_gb": 1.3,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Instruction following, math, knowledge",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
# Liquid AI LFM2 MoE models
|
||
{
|
||
"name": "LiquidAI/LFM2-8B-A1B",
|
||
"provider": "Liquid AI", "parameter_count": "8.3B",
|
||
"parameters_raw": 8300000000,
|
||
"min_ram_gb": 4.6, "recommended_ram_gb": 7.7, "min_vram_gb": 4.3,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "General purpose, edge MoE",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"is_moe": True, "num_experts": 32, "active_experts": 4,
|
||
"active_parameters": 1500000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-24B-A2B",
|
||
"provider": "Liquid AI", "parameter_count": "23.8B",
|
||
"parameters_raw": 23_843_661_440,
|
||
"min_ram_gb": 13.3, "recommended_ram_gb": 22.2, "min_vram_gb": 12.2,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Agentic tasks, RAG, summarization",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"is_moe": True, "num_experts": 32, "active_experts": 4,
|
||
"active_parameters": 2300000000,
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
# Liquid AI LFM2.5 models
|
||
{
|
||
"name": "LiquidAI/LFM2.5-1.2B-Base",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "General purpose text generation",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2.5-1.2B-Instruct",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Instruction following, chat",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2.5-1.2B-Thinking",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Advanced reasoning, chain-of-thought",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2.5-1.2B-JP",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Japanese language, multilingual chat",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
# Liquid AI LFM2 Vision-Language models
|
||
{
|
||
"name": "LiquidAI/LFM2-VL-450M",
|
||
"provider": "Liquid AI", "parameter_count": "451M",
|
||
"parameters_raw": 450822656,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-VL-1.6B",
|
||
"provider": "Liquid AI", "parameter_count": "1.6B",
|
||
"parameters_raw": 1584804000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.8,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-VL-3B",
|
||
"provider": "Liquid AI", "parameter_count": "3.0B",
|
||
"parameters_raw": 2998975216,
|
||
"min_ram_gb": 1.7, "recommended_ram_gb": 2.8, "min_vram_gb": 1.5,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2.5-VL-1.6B",
|
||
"provider": "Liquid AI", "parameter_count": "1.6B",
|
||
"parameters_raw": 1596625904,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.8,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Multimodal, vision and text",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
# Liquid AI LFM2 Audio models
|
||
{
|
||
"name": "LiquidAI/LFM2-Audio-1.5B",
|
||
"provider": "Liquid AI", "parameter_count": "1.5B",
|
||
"parameters_raw": 1500000000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.8,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Speech-to-speech, ASR, TTS",
|
||
"pipeline_tag": "audio-to-audio", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2.5-Audio-1.5B",
|
||
"provider": "Liquid AI", "parameter_count": "1.5B",
|
||
"parameters_raw": 1500000000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.8,
|
||
"quantization": "Q4_K_M", "context_length": 32768,
|
||
"use_case": "Speech-to-speech, ASR, TTS",
|
||
"pipeline_tag": "audio-to-audio", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
# Liquid AI Liquid Nanos (task-specific fine-tunes)
|
||
{
|
||
"name": "LiquidAI/LFM2-1.2B-Tool",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Tool calling, function calling",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-1.2B-RAG",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Retrieval-augmented generation",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-1.2B-Extract",
|
||
"provider": "Liquid AI", "parameter_count": "1.2B",
|
||
"parameters_raw": 1170340608,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.6,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Data extraction, structured output",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-350M-Extract",
|
||
"provider": "Liquid AI", "parameter_count": "354M",
|
||
"parameters_raw": 354483968,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Data extraction, structured output",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-350M-Math",
|
||
"provider": "Liquid AI", "parameter_count": "354M",
|
||
"parameters_raw": 354483968,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Math reasoning, chain-of-thought",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-350M-ENJP-MT",
|
||
"provider": "Liquid AI", "parameter_count": "354M",
|
||
"parameters_raw": 354483968,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "English-Japanese translation",
|
||
"pipeline_tag": "translation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-350M-PII-Extract-JP",
|
||
"provider": "Liquid AI", "parameter_count": "354M",
|
||
"parameters_raw": 354483968,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "PII extraction, Japanese",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-ColBERT-350M",
|
||
"provider": "Liquid AI", "parameter_count": "353M",
|
||
"parameters_raw": 353322752,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Semantic search, sentence similarity",
|
||
"pipeline_tag": "sentence-similarity", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "LiquidAI/LFM2-2.6B-Transcript",
|
||
"provider": "Liquid AI", "parameter_count": "2.6B",
|
||
"parameters_raw": 2569272320,
|
||
"min_ram_gb": 1.4, "recommended_ram_gb": 2.4, "min_vram_gb": 1.3,
|
||
"quantization": "Q4_K_M", "context_length": 128000,
|
||
"use_case": "Meeting transcription, summarization",
|
||
"pipeline_tag": "text-generation", "architecture": "lfm2",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "hexgrad/Kokoro-82M",
|
||
"provider": "hexgrad", "parameter_count": "82M",
|
||
"parameters_raw": 82_000_000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "F16", "format": "safetensors", "context_length": 4096,
|
||
"use_case": "Text-to-speech",
|
||
"capabilities": ["audio", "tts"], "languages": [],
|
||
"pipeline_tag": "text-to-speech", "architecture": "unknown",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "microsoft/speecht5_tts",
|
||
"provider": "Microsoft", "parameter_count": "144M",
|
||
"parameters_raw": 144_000_000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "F16", "format": "safetensors", "context_length": 4096,
|
||
"use_case": "Text-to-speech",
|
||
"capabilities": ["audio", "tts"], "languages": [],
|
||
"pipeline_tag": "text-to-speech", "architecture": "speecht5",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "facebook/mms-tts-eng",
|
||
"provider": "Meta", "parameter_count": "36M",
|
||
"parameters_raw": 36_000_000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.5,
|
||
"quantization": "F16", "format": "safetensors", "context_length": 4096,
|
||
"use_case": "Text-to-speech",
|
||
"capabilities": ["audio", "tts"], "languages": [],
|
||
"pipeline_tag": "text-to-speech", "architecture": "vits",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
# Mistral consolidated-format repos: params.json only, no config.json
|
||
# or safetensors metadata, so the scrape finds no parameter count.
|
||
{
|
||
"name": "mistralai/Mistral-Large-3-675B-Instruct-2512",
|
||
"provider": "Mistral AI", "parameter_count": "675B",
|
||
"parameters_raw": 675_000_000_000,
|
||
"min_ram_gb": 377.2, "recommended_ram_gb": 628.6, "min_vram_gb": 345.8,
|
||
"quantization": "Q4_K_M", "context_length": 294912,
|
||
"use_case": "Flagship multimodal granular MoE",
|
||
"pipeline_tag": "image-text-to-text", "architecture": "mistral3",
|
||
"is_moe": True, "num_experts": 128, "active_experts": 4,
|
||
"active_parameters": 41_000_000_000,
|
||
"num_hidden_layers": 61, "hidden_size": 7168,
|
||
"num_attention_heads": 128, "num_key_value_heads": 128,
|
||
"head_dim": 192, "vocab_size": 131072,
|
||
"moe_intermediate_size": 4096,
|
||
"license": "apache-2.0",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2025-11-28",
|
||
},
|
||
{
|
||
"name": "mistralai/Leanstral-1.5-119B-A6B",
|
||
"provider": "Mistral AI", "parameter_count": "119B",
|
||
"parameters_raw": 119_000_000_000,
|
||
"min_ram_gb": 66.5, "recommended_ram_gb": 110.8, "min_vram_gb": 61.0,
|
||
"quantization": "Q4_K_M", "context_length": 1048576,
|
||
"use_case": "Lean 4 theorem proving and formal reasoning, MoE",
|
||
"pipeline_tag": "text-generation", "architecture": "mistral4",
|
||
"is_moe": True, "num_experts": 128, "active_experts": 4,
|
||
"active_parameters": 6_500_000_000,
|
||
"num_hidden_layers": 36, "hidden_size": 4096,
|
||
"num_attention_heads": 32, "num_key_value_heads": 32,
|
||
"head_dim": 128, "vocab_size": 131072,
|
||
"moe_intermediate_size": 2048,
|
||
"license": "apache-2.0",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": "2026-07-01",
|
||
},
|
||
# RWKV v7 G1f: GGUF-native repos — no safetensors metadata, fallback required
|
||
{
|
||
"name": "shoumenchougou/RWKV7-G1f-1.5B-GGUF",
|
||
"provider": "RWKV", "parameter_count": "1.5B",
|
||
"parameters_raw": 1_500_000_000,
|
||
"min_ram_gb": 1.0, "recommended_ram_gb": 2.0, "min_vram_gb": 0.8,
|
||
"quantization": "Q4_K_M", "format": "gguf", "context_length": 8192,
|
||
"use_case": "General purpose text generation",
|
||
"capabilities": [],
|
||
"pipeline_tag": "text-generation", "architecture": "rwkv",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "shoumenchougou/RWKV7-G1f-2.9B-GGUF",
|
||
"provider": "RWKV", "parameter_count": "2.9B",
|
||
"parameters_raw": 2_900_000_000,
|
||
"min_ram_gb": 1.6, "recommended_ram_gb": 2.7, "min_vram_gb": 1.5,
|
||
"quantization": "Q4_K_M", "format": "gguf", "context_length": 8192,
|
||
"use_case": "General purpose text generation",
|
||
"capabilities": [],
|
||
"pipeline_tag": "text-generation", "architecture": "rwkv",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "shoumenchougou/RWKV7-G1f-7.2B-GGUF",
|
||
"provider": "RWKV", "parameter_count": "7.2B",
|
||
"parameters_raw": 7_200_000_000,
|
||
"min_ram_gb": 4.0, "recommended_ram_gb": 6.7, "min_vram_gb": 3.7,
|
||
"quantization": "Q4_K_M", "format": "gguf", "context_length": 8192,
|
||
"use_case": "General purpose text generation",
|
||
"capabilities": [],
|
||
"pipeline_tag": "text-generation", "architecture": "rwkv",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
{
|
||
"name": "shoumenchougou/RWKV7-G1f-13.3B-GGUF",
|
||
"provider": "RWKV", "parameter_count": "13.3B",
|
||
"parameters_raw": 13_300_000_000,
|
||
"min_ram_gb": 7.4, "recommended_ram_gb": 12.4, "min_vram_gb": 6.8,
|
||
"quantization": "Q4_K_M", "format": "gguf", "context_length": 8192,
|
||
"use_case": "General purpose text generation",
|
||
"capabilities": [],
|
||
"pipeline_tag": "text-generation", "architecture": "rwkv",
|
||
"hf_downloads": 0, "hf_likes": 0, "release_date": None,
|
||
},
|
||
]
|
||
|
||
print(f"Scraping {len(TARGET_MODELS)} curated models from HuggingFace...\n")
|
||
|
||
results, scraped_names = scrape_models_parallel(TARGET_MODELS, args.threads)
|
||
|
||
# Fill in fallbacks for models that couldn't be scraped
|
||
fallback_count = 0
|
||
for fb in FALLBACKS:
|
||
if fb["name"] not in scraped_names:
|
||
print(f" + Fallback: {fb['name']} ({fb['parameter_count']})")
|
||
results.append(fb)
|
||
scraped_names.add(fb["name"])
|
||
fallback_count += 1
|
||
|
||
# Auto-discover trending models if --discover flag is set
|
||
discovered_count = 0
|
||
if args.discover:
|
||
print(f"\nDiscovering top models by downloads (limit={args.discover_limit}, "
|
||
f"min_downloads={args.min_downloads:,})...")
|
||
trending = discover_trending_models(
|
||
limit=args.discover_limit,
|
||
min_downloads=args.min_downloads,
|
||
)
|
||
already_scraped = sum(1 for l in trending if l["id"] in scraped_names)
|
||
print(f"\n Discovery returned {len(trending)} candidates"
|
||
f" ({already_scraped} already scraped)\n")
|
||
|
||
candidates = [l for l in trending if l["id"] not in scraped_names]
|
||
|
||
if args.threads <= 1:
|
||
for i, listing in enumerate(candidates, 1):
|
||
repo_id = listing["id"]
|
||
print(f"[discover {i}/{len(candidates)}] {repo_id}...")
|
||
model = _build_discovered_model_safely(listing)
|
||
if model:
|
||
print(f" ✓ {model['parameter_count']} params, "
|
||
f"{model['hf_downloads']:,} downloads, "
|
||
f"ctx {model['context_length']}")
|
||
results.append(model)
|
||
scraped_names.add(repo_id)
|
||
discovered_count += 1
|
||
time.sleep(0.15)
|
||
else:
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads) as executor:
|
||
for i, (listing, model) in enumerate(
|
||
zip(candidates, executor.map(_build_discovered_model_safely, candidates)),
|
||
1,
|
||
):
|
||
repo_id = listing["id"]
|
||
print(f"[discover {i}/{len(candidates)}] {repo_id}...")
|
||
if model:
|
||
print(f" ✓ {model['parameter_count']} params, "
|
||
f"{model['hf_downloads']:,} downloads, "
|
||
f"ctx {model['context_length']}")
|
||
results.append(model)
|
||
scraped_names.add(repo_id)
|
||
discovered_count += 1
|
||
|
||
if DISCOVERY_SKIPPED:
|
||
print(f"\n Discovery skipped {len(DISCOVERY_SKIPPED)} repo(s) with "
|
||
f"unprocessable metadata: {', '.join(DISCOVERY_SKIPPED[:10])}")
|
||
if len(DISCOVERY_SKIPPED) > DISCOVERY_SKIP_LIMIT:
|
||
print(f"ERROR: {len(DISCOVERY_SKIPPED)} repos failed to build "
|
||
f"(limit {DISCOVERY_SKIP_LIMIT}). That is a scraper bug, not "
|
||
f"bad upstream data; refusing to ship a thinned catalog.")
|
||
sys.exit(1)
|
||
|
||
# --- Revalidate a slice of the entries the merge would retain as-is ---
|
||
revalidated_count = 0
|
||
retained_stamps: dict[str, str] = {}
|
||
if args.revalidate > 0 and os.path.exists("llmfit-core/data/hf_models.json"):
|
||
try:
|
||
with open("llmfit-core/data/hf_models.json") as f:
|
||
prior = json.load(f)
|
||
except (json.JSONDecodeError, OSError):
|
||
prior = []
|
||
prior_by_name = {m.get("name"): m for m in prior}
|
||
stale = select_retained_for_revalidation(prior, scraped_names, args.revalidate)
|
||
if stale:
|
||
print(f"\nRevalidating {len(stale)} retained entries "
|
||
f"(budget {args.revalidate})...\n")
|
||
refreshed, _ = scrape_models_parallel(stale, args.threads)
|
||
stamp = date.today().isoformat()
|
||
# Every attempt is stamped, including the ones that stay retained
|
||
# below, so next run's budget moves on to other candidates.
|
||
for name in stale:
|
||
prior_by_name[name]["_revalidated"] = stamp
|
||
for model in refreshed:
|
||
# A repo that is gone, gated or unparseable returns nothing
|
||
# and stays retained; only a successful re-fetch replaces it.
|
||
before = prior_by_name[model["name"]]
|
||
if revalidation_lost_parameters(before, model):
|
||
print(f" ⚠ {model['name']}: re-fetch reports "
|
||
f"{model['parameter_count']} against a retained "
|
||
f"{before.get('parameter_count')}, keeping the retained entry",
|
||
file=sys.stderr)
|
||
continue
|
||
if before.get("_discovered"):
|
||
model["_discovered"] = True
|
||
model["_revalidated"] = stamp
|
||
results.append(model)
|
||
scraped_names.add(model["name"])
|
||
revalidated_count += 1
|
||
# Entries that stay retained are re-read from disk by the merge,
|
||
# so hand it the stamped copies.
|
||
retained_stamps = {n: stamp for n in stale if n not in scraped_names}
|
||
print(f"\n Revalidated {revalidated_count} of {len(stale)} retained entries")
|
||
|
||
# --- Additive merge with existing database ---
|
||
# The database is additive: models from previous runs are preserved.
|
||
# Freshly scraped models update existing entries; historical models
|
||
# that are no longer in the top discovered set are kept as-is.
|
||
output_paths = ["llmfit-core/data/hf_models.json"]
|
||
|
||
# Build a map of freshly scraped models (name -> model dict)
|
||
fresh_by_name = {m["name"]: m for m in results}
|
||
|
||
# Load existing database and merge
|
||
existing_count = 0
|
||
retained_count = 0
|
||
updated_count = 0
|
||
arch_heads_restored = 0
|
||
for output_path in output_paths:
|
||
if os.path.exists(output_path):
|
||
try:
|
||
with open(output_path) as f:
|
||
existing = json.load(f)
|
||
existing_count = max(existing_count, len(existing))
|
||
for old_model in existing:
|
||
name = old_model.get("name", "")
|
||
if name in fresh_by_name:
|
||
fresh_model = fresh_by_name[name]
|
||
restored = preserve_existing_metadata(old_model, fresh_model)
|
||
if "num_attention_heads" in restored:
|
||
arch_heads_restored += 1
|
||
updated_count += 1
|
||
elif name:
|
||
# Historical model not in current scrape — keep it
|
||
if name in retained_stamps:
|
||
old_model["_revalidated"] = retained_stamps[name]
|
||
results.append(old_model)
|
||
fresh_by_name[name] = old_model
|
||
scraped_names.add(name)
|
||
retained_count += 1
|
||
except (json.JSONDecodeError, KeyError):
|
||
pass
|
||
break # Only need to load from one path
|
||
|
||
if existing_count:
|
||
print(f"\nMerged with existing database ({existing_count} models):")
|
||
print(f" Updated: {updated_count}, Retained historical: {retained_count}")
|
||
print(f" Architecture heads restored from prior catalog: {arch_heads_restored}")
|
||
if arch_heads_restored > ARCH_METADATA_DROP_LIMIT:
|
||
print(
|
||
f"ERROR: config.json fetch dropped attention-head metadata for "
|
||
f"{arch_heads_restored} models (limit {ARCH_METADATA_DROP_LIMIT}). "
|
||
f"Prior values were kept, but refusing to ship this scrape so the "
|
||
f"mass drop cannot land quietly again."
|
||
)
|
||
print(f" {rate_limit_summary()}")
|
||
sys.exit(1)
|
||
|
||
# Keep additive/retained entries on the current schema even if they were
|
||
# produced by an older scraper version.
|
||
for model in results:
|
||
model.setdefault("capabilities", [])
|
||
if not model.get("languages"):
|
||
model.pop("languages", None)
|
||
|
||
# Sort by parameter count
|
||
results.sort(key=lambda m: m["parameters_raw"])
|
||
|
||
# Enrich with GGUF download sources if requested
|
||
gguf_enriched = 0
|
||
if args.gguf_sources:
|
||
print(f"\nEnriching {len(results)} models with GGUF download sources...")
|
||
gguf_enriched = enrich_gguf_sources(
|
||
results, threads=args.threads, budget=args.gguf_probe_budget)
|
||
print(f" Found GGUF sources for {gguf_enriched} models")
|
||
|
||
# Credential-shaped strings occasionally leak into upstream metadata —
|
||
# e.g. a repo accidentally named after an access token. Never write them
|
||
# into the catalog: GitHub secret scanning rightly rejects such commits
|
||
# (it blocked the 2026-08-03 weekly-update push), and the entry is
|
||
# garbage anyway. Drop the whole model and report it redacted, so the
|
||
# token never lands in CI logs either.
|
||
results, dropped = drop_secret_bearing_models(results)
|
||
for redacted_name in dropped:
|
||
print(f" ⚠ Dropped entry containing a credential-shaped string: "
|
||
f"{redacted_name}", file=sys.stderr)
|
||
|
||
# Write to llmfit-core/data (compiled into the binary via include_str!)
|
||
for output_path in output_paths:
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
with open(output_path, "w") as f:
|
||
json.dump(results, f, indent=2)
|
||
|
||
print(f"\n✅ Wrote {len(results)} models to {', '.join(output_paths)}")
|
||
print(f" Curated: {len(TARGET_MODELS)}, Fallbacks: {fallback_count}, "
|
||
f"Discovered: {discovered_count}, Retained: {retained_count}, "
|
||
f"GGUF-sourced: {gguf_enriched}")
|
||
print(f" {rate_limit_summary()}")
|
||
|
||
# Print summary table
|
||
print(f"\n{'Model':<50} {'Params':>8} {'Min RAM':>8} {'Rec RAM':>8} {'VRAM':>6}")
|
||
print("─" * 84)
|
||
for m in results:
|
||
print(f"{m['name']:<50} {m['parameter_count']:>8} "
|
||
f"{m['min_ram_gb']:>7.1f}G {m['recommended_ram_gb']:>7.1f}G "
|
||
f"{m['min_vram_gb']:>5.1f}G")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|