File size: 4,770 Bytes
5db30ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""Model sizes (billions of parameters) for the dashboard's size filter.

OpenRouter lists no parameter counts. For open-weight models it gives a Hugging Face
repo id, and the Hugging Face API reports the parameter total from the safetensors
metadata. Those totals are cached in ``model_sizes.json`` next to this file so pages
never wait on Hugging Face; refresh the cache with::

    python -m elbench.dashboard.model_sizes

Models with no repo id fall back to the size in their name (``qwen3-235b-a22b`` is
235B total, 22B active). Closed models have no size at all.
"""

from __future__ import annotations

import json
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path

import httpx

SIZES_PATH = Path(__file__).with_name("model_sizes.json")
HF_MODEL_API = "https://huggingface.co/api/models/{repo}?expand[]=safetensors"
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"

# "-235b" or "/8b" but not "a22b" or "8x7b"; "-a22b" is the active size of a mixture-of-experts model.
_TOTAL_IN_NAME = re.compile(r"(?:^|[-_/.])(\d+(?:\.\d+)?)b(?=$|[-_:.])", re.IGNORECASE)
_ACTIVE_IN_NAME = re.compile(r"(?:^|[-_/.])a(\d+(?:\.\d+)?)b(?=$|[-_:.])", re.IGNORECASE)


@dataclass(frozen=True)
class ModelSize:
    total_b: float
    active_b: float | None = None

    def label(self) -> str:
        text = f"{_billions(self.total_b)}B"
        if self.active_b is not None and self.active_b < self.total_b:
            text += f" ({_billions(self.active_b)}B active)"
        return text


def _billions(value: float) -> str:
    return f"{value:.0f}" if value >= 10 else f"{value:.1f}".rstrip("0").rstrip(".")


def size_from_name(model_id: str) -> ModelSize | None:
    name = str(model_id or "").split("/", 1)[-1]
    total = _TOTAL_IN_NAME.search(name)
    if total is None:
        return None
    active = _ACTIVE_IN_NAME.search(name)
    return ModelSize(float(total.group(1)), float(active.group(1)) if active else None)


def load_sizes(path: Path = SIZES_PATH) -> dict[str, float]:
    """Cached totals: Hugging Face repo id -> billions of parameters."""
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}
    return {str(repo): float(total) for repo, total in (data.get("total_b") or {}).items()}


def model_size(model_id: str, hugging_face_id: str | None, sizes: dict[str, float]) -> ModelSize | None:
    named = size_from_name(model_id)
    total = sizes.get(hugging_face_id or "")
    if total is not None:
        return ModelSize(total, named.active_b if named else None)
    return named


def fetch_total_b(repo: str, client: httpx.Client) -> float | None:
    response = client.get(HF_MODEL_API.format(repo=repo))
    if response.status_code != 200:
        return None
    total = ((response.json() or {}).get("safetensors") or {}).get("total")
    return float(total) / 1e9 if isinstance(total, (int, float)) and total > 0 else None


def refresh_sizes(repos: list[str], *, token: str | None = None, path: Path = SIZES_PATH) -> dict[str, float]:
    """Fetch totals for ``repos`` not yet cached, then rewrite the cache file."""
    sizes = load_sizes(path)
    missing = sorted({repo for repo in repos if repo and repo not in sizes})
    headers = {"Authorization": f"Bearer {token}"} if token else {}
    with httpx.Client(headers=headers, timeout=20, follow_redirects=True) as client:

        def fetch(repo: str) -> tuple[str, float | None]:
            try:
                return repo, fetch_total_b(repo, client)
            except httpx.HTTPError:
                return repo, None

        with ThreadPoolExecutor(max_workers=8) as pool:
            for repo, total in pool.map(fetch, missing):
                if total is not None:
                    sizes[repo] = round(total, 3)
    path.write_text(
        json.dumps(
            {
                "source": "Hugging Face API safetensors.total, for OpenRouter models with a hugging_face_id",
                "updated": time.strftime("%Y-%m-%d", time.gmtime()),
                "total_b": dict(sorted(sizes.items())),
            },
            indent=2,
            ensure_ascii=False,
        )
        + "\n",
        encoding="utf-8",
    )
    return sizes


def main() -> int:
    catalog = httpx.get(OPENROUTER_MODELS_URL, timeout=45).json()
    repos = [str(item.get("hugging_face_id") or "") for item in catalog.get("data") or []]
    token = os.environ.get("HF_TOKEN") or os.environ.get("HF_API_KEY")
    sizes = refresh_sizes([repo for repo in repos if repo], token=token)
    print(f"{len(sizes)} model sizes cached in {SIZES_PATH}")
    return 0


if __name__ == "__main__":
    sys.exit(main())