llmfit/scripts/scrape_hf_models.py
Akciali 29bab4d109
fix(scraper): keep a rate-limited base lookup out of the params cache (#1071)
* 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
2026-10-09 06:43:28 +01:00

3875 lines
164 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()