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,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))
@@ -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)