File size: 9,118 Bytes
f707f5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""Re-pull `inference-provider-capabilities.jsonl` from the HF Inference Providers catalog.

The snapshot next to this script is dated data (see the `.md`): the repo convention is to
re-pull whenever it is more than ~7 days old, because provider capability booleans, prices,
and even whole `(model, provider)` routes churn week to week.

What it does: fetch the catalog's backing endpoint (the same one the HF Inference Providers
page reads), keep only `status: "live"` provider rows, flatten each to one record, stamp every
record with the pull date, and write the JSONL sorted by model id (case-insensitively) then
provider β€” the stable order the file has always used, so a re-pull diffs cleanly.

It writes the DATA only. The `.md`'s prose β€” snapshot date, row/model counts, the provider
capability table, the notable-gaps bullets, the cheapest-routes table β€” is hand-maintained;
`--summary` prints the recomputed numbers to paste in, and `--diff` reports what changed
against the committed snapshot so the drift is easy to write up.

    python refresh-capabilities.py --diff --summary      # pull + report (writes the JSONL)
    python refresh-capabilities.py --dry-run --diff      # report only, write nothing
    python refresh-capabilities.py --retrieved 2026-07-27  # override the stamp (default: today)

Needs no API key β€” the catalog endpoint is public.
"""

from __future__ import annotations

import argparse
import json
import urllib.request
from collections import defaultdict
from datetime import date
from pathlib import Path
from typing import Any

_HERE = Path(__file__).resolve().parent
OUT = _HERE / "inference-provider-capabilities.jsonl"
ENDPOINT = "https://router.huggingface.co/v1/models"
SOURCE = "https://huggingface.co/inference/models"

# Record field order β€” frozen so a re-pull produces a minimal diff. Documented in the .md.
FIELDS = ("model", "owned_by", "provider", "context_length", "input_price_per_mtok",
          "output_price_per_mtok", "supports_tools", "supports_structured_output",
          "is_model_author", "retrieved", "source", "endpoint")


def _price(v: Any) -> float | None:
    """USD per 1M tokens as the catalog reports it, rounded to kill float noise
    (the endpoint returns e.g. 0.9299999999999999 for a $0.93 route)."""
    return None if v is None else round(float(v), 6)


def fetch(endpoint: str = ENDPOINT) -> list[dict[str, Any]]:
    with urllib.request.urlopen(endpoint, timeout=120) as resp:
        return json.load(resp)["data"]


def flatten(models: list[dict[str, Any]], retrieved: str) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for m in models:
        for p in m.get("providers") or []:
            if p.get("status") != "live":
                continue
            pricing = p.get("pricing") or {}
            rows.append({
                "model": m["id"],
                "owned_by": m.get("owned_by"),
                "provider": p.get("provider"),
                "context_length": p.get("context_length"),
                "input_price_per_mtok": _price(pricing.get("input")),
                "output_price_per_mtok": _price(pricing.get("output")),
                # Absent/None reads as "the provider doesn't expose it" β€” coerce so the
                # column is a clean boolean for consumers that filter on it.
                "supports_tools": bool(p.get("supports_tools")),
                "supports_structured_output": bool(p.get("supports_structured_output")),
                "is_model_author": bool(p.get("is_model_author")),
                "retrieved": retrieved,
                "source": SOURCE,
                "endpoint": endpoint_of(m),
            })
    rows.sort(key=lambda r: (r["model"].lower(), r["provider"]))
    return rows


def endpoint_of(_model: dict[str, Any]) -> str:
    return ENDPOINT


def _read_committed() -> list[dict[str, Any]]:
    if not OUT.exists():
        return []
    return [json.loads(line) for line in OUT.read_text(encoding="utf-8").splitlines() if line.strip()]


def report_diff(old: list[dict[str, Any]], new: list[dict[str, Any]]) -> None:
    """What changed vs the committed snapshot, keyed by (model, provider) and ignoring
    the `retrieved` stamp (which changes on every pull by construction)."""
    def index(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
        return {(r["model"], r["provider"]): {k: v for k, v in r.items() if k != "retrieved"} for r in rows}

    o, n = index(old), index(new)
    prev_date = old[0].get("retrieved") if old else "(none)"
    added, removed = sorted(n.keys() - o.keys()), sorted(o.keys() - n.keys())
    flipped = [(k, {f: (o[k][f], n[k][f]) for f in o[k] if o[k][f] != n[k][f]})
               for k in sorted(o.keys() & n.keys()) if o[k] != n[k]]
    print(f"\n--- drift vs committed snapshot ({prev_date}) ---")
    print(f"{len(added)} added, {len(removed)} removed, {len(flipped)} changed "
          f"({len(o)} -> {len(n)} rows)")
    for k in added:
        print(f"  + {k[0]} @ {k[1]}")
    for k in removed:
        print(f"  - {k[0]} @ {k[1]}")
    for k, d in flipped:
        print(f"  ~ {k[0]} @ {k[1]}: " + ", ".join(f"{f} {a}->{b}" for f, (a, b) in d.items()))


def report_summary(rows: list[dict[str, Any]]) -> None:
    """Recompute the hand-maintained numbers in the .md so they can be pasted in."""
    both = [r for r in rows if r["supports_tools"] and r["supports_structured_output"]]
    tools_only = [r for r in rows if r["supports_tools"] and not r["supports_structured_output"]]
    print(f"\n--- .md summary numbers ---")
    print(f"Rows: {len(rows)} live (model, provider) pairs across "
          f"{len({r['model'] for r in rows})} models and {len({r['provider'] for r in rows})} providers.")
    print(f"Both tools + structured output: {len(both)} provider rows across "
          f"{len({r['model'] for r in both})} models. Tools-only: {len(tools_only)} rows.")

    profile: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0])
    for r in rows:
        e = profile[r["provider"]]
        e[0] += 1
        e[1] += bool(r["supports_tools"])
        e[2] += bool(r["supports_tools"] and r["supports_structured_output"])
    print("\n| Provider | live rows | tools | tools **+** structured output |")
    print("| --- | ---: | ---: | ---: |")
    # Same ordering as the .md: both-capable providers first (most structured-output
    # routes), then the tools-only ones (most tool routes), then the capability-less
    # bulk host β€” so the table reads as a capability ranking, not a volume ranking.
    both_capable = sorted((p for p, e in profile.items() if e[2]), key=lambda p: -profile[p][2])
    rest = sorted((p for p, e in profile.items() if not e[2] and e[1]), key=lambda p: -profile[p][1])
    none = sorted((p for p, e in profile.items() if not e[1]), key=lambda p: -profile[p][0])
    for p in [*both_capable, *rest, *none]:
        live, t, b = profile[p]
        print(f"| {p} | {live} | {t} | {b if b else '**0**'} |")

    cheapest: dict[str, dict[str, Any]] = {}
    for r in both:
        if r["input_price_per_mtok"] is None:
            continue
        cur = cheapest.get(r["model"])
        if cur is None or r["input_price_per_mtok"] < cur["input_price_per_mtok"]:
            cheapest[r["model"]] = r
    print("\n| Model | Cheapest both-capable provider | Context | $ In / Out (1M) |")
    print("| --- | --- | ---: | --- |")
    for r in sorted(cheapest.values(), key=lambda r: r["input_price_per_mtok"])[:5]:
        ctx = f"{round((r['context_length'] or 0) / 1000)}K" if r["context_length"] else "?"
        print(f"| {r['model']} | {r['provider']} | {ctx} | "
              f"{r['input_price_per_mtok']} / {r['output_price_per_mtok']} |")


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--retrieved", default=date.today().isoformat(),
                    help="snapshot date to stamp on every record (default: today)")
    ap.add_argument("--dry-run", action="store_true", help="don't write the JSONL")
    ap.add_argument("--diff", action="store_true", help="report drift vs the committed snapshot")
    ap.add_argument("--summary", action="store_true", help="print the .md's hand-maintained numbers")
    args = ap.parse_args()

    old = _read_committed()
    rows = flatten(fetch(), args.retrieved)
    if args.diff:
        report_diff(old, rows)
    if args.summary:
        report_summary(rows)
    if args.dry_run:
        print(f"\n(dry run β€” {len(rows)} rows not written)")
        return 0
    OUT.write_text("".join(json.dumps({k: r[k] for k in FIELDS}, separators=(",", ":"),
                                      ensure_ascii=False) + "\n" for r in rows), encoding="utf-8")
    print(f"\nWrote {len(rows)} rows to {OUT} (retrieved {args.retrieved}).")
    print("Now update inference-provider-capabilities.md by hand: snapshot date, row/model "
          "counts, provider table, notable-gaps bullets, cheapest-routes table.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())