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.
- opencode_models/__init__.py +8 -0
- opencode_models/__main__.py +5 -0
- opencode_models/arena.py +108 -0
- opencode_models/auth.py +48 -0
- opencode_models/availability.py +102 -0
- opencode_models/benchmarks.py +19 -0
- opencode_models/boards.py +518 -0
- opencode_models/cli.py +1172 -0
- opencode_models/data.py +82 -0
- opencode_models/fetch.py +101 -0
- opencode_models/identity.py +100 -0
- opencode_models/labelmap.py +84 -0
- opencode_models/live.py +88 -0
- opencode_models/overrides.py +252 -0
- opencode_models/pareto.py +34 -0
- opencode_models/pricing.py +415 -0
- opencode_models/ranking.py +591 -0
- opencode_models/scoring.py +230 -0
- opencode_models/seed.py +184 -0
- opencode_models/sources/go.txt +45 -0
- opencode_models/sources/zen.txt +87 -0
- opencode_models/sources.py +55 -0
- opencode_models/tables.py +94 -0
- opencode_models-0.1.0.dist-info/METADATA +544 -0
- opencode_models-0.1.0.dist-info/RECORD +28 -0
- opencode_models-0.1.0.dist-info/WHEEL +4 -0
- opencode_models-0.1.0.dist-info/entry_points.txt +2 -0
- opencode_models-0.1.0.dist-info/licenses/LICENSE +21 -0
opencode_models/data.py
ADDED
|
@@ -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
|
opencode_models/fetch.py
ADDED
|
@@ -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()
|
opencode_models/live.py
ADDED
|
@@ -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
|