opencode-models 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ """Load opencode.ai/data per-model JSON files into normalized records.
2
+
3
+ The scraper (see fetch.py) stores one JSON file per model exactly as served
4
+ by ``https://opencode.ai/data/<lab>/<model>.json`` under ``data/opencode/``
5
+ — the verbatim upstream layer that only ``fetch`` writes. Curated overlays
6
+ live beside it in ``data/overrides/`` and are merged by overrides.py, never
7
+ here. This module turns the upstream files into :class:`Model` records;
8
+ parsing is tolerant — a model without pricing, without benchmarks, or a
9
+ file that is not valid JSON at all must not take down a whole render.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Model:
21
+ """One model's identity, pricing and benchmark scores."""
22
+
23
+ model_id: str
24
+ name: str
25
+ lab: str
26
+ price_input: float | None
27
+ price_output: float | None
28
+ price_cached_input: float | None
29
+ scores: dict[str, float] = field(default_factory=dict)
30
+ sources: dict[str, str | None] = field(default_factory=dict)
31
+
32
+ @property
33
+ def slug(self) -> str:
34
+ """Filename-safe unique key (``deepseek/deepseek-v4-pro`` → dashes)."""
35
+ return self.model_id.replace("/", "-")
36
+
37
+
38
+ def benchmark_key(b: dict) -> str:
39
+ """Benchmark name qualified by version when present.
40
+
41
+ Two models' "Terminal-Bench" scores are only comparable when the
42
+ benchmark version is the same — Fable 5's 88 on Terminal-Bench 2.1 and
43
+ Fable 5.1's 55.8 on 4.0 look like a regression until you see the
44
+ version. Entries without a version field keep the bare name.
45
+ """
46
+ name = b["name"]
47
+ version = b.get("version")
48
+ return f"{name} {version}" if version else name
49
+
50
+
51
+ def load_model(path: Path) -> Model:
52
+ """Parse one scraped ``<lab>__<model>.json`` file."""
53
+ doc = json.loads(path.read_text())
54
+ m = doc["model"]
55
+ price = m.get("priceUsdPer1M") or {}
56
+ scores: dict[str, float] = {}
57
+ sources: dict[str, str | None] = {}
58
+ for b in m.get("benchmarks") or []:
59
+ if isinstance(b.get("score"), (int, float)):
60
+ scores[benchmark_key(b)] = b["score"]
61
+ sources[benchmark_key(b)] = b.get("source")
62
+ return Model(
63
+ model_id=m["id"],
64
+ name=m.get("name") or m["id"],
65
+ lab=m.get("lab") or m["id"].split("/")[0],
66
+ price_input=price.get("input"),
67
+ price_output=price.get("output"),
68
+ price_cached_input=price.get("cachedInput"),
69
+ scores=scores,
70
+ sources=sources,
71
+ )
72
+
73
+
74
+ def load_models(data_dir: Path) -> list[Model]:
75
+ """Parse every ``*.json`` file in *data_dir*, skipping unreadable ones."""
76
+ models: list[Model] = []
77
+ for path in sorted(Path(data_dir).glob("*.json")):
78
+ try:
79
+ models.append(load_model(path))
80
+ except (json.JSONDecodeError, KeyError, TypeError):
81
+ continue
82
+ return models
@@ -0,0 +1,101 @@
1
+ """Refresh the data directory from opencode.ai.
2
+
3
+ The site publishes one page per model and serves its structured data at the
4
+ same URL with a ``.json`` suffix; the sitemap at SITEMAP_URL lists every
5
+ page. HTTP access is an injected ``get(url) -> str`` callable so tests can
6
+ exercise the full flow offline (see tests/test_fetch.py).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import urllib.error
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from xml.etree import ElementTree
15
+
16
+ from . import __version__
17
+ from .availability import offered
18
+
19
+ SITEMAP_URL = "https://opencode.ai/data/sitemap.xml"
20
+ _BASE = "https://opencode.ai/data/"
21
+ _SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
22
+
23
+
24
+ def http_get(url: str) -> str:
25
+ """Real transport: plain GET with a browser-ish user agent.
26
+
27
+ Failed responses are closed before the error propagates: urlopen
28
+ leaves the error response open, and dropping it unclosed warns at
29
+ GC time. No caller reads error bodies, so nothing is lost.
30
+ """
31
+ req = urllib.request.Request(
32
+ url, headers={"User-Agent": f"opencode-models/{__version__}"}
33
+ )
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=30) as resp:
36
+ return resp.read().decode("utf-8", errors="replace")
37
+ except urllib.error.HTTPError as exc:
38
+ exc.close()
39
+ raise
40
+
41
+
42
+ def urls_from_sitemap(xml: str) -> list[str]:
43
+ """Model-page URLs from sitemap XML, skipping the root and lab indexes."""
44
+ root = ElementTree.fromstring(xml)
45
+ urls = []
46
+ for loc in root.iter(f"{_SITEMAP_NS}loc"):
47
+ text = (loc.text or "").strip()
48
+ # Only <scheme>://opencode.ai/data/<lab>/<model> — lab indexes and
49
+ # other paths have fewer segments or a different host.
50
+ if text.startswith(_BASE) and text.count("/") == 5:
51
+ urls.append(text)
52
+ return urls
53
+
54
+
55
+ def _filename_for(url: str) -> str:
56
+ """``https://opencode.ai/data/<lab>/<model>`` → ``<lab>__<model>.json``."""
57
+ lab, model = url[len(_BASE) :].rsplit("/", 1)
58
+ return f"{lab}__{model}.json"
59
+
60
+
61
+ def refresh(
62
+ data_dir: Path, get=http_get, progress=None, only: set[str] | None = None
63
+ ) -> list[str]:
64
+ """Download every model's JSON into *data_dir*/opencode; return names.
65
+
66
+ The upstream layer is verbatim: ``fetch`` writes only here, and the
67
+ curated overlays in ``data/overrides/`` are layered on at load time
68
+ (see overrides.py). A failed or empty download for one model is
69
+ skipped, not fatal — the rest of the dataset still refreshes.
70
+
71
+ *progress* is an optional ``(done, total, name)`` callback, fired
72
+ after every model URL is attempted (saved or skipped), so a caller
73
+ can show the long sequential walk instead of silence.
74
+
75
+ *only* restricts downloads to the models the plans offer (slugs or
76
+ ids — ``-free`` variants reaching their model, as elsewhere). Files
77
+ already on disk are never deleted: a model the plans dropped keeps
78
+ its record, so the scales it anchors do not move under the scores.
79
+ None downloads everything.
80
+ """
81
+ out = Path(data_dir) / "opencode"
82
+ out.mkdir(parents=True, exist_ok=True)
83
+ saved: list[str] = []
84
+ urls = urls_from_sitemap(get(SITEMAP_URL))
85
+ if only is not None:
86
+ urls = [u for u in urls if offered(u[len(_BASE) :], only)]
87
+ total = len(urls)
88
+ for done, url in enumerate(urls, 1):
89
+ try:
90
+ body = get(f"{url}.json")
91
+ except (urllib.error.URLError, OSError):
92
+ body = ""
93
+ if body.strip():
94
+ name = _filename_for(url)
95
+ (out / name).write_text(body)
96
+ saved.append(name)
97
+ else:
98
+ name = _filename_for(url)
99
+ if progress is not None:
100
+ progress(done, total, name)
101
+ return saved
@@ -0,0 +1,100 @@
1
+ """Which model a catalog id really names.
2
+
3
+ A designation is not an identity. ``meta/muse-spark-1.3`` and
4
+ ``meta/muse-spark-1.3-contributor`` are the same weights on two plans: eleven
5
+ identical benchmark values, none differing, $1.25 in on Zen and $0.10 in on
6
+ Go. The catalog lists them as two models, so the console showed two rows for
7
+ one model - and two different scores, 99 and 98, because the contributor
8
+ record happened to lack a Terminal-Bench 4.0 run its twin carried. A
9
+ data-completeness artefact, not a difference between models.
10
+
11
+ So the benchmarks merge into one model, the score is computed once, and each
12
+ plan keeps its own priced line - which is exactly how a priced variant like
13
+ ``(Off-Peak)`` already works. What changes is that the alias has to be known
14
+ in three places at once: the model's own record, the tier price entries keyed
15
+ by id, and the availability record. Getting two of the three right still
16
+ splits the model, so all three go through :func:`canonical_id`.
17
+
18
+ The alias map is explicit rather than a suffix rule. ``-contributor`` is a
19
+ Meta programme today and means nothing anywhere else, and a suffix rule would
20
+ quietly merge a model whose name merely ends that way - while a designation
21
+ with no twin in the catalog keeps its own identity rather than vanishing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import replace
27
+
28
+ from .data import Model
29
+
30
+ #: Designation id → the model it is a designation of.
31
+ #:
32
+ #: Added only where the two records carry identical benchmark values, so the
33
+ #: merge cannot invent a difference. A pair that disagrees is a data problem
34
+ #: to look at, not an alias to paper over.
35
+ MODEL_ALIASES: dict[str, str] = {
36
+ "meta/muse-spark-1.2-contributor": "meta/muse-spark-1.2",
37
+ "meta/muse-spark-1.3-contributor": "meta/muse-spark-1.3",
38
+ }
39
+
40
+
41
+ def canonical_id(model_id: str) -> str:
42
+ """The model *model_id* names, with any plan designation folded away."""
43
+ return MODEL_ALIASES.get(model_id, model_id)
44
+
45
+
46
+ def plan_ids(model_id: str) -> set[str]:
47
+ """Every catalog id that is this model, designation or not."""
48
+ ids = {model_id}
49
+ ids.update(alias for alias, plain in MODEL_ALIASES.items() if plain == model_id)
50
+ return ids
51
+
52
+
53
+ def merge_aliases(models: list[Model]) -> list[Model]:
54
+ """Fold designation records into the model they are a designation of.
55
+
56
+ Benchmarks union (the plain record wins a disagreement, so a designation
57
+ can never quietly restate a number), and the cheapest price is kept: the
58
+ tier price tables replace it wherever they list the model, and where they
59
+ do not, the price someone can actually pay is the honest one. Order is
60
+ the catalog's, so the tables do not reshuffle.
61
+ """
62
+ known = {m.model_id: m for m in models}
63
+ merged: list[Model] = []
64
+ seen: set[str] = set()
65
+ for model in models:
66
+ plain = canonical_id(model.model_id)
67
+ if plain in seen:
68
+ continue # already emitted as part of its group
69
+ twin = known.get(plain)
70
+ group = (
71
+ [
72
+ known[alias]
73
+ for alias, target in MODEL_ALIASES.items()
74
+ if target == plain and alias in known
75
+ ]
76
+ if twin
77
+ else []
78
+ )
79
+ seen.add(model.model_id)
80
+ seen.update(m.model_id for m in group)
81
+ if twin is None or not group:
82
+ merged.append(model) # no twin: renaming must not hide it
83
+ continue
84
+ scores, sources = dict(twin.scores), dict(twin.sources)
85
+ for other in group:
86
+ scores = {**other.scores, **scores} # plain record wins
87
+ sources = {**other.sources, **sources}
88
+ merged.append(
89
+ replace(
90
+ twin,
91
+ price_input=min([twin.price_input] + [m.price_input for m in group]),
92
+ price_output=min([twin.price_output] + [m.price_output for m in group]),
93
+ price_cached_input=min(
94
+ [twin.price_cached_input] + [m.price_cached_input for m in group]
95
+ ),
96
+ scores=scores,
97
+ sources=sources,
98
+ )
99
+ )
100
+ return merged
@@ -0,0 +1,84 @@
1
+ """How each board's model labels map onto catalog ids.
2
+
3
+ A board and the catalog name the same models differently: Terminal-Bench
4
+ says "Opus 5.5" where the catalog says "Claude Opus 5.5", SWE-bench says
5
+ "Claude 3.5 Sonnet" where the catalog says "Claude Sonnet 3.5 v2". The token
6
+ matcher in :mod:`boards` handles most of that, but two things it cannot do
7
+ on its own:
8
+
9
+ - **Decide a tie.** "GPT-3.5" fits ``openai/gpt-3.5-turbo`` and
10
+ ``openai/gpt-5.3-codex`` equally well; the right answer is the first, and
11
+ no amount of token arithmetic says so.
12
+ - **Stay put.** The catalog gains and loses rows between fetches. A label
13
+ that resolves today can become ambiguous after an unrelated model lands,
14
+ and a board score would stop being applied without anyone noticing.
15
+
16
+ So each service's awkward labels are recorded here, once, by hand. This is
17
+ the *exception list*: labels the matcher places by name or by unique token
18
+ overlap are not listed, because there is nothing to decide. When one appears
19
+ here it is because a human looked at the candidates and made a call.
20
+
21
+ To add an entry: run ``opencode-models boards``-style analysis (see the
22
+ backfill-scores skill), find the label the matcher cannot place, look at its
23
+ candidates, and write down the one you mean. If the candidates are genuinely
24
+ different models, leave the label out — an absent label is dropped on
25
+ purpose, which is better than a wrong merge.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ #: Board source host → {label as the board writes it: catalog model id}.
31
+ #: Ids are the canonical ones; a dated snapshot beside them is reached
32
+ #: through :func:`~opencode_models.boards.match_models`, which returns every
33
+ #: row of the matched model.
34
+ LABEL_MAPS: dict[str, dict[str, str]] = {
35
+ # Terminal-Bench drops the vendor prefix: "Opus 5.5", not "Claude".
36
+ "www.tbench.ai": {
37
+ "Fable 5": "anthropic/claude-fable-5",
38
+ "Fable 5.1": "anthropic/claude-fable-5-1",
39
+ "Opus 4.8": "anthropic/claude-opus-4-8",
40
+ "Opus 5": "anthropic/claude-opus-5",
41
+ "Opus 5.5": "anthropic/claude-opus-5-5",
42
+ "Sonnet 5": "anthropic/claude-sonnet-5",
43
+ "Sonnet 5.5": "anthropic/claude-sonnet-5-5",
44
+ },
45
+ # SWE-bench orders the version after the family, adds release tags, and
46
+ # on one label ties with a newer model family entirely.
47
+ "www.swebench.com": {
48
+ "Claude 3.5 Sonnet": "anthropic/claude-3-5-sonnet-20241022",
49
+ "Claude Sonnet 3.5": "anthropic/claude-3-5-sonnet-20241022",
50
+ "claude-3-5-sonnet-20241022": "anthropic/claude-3-5-sonnet-20241022",
51
+ "DevStral Small 2507": "mistral/devstral-small-2507",
52
+ "Devstral (2512)": "mistral/devstral-2512",
53
+ "Gemini 3 Pro": "google/gemini-3-pro-preview",
54
+ "Llama 4 Maverick Instruct": "meta/llama-4-maverick-17b-instruct",
55
+ "Llama 4 Scout Instruct": "meta/llama-4-scout-17b-instruct",
56
+ # "GPT-3.5" fits gpt-3.5-turbo and gpt-5.3-codex equally by token
57
+ # count; it means the turbo, which the 0.4% resolve rate confirms.
58
+ "GPT-3.5": "openai/gpt-3.5-turbo",
59
+ },
60
+ }
61
+
62
+ #: Labels a human looked at and left unmapped, with the reason. Kept so the
63
+ #: same question is not re-litigated on every fetch — an unmapped label
64
+ #: drops its score, which is the correct outcome when the board's label
65
+ #: genuinely fits several models.
66
+ UNRESOLVED: dict[str, dict[str, str]] = {
67
+ "www.swebench.com": {
68
+ "Gemini 3 Flash": "fits 3-flash-preview, 3.5, 3.6 and 3.7 alike; "
69
+ "the board does not say which",
70
+ "Kimi K2": "fits k2-instruct, k2-thinking, k2.5 and k2.6 alike",
71
+ },
72
+ }
73
+
74
+
75
+ def mapped_id(source: str, label: str) -> str | None:
76
+ """The catalog id *source* uses for *label*, if a human decided one."""
77
+ host = _host(source)
78
+ return LABEL_MAPS.get(host, {}).get(label)
79
+
80
+
81
+ def _host(source: str) -> str:
82
+ """The host of a source URL, ignoring scheme and ``www.`` differences."""
83
+ text = (source or "").split("://", 1)[-1]
84
+ return text.split("/", 1)[0].lower()
@@ -0,0 +1,88 @@
1
+ """Live availability from the Console config API.
2
+
3
+ ``GET /api/v2/config`` answers with the workspace's live model set, per
4
+ provider — the same plans the docs describe, but as the API serves them
5
+ right now rather than as the pricing pages last said. The docs snapshot
6
+ (``tier_prices/``) stays the offline fallback; this layer wins wherever it
7
+ exists, because the API is what the next request is actually checked
8
+ against.
9
+
10
+ Reading needs a service-account key (``OPENCODE_API_KEY``); the caller
11
+ builds the authed transport, so this module never touches the environment.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ #: The workspace's live model set, with tiered cost arrays and limits.
21
+ LIVE_CONFIG_URL = "https://opencode.ai/console/api/v2/config"
22
+
23
+ #: Config providers to console tiers. Anything else the API lists is not a
24
+ #: tier this tool ranks, so it is ignored rather than guessed at.
25
+ LIVE_TIERS = {"opencode": "zen", "opencode-go": "go"}
26
+
27
+ #: Where ``fetch`` snapshots the live set; ``models`` reads it offline.
28
+ SNAPSHOT_DIR = "available"
29
+
30
+
31
+ def fetch_live(get) -> dict[str, set[str]]:
32
+ """Model slugs each tier offers right now, keyed by tier.
33
+
34
+ *get* is an authed ``(url) -> str`` callable (the endpoint is
35
+ person-free but key-gated). Unknown providers are ignored; a provider
36
+ with no models entry counts as offering nothing, not as unknown.
37
+ """
38
+ payload = json.loads(get(LIVE_CONFIG_URL))
39
+ providers = payload.get("providers") or {}
40
+ return {
41
+ tier: set((providers.get(name) or {}).get("models") or {})
42
+ for name, tier in LIVE_TIERS.items()
43
+ }
44
+
45
+
46
+ def save_live(data_dir: Path, live: dict[str, set[str]]) -> list[str]:
47
+ """Snapshot the live set to ``available/<tier>.json``; returns files."""
48
+ saved = []
49
+ for tier, slugs in live.items():
50
+ directory = Path(data_dir) / SNAPSHOT_DIR
51
+ directory.mkdir(parents=True, exist_ok=True)
52
+ path = directory / f"{tier}.json"
53
+ path.write_text(
54
+ json.dumps(
55
+ {
56
+ "meta": {
57
+ "source": LIVE_CONFIG_URL,
58
+ "fetched_at": datetime.now(timezone.utc).isoformat(
59
+ timespec="seconds"
60
+ ),
61
+ },
62
+ "models": sorted(slugs),
63
+ },
64
+ indent=1,
65
+ )
66
+ )
67
+ saved.append(path.name)
68
+ return saved
69
+
70
+
71
+ def load_live(data_dir: Path) -> dict[str, set[str] | None]:
72
+ """The live snapshot, by tier; None where there is none.
73
+
74
+ None means "never fetched live", not "the plan offers nothing".
75
+ """
76
+ out = {}
77
+ for tier in sorted(LIVE_TIERS.values()):
78
+ path = Path(data_dir) / SNAPSHOT_DIR / f"{tier}.json"
79
+ if not path.exists():
80
+ out[tier] = None
81
+ continue
82
+ try:
83
+ models = json.loads(path.read_text()).get("models")
84
+ except (json.JSONDecodeError, OSError, AttributeError):
85
+ out[tier] = None
86
+ continue
87
+ out[tier] = set(models) if isinstance(models, list) else None
88
+ return out