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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""A model's score, on one scale the whole catalog can be compared on.
|
|
2
|
+
|
|
3
|
+
The raw benchmarks are not comparable:Terminal-Bench 2.1 runs 25–90,
|
|
4
|
+
Terminal-Bench 4.0 runs 16–65, SWE-bench Verified 35–96. Putting them in one
|
|
5
|
+
column and sorting on the result compares a 4.0 score against a 2.1 score
|
|
6
|
+
and calls the winner the better model.
|
|
7
|
+
|
|
8
|
+
So each benchmark gets its own 0–100 scale — its best model in this catalog
|
|
9
|
+
is 100, its worst is 0, and the distance between two models is proportionate
|
|
10
|
+
and linear in the raw score — and a model's score is the **median** of the
|
|
11
|
+
scales it has.
|
|
12
|
+
|
|
13
|
+
The median rather than the mean because models here carry wildly uneven
|
|
14
|
+
coverage: some have one benchmark, some have thirty. An average lets a model
|
|
15
|
+
with one good score outrank a frontier model that was evaluated properly,
|
|
16
|
+
and it makes the number swing as unrelated benchmarks appear. The median
|
|
17
|
+
answers "how does this model typically do", and one bad result cannot bury a
|
|
18
|
+
model that is strong on everything else.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from statistics import median
|
|
24
|
+
|
|
25
|
+
from .data import Model
|
|
26
|
+
from .overrides import TIER_OFFICIAL, source_tier
|
|
27
|
+
|
|
28
|
+
#: A benchmark every model scored the same on carries no information, so it
|
|
29
|
+
#: normalises to the midpoint rather than to 100.
|
|
30
|
+
NEUTRAL = 50.0
|
|
31
|
+
|
|
32
|
+
#: How much of a benchmark's numbers must be checkable before they are
|
|
33
|
+
#: allowed to set the ends of its scale.
|
|
34
|
+
#:
|
|
35
|
+
#: The rule that a vendor self-report cannot set the floor exists to stop one
|
|
36
|
+
#: uncited number from compressing everyone else's score. Applied blindly it
|
|
37
|
+
#: causes the opposite fault: on Terminal-Bench 2.1 only 8 of 50 numbers
|
|
38
|
+
#: come from a board, and scaling the axis to those turned it into a
|
|
39
|
+
#: threshold - bounds of 64.3-73.4, so nearly every model clamped to 0 or
|
|
40
|
+
#: 100 and the primary axis stopped discriminating. A scale drawn from a
|
|
41
|
+
#: small minority is a threshold, not a scale, so the ends stand aside until
|
|
42
|
+
#: the checkable numbers are a real share of the field.
|
|
43
|
+
CHECKABLE_SHARE = 0.25
|
|
44
|
+
|
|
45
|
+
#: How many benchmarks a model needs before its score means anything.
|
|
46
|
+
#:
|
|
47
|
+
#: The median of one number is that number. A model measured on one
|
|
48
|
+
#: benchmark it happens to lead reads as a top model on the strength of a
|
|
49
|
+
#: single result - in the live catalog 26 of 63 scored models have one
|
|
50
|
+
#: benchmark and 6 of the top 9 rows are among them, one of them leading the
|
|
51
|
+
#: whole catalog on a lone Terminal-Bench 4.0 run. Two independent axes is
|
|
52
|
+
#: the smallest amount of evidence that is not one measurement, and it costs
|
|
53
|
+
#: coverage deliberately: a model with one number is unscored, not ranked
|
|
54
|
+
#: last.
|
|
55
|
+
MIN_AXES = 2
|
|
56
|
+
|
|
57
|
+
#: The benchmarks this tool scores on, as an explicit list.
|
|
58
|
+
#:
|
|
59
|
+
#: Everything else the catalog mentions is context, not signal. The catalog
|
|
60
|
+
#: carries 212 benchmark names, of which 137 are held by one or two models -
|
|
61
|
+
#: a niche evaluation nobody else ran. Counted, they hand a model a hard 0
|
|
62
|
+
#: (the worst of four is 0) and drag its median down: a model resolving 82 on
|
|
63
|
+
#: SWE-bench Verified was landing on 0 because of a 17.7 on SEC-Bench Pro.
|
|
64
|
+
#:
|
|
65
|
+
#: A prefix rule was the obvious way to write this and it was wrong in both
|
|
66
|
+
#: directions. Matching "Terminal-Bench" also swept in "Terminal-Bench Hard",
|
|
67
|
+
#: "-Science" and 3.0, which are separate task sets rather than runs of the
|
|
68
|
+
#: benchmark; matching "SWE-Bench" admitted Lite, Multimodal and
|
|
69
|
+
#: Multilingual. Each of those opens another axis in the scoring equation
|
|
70
|
+
#: while adding no model that could not otherwise be scored: Lite and
|
|
71
|
+
#: Multimodal reach no tier model at all, Multilingual is only ever carried
|
|
72
|
+
#: alongside Verified or Pro, and 3.0 has two. Naming the benchmarks instead
|
|
73
|
+
#: keeps adding one a deliberate act, with a reason attached.
|
|
74
|
+
#:
|
|
75
|
+
#: An older revision is left out as well. Terminal-Bench 2.0 is 2.1 with an
|
|
76
|
+
#: easier, older task set, and a public task set gets trained against: a
|
|
77
|
+
#: vendor can publish a 2.0 number without the model being able to do the
|
|
78
|
+
#: work. Keeping it costs no coverage either - the five tier models whose
|
|
79
|
+
#: only Terminal-Bench run is a 2.0 all carry SWE-bench - so there is
|
|
80
|
+
#: nothing to weigh against it. Elo is left out too: it is preference votes
|
|
81
|
+
#: on chat answers rather than task completion, and no model in the catalog
|
|
82
|
+
#: carries it.
|
|
83
|
+
#:
|
|
84
|
+
#: DeepSWE 1.1 earns its place by being the only thing that pairs with the
|
|
85
|
+
#: rest. It is the broadest benchmark carried by models that hold nothing
|
|
86
|
+
#: else scored - nine of the 25 offered models on a single axis carry it -
|
|
87
|
+
#: so admitting it is what makes those models rankable at all. It is also
|
|
88
|
+
#: the weakest entry on provenance and on signal, and both facts are worth
|
|
89
|
+
#: stating plainly: every one of its 29 numbers is a vendor self-report (no
|
|
90
|
+
#: board runs it), and its rank correlation with Terminal-Bench 2.1 over the
|
|
91
|
+
#: models carrying both is 0.87, so it mostly re-ranks what TB 2.1 already
|
|
92
|
+
#: says rather than adding an independent read. A median over five
|
|
93
|
+
#: benchmarks therefore leans on TB 2.1 twice.
|
|
94
|
+
#:
|
|
95
|
+
#: The bare "DeepSWE" name stays out: it shares no model with "DeepSWE 1.1"
|
|
96
|
+
#: in the catalog, so it is an older revision carrying four models.
|
|
97
|
+
#:
|
|
98
|
+
#: The order is also the order the benchmarks are shown in: the axis the
|
|
99
|
+
#: console ranks on first, then the axes that fill in.
|
|
100
|
+
_TRACKED = (
|
|
101
|
+
"Terminal-Bench 2.1",
|
|
102
|
+
"Terminal-Bench 4.0",
|
|
103
|
+
"SWE-Bench Verified",
|
|
104
|
+
"SWE-Bench Pro",
|
|
105
|
+
"DeepSWE 1.1",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _folded(name: str) -> str:
|
|
110
|
+
"""The benchmark name with dashes read as spaces, whatever the registry.
|
|
111
|
+
|
|
112
|
+
Two registries spell these both ways ("Terminal-Bench 2.1",
|
|
113
|
+
"Terminal Bench 2.1") for one benchmark, and they have to land on one
|
|
114
|
+
scale and in one column rather than in two near-empty ones.
|
|
115
|
+
"""
|
|
116
|
+
return name.replace("-", " ")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_TRACKED_FOLDED = frozenset(_folded(name) for name in _TRACKED)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def tracked(name: str) -> bool:
|
|
123
|
+
"""Whether *name* is one of the benchmarks we score on."""
|
|
124
|
+
return _folded(name) in _TRACKED_FOLDED
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def canonical(name: str) -> str:
|
|
128
|
+
"""*name* with the dash and space spellings folded into one benchmark.
|
|
129
|
+
|
|
130
|
+
Labs spell these both ways - "Terminal-Bench 2.1" and "Terminal Bench 2.1"
|
|
131
|
+
are one benchmark from two registries - and they belong side by side in
|
|
132
|
+
one column rather than in two near-empty ones. Untracked names are
|
|
133
|
+
returned untouched, which is what keeps "Terminal-Bench-Science 0.1" out
|
|
134
|
+
of Terminal-Bench's column.
|
|
135
|
+
"""
|
|
136
|
+
return _folded(name) if tracked(name) else name
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def scales(models: list[Model]) -> dict[str, tuple[float, float]]:
|
|
140
|
+
"""The 0-100 range of each tracked benchmark, ``(lowest, best)``.
|
|
141
|
+
|
|
142
|
+
Over the models that actually carry it: the best model in this catalog
|
|
143
|
+
is 100 and the lowest is 0, so the ends move when the catalog does.
|
|
144
|
+
|
|
145
|
+
The ends are set by numbers someone else can check - an official board
|
|
146
|
+
or an independent evaluator - and not by vendor self-reports. Min-max is
|
|
147
|
+
only as good as its extremes: with every number counted, one uncited
|
|
148
|
+
1.5 from a launch post was the floor of the Terminal-Bench 4.0 scale, so
|
|
149
|
+
64.85 at the top and 1.5 at the bottom stretched the range and
|
|
150
|
+
compressed every model's derived score into the middle. A benchmark with
|
|
151
|
+
no board or independent number at all keeps its own ends, since
|
|
152
|
+
tightening them would leave those models scored against nothing.
|
|
153
|
+
|
|
154
|
+
A benchmark is keyed by its own name, but its bounds are pooled across
|
|
155
|
+
the spellings registries use for it - one benchmark means one scale, so
|
|
156
|
+
a model measured by one registry is not the worst of a pair with a model
|
|
157
|
+
measured by another.
|
|
158
|
+
"""
|
|
159
|
+
all_values: dict[str, list[float]] = {}
|
|
160
|
+
checkable: dict[str, list[float]] = {}
|
|
161
|
+
names: set[str] = set()
|
|
162
|
+
for model in models:
|
|
163
|
+
for name, value in model.scores.items():
|
|
164
|
+
if not (tracked(name) and isinstance(value, (int, float))):
|
|
165
|
+
continue
|
|
166
|
+
key = canonical(name)
|
|
167
|
+
names.add(name)
|
|
168
|
+
all_values.setdefault(key, []).append(float(value))
|
|
169
|
+
if source_tier(model.sources.get(name)) <= TIER_OFFICIAL:
|
|
170
|
+
checkable.setdefault(key, []).append(float(value))
|
|
171
|
+
tight = {
|
|
172
|
+
key: (min(values), max(values))
|
|
173
|
+
for key, values in checkable.items()
|
|
174
|
+
if len(values) >= CHECKABLE_SHARE * len(all_values[key])
|
|
175
|
+
}
|
|
176
|
+
bounds = {
|
|
177
|
+
key: tight.get(key) or (min(values), max(values))
|
|
178
|
+
for key, values in all_values.items()
|
|
179
|
+
}
|
|
180
|
+
return {name: bounds[canonical(name)] for name in sorted(names)}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def normalise(score: float, bounds: tuple[float, float]) -> float:
|
|
184
|
+
"""One raw score onto its benchmark's 0–100 scale.
|
|
185
|
+
|
|
186
|
+
Linear and clamped: a score outside the catalog's range — a benchmark
|
|
187
|
+
fetched after the snapshot, a vendor's own figure — reads 0 or 100
|
|
188
|
+
rather than extrapolating past the ends.
|
|
189
|
+
"""
|
|
190
|
+
floor, ceiling = bounds
|
|
191
|
+
if ceiling <= floor:
|
|
192
|
+
return NEUTRAL
|
|
193
|
+
pct = (score - floor) / (ceiling - floor) * 100.0
|
|
194
|
+
return min(100.0, max(0.0, pct))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def derived_scores(
|
|
198
|
+
model: Model, table: dict[str, tuple[float, float]]
|
|
199
|
+
) -> dict[str, float]:
|
|
200
|
+
"""Each benchmark we score on that this model carries, on its 0–100 scale.
|
|
201
|
+
|
|
202
|
+
Benchmarks outside the shortlist never appear: they are not in *table*,
|
|
203
|
+
which ``scales`` builds from ``_TRACKED`` alone. A benchmark with no
|
|
204
|
+
spread across the catalog is left out too - it cannot say anything about
|
|
205
|
+
the model, and counting it at the midpoint would pull a real median
|
|
206
|
+
toward the middle for no reason.
|
|
207
|
+
"""
|
|
208
|
+
return {
|
|
209
|
+
name: normalise(value, bounds)
|
|
210
|
+
for name, value in model.scores.items()
|
|
211
|
+
if name in table
|
|
212
|
+
and isinstance(value, (int, float))
|
|
213
|
+
and (bounds := table[name])[1] > bounds[0]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def combined(derived: dict[str, float]) -> float | None:
|
|
218
|
+
"""The model's score: the median of its derived scores, or None.
|
|
219
|
+
|
|
220
|
+
None below ``MIN_AXES``, which is what makes a thinly measured model
|
|
221
|
+
unranked rather than ranked on one number.
|
|
222
|
+
"""
|
|
223
|
+
if len(derived) < MIN_AXES:
|
|
224
|
+
return None
|
|
225
|
+
return median(derived.values())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def score_of(model: Model, table: dict[str, tuple[float, float]]) -> float | None:
|
|
229
|
+
""":func:`combined` of :func:`derived_scores`, in one call."""
|
|
230
|
+
return combined(derived_scores(model, table))
|
opencode_models/seed.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Seed a reference dataset on install, and deploy pushed curation.
|
|
2
|
+
|
|
3
|
+
A fresh `uv tool install` leaves the XDG data dir empty (or stale from an
|
|
4
|
+
older fetch): the catalog lives in no branch at all, and a bare
|
|
5
|
+
`opencode-models list` then silently disagrees with `just run list`. Seeding
|
|
6
|
+
copies reference files into the target directory for files that are
|
|
7
|
+
*missing* only - an existing file is never overwritten, so a workspace's
|
|
8
|
+
own fetch results are always safe.
|
|
9
|
+
|
|
10
|
+
`data/overrides/` plays by a different rule on purpose: it is versioned
|
|
11
|
+
curation, not fetched records, so installing newer code must bring newer
|
|
12
|
+
pins. Same-named override files sync from the reference (the target's stale
|
|
13
|
+
pin is the bug being fixed); local files the reference never shipped are
|
|
14
|
+
left alone, and fetched layers stay missing-only whatever happens.
|
|
15
|
+
|
|
16
|
+
Reference precedence per file: the repo working tree first, then the
|
|
17
|
+
`local-data` branch. The branch is read with `ls-tree`/`show`, so the
|
|
18
|
+
checkout is never switched.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
#: Data subdirectories worth seeding. Everything fetched belongs here;
|
|
27
|
+
#: nothing curated is ever written by `fetch`, so seeding it is safe.
|
|
28
|
+
SEED_SUBDIRS = (
|
|
29
|
+
"opencode",
|
|
30
|
+
"tier_prices",
|
|
31
|
+
"boards",
|
|
32
|
+
"aiarena",
|
|
33
|
+
"usage",
|
|
34
|
+
"available",
|
|
35
|
+
"overrides",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
BRANCH = "local-data"
|
|
39
|
+
PREFIX = "data/"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def copy_missing(src: Path, dst: Path) -> tuple[int, int]:
|
|
43
|
+
"""Copy files under *src* to *dst* where *dst* lacks them.
|
|
44
|
+
|
|
45
|
+
Returns ``(copied, skipped)``. A missing source is not an error -
|
|
46
|
+
a fresh clone simply has nothing to seed from.
|
|
47
|
+
"""
|
|
48
|
+
src, dst = Path(src), Path(dst)
|
|
49
|
+
if not src.is_dir():
|
|
50
|
+
return (0, 0)
|
|
51
|
+
copied = skipped = 0
|
|
52
|
+
for path in sorted(src.rglob("*")):
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
continue
|
|
55
|
+
dest = dst / path.relative_to(src)
|
|
56
|
+
if dest.exists():
|
|
57
|
+
skipped += 1
|
|
58
|
+
continue
|
|
59
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
dest.write_bytes(path.read_bytes())
|
|
61
|
+
copied += 1
|
|
62
|
+
return (copied, skipped)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_branch_files(repo: Path, branch: str, prefix: str) -> dict[str, str]:
|
|
66
|
+
"""``{path: text}`` for files under *prefix* on *branch*, undecoded raw.
|
|
67
|
+
|
|
68
|
+
A missing branch (or a directory that is not a repo at all) reads as
|
|
69
|
+
empty rather than failing: seeding degrades to the working tree.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
names = subprocess.run(
|
|
73
|
+
["git", "ls-tree", "-r", "--name-only", branch, "--", prefix],
|
|
74
|
+
cwd=repo,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=True,
|
|
78
|
+
).stdout
|
|
79
|
+
except (subprocess.CalledProcessError, OSError):
|
|
80
|
+
return {}
|
|
81
|
+
files: dict[str, str] = {}
|
|
82
|
+
for name in names.splitlines():
|
|
83
|
+
name = name.strip()
|
|
84
|
+
if not name:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
text = subprocess.run(
|
|
88
|
+
["git", "show", f"{branch}:{name}"],
|
|
89
|
+
cwd=repo,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
check=True,
|
|
92
|
+
).stdout
|
|
93
|
+
except (subprocess.CalledProcessError, OSError):
|
|
94
|
+
continue
|
|
95
|
+
files[name] = text.decode("utf-8", errors="replace")
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sync_tracked(src: Path, dst: Path) -> set[str]:
|
|
100
|
+
"""Deploy curated files, returning the relative names written.
|
|
101
|
+
|
|
102
|
+
Same-named files sync (overwrite); local files the reference never
|
|
103
|
+
shipped are not even listed. Missing source deploys nothing.
|
|
104
|
+
"""
|
|
105
|
+
src, dst = Path(src), Path(dst)
|
|
106
|
+
written: set[str] = set()
|
|
107
|
+
if not src.is_dir():
|
|
108
|
+
return written
|
|
109
|
+
for path in sorted(src.rglob("*")):
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
continue
|
|
112
|
+
rel = path.relative_to(src)
|
|
113
|
+
dest = dst / rel
|
|
114
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
dest.write_bytes(path.read_bytes())
|
|
116
|
+
written.add(str(rel))
|
|
117
|
+
return written
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def seed(target: Path, repo: Path) -> dict[str, int]:
|
|
121
|
+
"""Seed *target* (a data dir) from *repo*.
|
|
122
|
+
|
|
123
|
+
Fetched layers copy missing files only; `overrides/` deploys (same-named
|
|
124
|
+
files sync). Working-tree reference data first, then the branch for
|
|
125
|
+
whatever the tree lacks. Returns
|
|
126
|
+
``{"copied": n, "skipped": m, "synced": k}``.
|
|
127
|
+
"""
|
|
128
|
+
target, repo = Path(target), Path(repo)
|
|
129
|
+
copied = skipped = synced = 0
|
|
130
|
+
for sub in SEED_SUBDIRS:
|
|
131
|
+
if sub == "overrides":
|
|
132
|
+
continue
|
|
133
|
+
c, s = copy_missing(repo / "data" / sub, target / sub)
|
|
134
|
+
copied += c
|
|
135
|
+
skipped += s
|
|
136
|
+
provided = sync_tracked(repo / "data" / "overrides", target / "overrides")
|
|
137
|
+
synced += len(provided)
|
|
138
|
+
for key, text in sorted(read_branch_files(repo, BRANCH, PREFIX).items()):
|
|
139
|
+
rel = key[len(PREFIX) :] if key.startswith(PREFIX) else key
|
|
140
|
+
dest = target / rel
|
|
141
|
+
if rel.startswith("overrides/"):
|
|
142
|
+
if rel[len("overrides/") :] in provided:
|
|
143
|
+
continue # the working tree already deployed this pin
|
|
144
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
dest.write_bytes(text.encode("utf-8"))
|
|
146
|
+
synced += 1
|
|
147
|
+
elif dest.exists():
|
|
148
|
+
skipped += 1
|
|
149
|
+
else:
|
|
150
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
dest.write_text(text)
|
|
152
|
+
copied += 1
|
|
153
|
+
return {"copied": copied, "skipped": skipped, "synced": synced}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _repo_root() -> Path:
|
|
157
|
+
"""The repo this seed runs from: git toplevel, else the cwd."""
|
|
158
|
+
try:
|
|
159
|
+
out = subprocess.run(
|
|
160
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
check=True,
|
|
164
|
+
).stdout
|
|
165
|
+
except (subprocess.CalledProcessError, OSError):
|
|
166
|
+
return Path.cwd()
|
|
167
|
+
return Path(out.strip() or ".")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main() -> int:
|
|
171
|
+
from .cli import default_data_dir
|
|
172
|
+
|
|
173
|
+
target = default_data_dir()
|
|
174
|
+
result = seed(target, _repo_root())
|
|
175
|
+
print(
|
|
176
|
+
f"seeded {result['copied']} file(s) and deployed "
|
|
177
|
+
f"{result['synced']} override(s) into {target} "
|
|
178
|
+
f"({result['skipped']} already present)"
|
|
179
|
+
)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
minimax-m3
|
|
2
|
+
minimax-m2.7
|
|
3
|
+
minimax-m2.5
|
|
4
|
+
kimi-k3
|
|
5
|
+
kimi-k2.7-code
|
|
6
|
+
kimi-k2.6
|
|
7
|
+
longcat-2.0
|
|
8
|
+
kimi-k2.5
|
|
9
|
+
glm-5.2
|
|
10
|
+
glm-5.3-flash
|
|
11
|
+
glm-5.3
|
|
12
|
+
glm-5.1
|
|
13
|
+
glm-5
|
|
14
|
+
deepseek-v4-pro
|
|
15
|
+
deepseek-v4-flash
|
|
16
|
+
deepseek-flash
|
|
17
|
+
deepseek-v4.1-flash
|
|
18
|
+
deepseek-v4-flash-vision-exp
|
|
19
|
+
qwen3.7-max
|
|
20
|
+
qwen3.8-max
|
|
21
|
+
qwen3.8-flash
|
|
22
|
+
qwen3.7-plus
|
|
23
|
+
qwen3.6-plus
|
|
24
|
+
qwen3.5-plus
|
|
25
|
+
mimo-v2-pro
|
|
26
|
+
mimo-v2-omni
|
|
27
|
+
mimo-v2.6-pro
|
|
28
|
+
mimo-v2.6-flash
|
|
29
|
+
longcat-2.5-preview-free
|
|
30
|
+
step-5-preview-free
|
|
31
|
+
mimo-v2.5-pro
|
|
32
|
+
mimo-v2.5
|
|
33
|
+
hy4-preview
|
|
34
|
+
hy3
|
|
35
|
+
hy3-preview
|
|
36
|
+
claude-haiku-5-5
|
|
37
|
+
gpt-5.6-luna
|
|
38
|
+
grok-4.5
|
|
39
|
+
grok-4.7
|
|
40
|
+
grok-4.6
|
|
41
|
+
muse-spark-1.3-contributor
|
|
42
|
+
muse-spark-1.2-contributor
|
|
43
|
+
omen-alpha
|
|
44
|
+
gpt-6-luna
|
|
45
|
+
space-bunny
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
claude-fable-5
|
|
2
|
+
claude-fable-5-1
|
|
3
|
+
claude-opus-5-5
|
|
4
|
+
claude-opus-5
|
|
5
|
+
claude-opus-4-8
|
|
6
|
+
claude-opus-4-7
|
|
7
|
+
claude-opus-4-6
|
|
8
|
+
claude-opus-4-5
|
|
9
|
+
claude-sonnet-5-5
|
|
10
|
+
claude-sonnet-5
|
|
11
|
+
claude-sonnet-4-6
|
|
12
|
+
claude-sonnet-4-5
|
|
13
|
+
claude-sonnet-4
|
|
14
|
+
claude-haiku-5-5
|
|
15
|
+
claude-haiku-4-5
|
|
16
|
+
gemini-3.6-flash
|
|
17
|
+
gemini-3.8-flash
|
|
18
|
+
gemini-3.7-flash
|
|
19
|
+
gemini-3.5-flash-lite
|
|
20
|
+
gemini-3.5-flash
|
|
21
|
+
gemini-3.1-pro
|
|
22
|
+
gemini-3-flash
|
|
23
|
+
gpt-6-astra
|
|
24
|
+
gpt-6.1-sol
|
|
25
|
+
gpt-6-sol
|
|
26
|
+
gpt-6-luna
|
|
27
|
+
gpt-5.6-sol
|
|
28
|
+
gpt-5.6-terra
|
|
29
|
+
gpt-5.6-luna
|
|
30
|
+
gpt-5.5
|
|
31
|
+
gpt-5.5-pro
|
|
32
|
+
gpt-5.4
|
|
33
|
+
gpt-5.4-pro
|
|
34
|
+
gpt-5.4-mini
|
|
35
|
+
gpt-5.4-nano
|
|
36
|
+
gpt-5.3-codex-spark
|
|
37
|
+
gpt-5.3-codex
|
|
38
|
+
gpt-5.2
|
|
39
|
+
gpt-5.2-codex
|
|
40
|
+
gpt-5.1
|
|
41
|
+
gpt-5.1-codex-max
|
|
42
|
+
gpt-5.1-codex
|
|
43
|
+
gpt-5.1-codex-mini
|
|
44
|
+
gpt-5
|
|
45
|
+
gpt-5-codex
|
|
46
|
+
gpt-5-nano
|
|
47
|
+
grok-build-0.1
|
|
48
|
+
grok-4.7
|
|
49
|
+
grok-4.6
|
|
50
|
+
grok-4.5
|
|
51
|
+
muse-spark-1.3
|
|
52
|
+
muse-spark-1.2
|
|
53
|
+
mistral-large-4
|
|
54
|
+
deepseek-v4.1-flash
|
|
55
|
+
deepseek-v4-pro
|
|
56
|
+
deepseek-v4-flash
|
|
57
|
+
deepseek-v4-flash-vision-exp
|
|
58
|
+
glm-5.3-flash
|
|
59
|
+
glm-5.3
|
|
60
|
+
glm-5.2
|
|
61
|
+
glm-5.1
|
|
62
|
+
glm-5
|
|
63
|
+
minimax-m3
|
|
64
|
+
minimax-m2.7
|
|
65
|
+
minimax-m2.5
|
|
66
|
+
kimi-k3
|
|
67
|
+
kimi-k2.7-code
|
|
68
|
+
kimi-k2.6
|
|
69
|
+
kimi-k2.5
|
|
70
|
+
qwen3.8-flash
|
|
71
|
+
qwen3.6-plus
|
|
72
|
+
qwen3.5-plus
|
|
73
|
+
jev-1.13
|
|
74
|
+
big-pickle
|
|
75
|
+
jev-1.13-free
|
|
76
|
+
exo-free
|
|
77
|
+
muse-spark-1.3-contributor-free
|
|
78
|
+
muse-spark-1.2-contributor-free
|
|
79
|
+
mimo-v2.6-flash-free
|
|
80
|
+
space-bunny-free
|
|
81
|
+
longcat-2.5-preview-free
|
|
82
|
+
step-5-preview-free
|
|
83
|
+
ling-3.0-flash-fin-free
|
|
84
|
+
nemotron-3-ultra-free
|
|
85
|
+
nemotron-3.5-lightning-free
|
|
86
|
+
qwen3.8-max
|
|
87
|
+
ling-3.1-flash-free
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Membership lists for the OpenCode Console tiers (zen = curated, go = open).
|
|
2
|
+
|
|
3
|
+
Slugs are bare model ids (``claude-opus-5``) as listed on the tier pages,
|
|
4
|
+
shipped as text files beside this module. Matching against the catalog
|
|
5
|
+
happens on the slug (last path segment of ``lab/model``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .data import Model
|
|
13
|
+
|
|
14
|
+
_DIR = Path(__file__).resolve().parent / "sources"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def available_sources() -> list[str]:
|
|
18
|
+
"""Tier names that have a shipped membership file."""
|
|
19
|
+
return sorted(p.stem for p in _DIR.glob("*.txt"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_source(name: str) -> set[str]:
|
|
23
|
+
"""Slugs of models in tier *name*; raises KeyError for unknown tiers."""
|
|
24
|
+
path = _DIR / f"{name}.txt"
|
|
25
|
+
if not path.exists():
|
|
26
|
+
raise KeyError(name)
|
|
27
|
+
return {line.strip() for line in path.read_text().splitlines() if line.strip()}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def members(
|
|
31
|
+
models: list[Model], slugs: set[str] | None
|
|
32
|
+
) -> tuple[list[Model], set[str]]:
|
|
33
|
+
"""Tier membership: the models in a tier, plus which of them are free.
|
|
34
|
+
|
|
35
|
+
The tier pages list free variants under their own slug
|
|
36
|
+
(``mimo-v2.6-flash-free``, ``step-5-preview-free``) while the catalog
|
|
37
|
+
carries one model under the base slug, so ``X-free`` has to reach ``X``.
|
|
38
|
+
A member reached through a ``-free`` slug — or priced at zero — is free.
|
|
39
|
+
"""
|
|
40
|
+
if slugs is None: # `--source all`: no tier filter
|
|
41
|
+
return (list(models), {m.model_id for m in models if m.price_input == 0})
|
|
42
|
+
included: list[Model] = []
|
|
43
|
+
free: set[str] = set()
|
|
44
|
+
for m in models:
|
|
45
|
+
slug = m.model_id.split("/")[-1]
|
|
46
|
+
if slug in slugs or m.model_id in slugs:
|
|
47
|
+
matched_free = False
|
|
48
|
+
elif f"{slug}-free" in slugs:
|
|
49
|
+
matched_free = True
|
|
50
|
+
else:
|
|
51
|
+
continue
|
|
52
|
+
included.append(m)
|
|
53
|
+
if matched_free or m.price_input == 0:
|
|
54
|
+
free.add(m.model_id)
|
|
55
|
+
return included, free
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Aligned text tables from declarative input.
|
|
2
|
+
|
|
3
|
+
Every console view was hand-rolling the same loop: measure the widest
|
|
4
|
+
cell per column (header included), pad, join, print — with the widths
|
|
5
|
+
and the alignment re-decided at each site, so a wide cell silently
|
|
6
|
+
broke the grid instead of widening it. These helpers take headers plus
|
|
7
|
+
rows of plain strings and return aligned text; the views only declare
|
|
8
|
+
*what* goes in each column, never how wide it is.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def column_widths(
|
|
17
|
+
headers: Sequence[str],
|
|
18
|
+
rows: Sequence[Sequence[str]],
|
|
19
|
+
min_widths: Sequence[int] = (),
|
|
20
|
+
) -> list[int]:
|
|
21
|
+
"""The display width of each column: header, cells and floor combined."""
|
|
22
|
+
count = max([len(headers)] + [len(r) for r in rows] or [0])
|
|
23
|
+
widths = []
|
|
24
|
+
for i in range(count):
|
|
25
|
+
cells = [r[i] for r in rows if len(r) > i]
|
|
26
|
+
floor = min_widths[i] if i < len(min_widths) else 0
|
|
27
|
+
widths.append(
|
|
28
|
+
max(
|
|
29
|
+
[len(headers[i]) if i < len(headers) else 0, floor]
|
|
30
|
+
+ [len(c) for c in cells]
|
|
31
|
+
or [0]
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
return widths
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pad(cell: str, width: int, align: str) -> str:
|
|
38
|
+
return cell.ljust(width) if align == "<" else cell.rjust(width)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _aligns_for(aligns: str | Sequence[str], count: int) -> list[str]:
|
|
42
|
+
if isinstance(aligns, str):
|
|
43
|
+
return [aligns] * count
|
|
44
|
+
return list(aligns) + [">"] * (count - len(aligns))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_row(
|
|
48
|
+
cells: Sequence[str],
|
|
49
|
+
widths: Sequence[int],
|
|
50
|
+
aligns: str | Sequence[str] = ">",
|
|
51
|
+
gap: str = " ",
|
|
52
|
+
) -> str:
|
|
53
|
+
"""One grid line: padded cells joined, trailing padding stripped.
|
|
54
|
+
|
|
55
|
+
Short rows pad out with blanks; a row wider than the grid is a caller
|
|
56
|
+
bug and raises instead of silently dropping a column.
|
|
57
|
+
"""
|
|
58
|
+
if len(cells) > len(widths):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"row has {len(cells)} cells for {len(widths)} columns: {cells!r}"
|
|
61
|
+
)
|
|
62
|
+
padded = [cells[i] if i < len(cells) else "" for i in range(len(widths))]
|
|
63
|
+
return gap.join(
|
|
64
|
+
_pad(c, w, a)
|
|
65
|
+
for c, w, a in zip(
|
|
66
|
+
padded, widths, _aligns_for(aligns, len(widths)), strict=True
|
|
67
|
+
)
|
|
68
|
+
).rstrip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render(
|
|
72
|
+
headers: Sequence[str],
|
|
73
|
+
rows: Sequence[Sequence[str]],
|
|
74
|
+
aligns: str | Sequence[str] = ">",
|
|
75
|
+
header_aligns: str | Sequence[str] | None = None,
|
|
76
|
+
min_widths: Sequence[int] = (),
|
|
77
|
+
indent: str = "",
|
|
78
|
+
gap: str = " ",
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Headers plus rows as aligned text, one line each, no trailing newline.
|
|
81
|
+
|
|
82
|
+
Columns measure across the headers and every row, so a wide cell
|
|
83
|
+
widens its column instead of breaking the grid; *min_widths* holds
|
|
84
|
+
narrow columns open where a fixed layout is wanted. Short rows pad
|
|
85
|
+
out with blanks. Every line is *indent*ed and right-stripped.
|
|
86
|
+
"""
|
|
87
|
+
widths = column_widths(headers, rows, min_widths)
|
|
88
|
+
lines = [
|
|
89
|
+
format_row(
|
|
90
|
+
headers, widths, header_aligns if header_aligns is not None else aligns, gap
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
lines += [format_row(row, widths, aligns, gap) for row in rows]
|
|
94
|
+
return "\n".join(indent + line for line in lines)
|