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,8 @@
|
|
|
1
|
+
"""Ranked console of AI models: benchmark scores against real per-token prices."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("opencode-models")
|
|
7
|
+
except PackageNotFoundError: # bare checkout without an install
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
opencode_models/arena.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Arena.ai (LMArena) Elo scores, cached in data/ so every command except the
|
|
2
|
+
online lookups reads locally.
|
|
3
|
+
|
|
4
|
+
The live arena API is Cloudflare-blocked, so the lookup uses the community
|
|
5
|
+
copy on HuggingFace's datasets-server JSON API (no auth). Results are
|
|
6
|
+
folded into ``data/aiarena/scores.json`` — a {slug: elo} cache — and merged
|
|
7
|
+
into models at load time as an "LMArena Elo" benchmark axis.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .data import Model
|
|
16
|
+
|
|
17
|
+
CACHE_NAME = "scores.json"
|
|
18
|
+
ARENA_BENCHMARK = "LMArena Elo"
|
|
19
|
+
ROWS_URL = (
|
|
20
|
+
"https://datasets-server.huggingface.co/rows?dataset="
|
|
21
|
+
"openaccess-ai-collective/chatbot-arena-elo-scores&config=default"
|
|
22
|
+
"&split=train&offset=0&length=100"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArenaCache:
|
|
27
|
+
"""Persistent {model slug: Elo} cache backed by one JSON file."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, data_dir: Path):
|
|
30
|
+
self.path = Path(data_dir) / CACHE_NAME
|
|
31
|
+
|
|
32
|
+
def load(self) -> dict[str, float]:
|
|
33
|
+
if not self.path.exists():
|
|
34
|
+
return {}
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(self.path.read_text())
|
|
37
|
+
except (json.JSONDecodeError, OSError):
|
|
38
|
+
return {}
|
|
39
|
+
return data if isinstance(data, dict) else {}
|
|
40
|
+
|
|
41
|
+
def save(self, scores: dict[str, float]) -> None:
|
|
42
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
self.path.write_text(json.dumps(scores, indent=1, sort_keys=True) + "\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def lookup_arena_elo(get) -> dict[str, float]:
|
|
47
|
+
"""Fetch {slug: elo} from the datasets-server rows API (paginated).
|
|
48
|
+
|
|
49
|
+
*get* is the injected transport (``fetch.http_get`` in production).
|
|
50
|
+
"""
|
|
51
|
+
scores: dict[str, float] = {}
|
|
52
|
+
url: str | None = ROWS_URL
|
|
53
|
+
while url:
|
|
54
|
+
doc = json.loads(get(url))
|
|
55
|
+
for row in doc.get("rows", []):
|
|
56
|
+
r = row.get("row", {})
|
|
57
|
+
name = r.get("chatbot_name")
|
|
58
|
+
elo = r.get("elo_score")
|
|
59
|
+
if name and isinstance(elo, (int, float)):
|
|
60
|
+
scores[name] = float(elo)
|
|
61
|
+
url = doc.get("next_page_url")
|
|
62
|
+
return scores
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _match_key(m: Model) -> str:
|
|
66
|
+
"""Arena names are bare model ids (``deepseek-v4-pro``), so match on the
|
|
67
|
+
last path segment of our ``lab/model`` id, not the lab-qualified slug."""
|
|
68
|
+
return m.model_id.rsplit("/", 1)[-1]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def merge_arena_scores(models: list[Model], arena: dict[str, float]) -> list[Model]:
|
|
72
|
+
"""Attach LMArena Elo to models whose bare model id matches an arena
|
|
73
|
+
entry.
|
|
74
|
+
|
|
75
|
+
Only models that do not already carry an arena score are touched
|
|
76
|
+
(cache wins), and a missing price does not disqualify the merge — the
|
|
77
|
+
arena axis is usable for ranking even before pricing is known.
|
|
78
|
+
"""
|
|
79
|
+
result = []
|
|
80
|
+
for m in models:
|
|
81
|
+
key = _match_key(m)
|
|
82
|
+
if key in arena and ARENA_BENCHMARK not in m.scores:
|
|
83
|
+
result.append(merge_arena_score(m, arena[key]))
|
|
84
|
+
else:
|
|
85
|
+
result.append(m)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge_arena_score(m: Model, elo: float) -> Model:
|
|
90
|
+
"""Return a copy of *m* with the arena score attached."""
|
|
91
|
+
scores = dict(m.scores)
|
|
92
|
+
scores[ARENA_BENCHMARK] = elo
|
|
93
|
+
return Model(
|
|
94
|
+
model_id=m.model_id,
|
|
95
|
+
name=m.name,
|
|
96
|
+
lab=m.lab,
|
|
97
|
+
price_input=m.price_input,
|
|
98
|
+
price_output=m.price_output,
|
|
99
|
+
price_cached_input=m.price_cached_input,
|
|
100
|
+
scores=scores,
|
|
101
|
+
sources=dict(m.sources),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def merge_from_file(models: list[Model], data_dir: Path) -> list[Model]:
|
|
106
|
+
"""Model list with any cached arena scores applied."""
|
|
107
|
+
cache = ArenaCache(data_dir)
|
|
108
|
+
return merge_arena_scores(models, cache.load())
|
opencode_models/auth.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Local storage for the Console service-account key.
|
|
2
|
+
|
|
3
|
+
The key lives in one file under the user's config dir, readable only by
|
|
4
|
+
its owner — never in the repo, never in a data dir (a `--data` checkout
|
|
5
|
+
may be shared or committed). The environment always wins: CI and one-off
|
|
6
|
+
runs set `OPENCODE_API_KEY` without touching the file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def key_file() -> Path:
|
|
16
|
+
"""Where the saved key lives (XDG config home, per user)."""
|
|
17
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
18
|
+
return Path(base) / "opencode-models" / "api_key"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_key(key: str) -> Path:
|
|
22
|
+
"""Write *key* to the key file, owner-only, creating dirs as needed.
|
|
23
|
+
|
|
24
|
+
The file is created with 0o600 and an existing file is tightened to
|
|
25
|
+
it — whatever the umask says.
|
|
26
|
+
"""
|
|
27
|
+
path = key_file()
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
30
|
+
with os.fdopen(fd, "w") as handle:
|
|
31
|
+
handle.write(key)
|
|
32
|
+
os.chmod(path, 0o600)
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_key() -> str | None:
|
|
37
|
+
"""The service-account key: the environment first, then the file.
|
|
38
|
+
|
|
39
|
+
None when neither has one. The file's content is stripped — a
|
|
40
|
+
trailing newline from an editor is not part of the key.
|
|
41
|
+
"""
|
|
42
|
+
env = os.environ.get("OPENCODE_API_KEY")
|
|
43
|
+
if env:
|
|
44
|
+
return env
|
|
45
|
+
try:
|
|
46
|
+
return key_file().read_text().strip() or None
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Which models the plans actually offer right now.
|
|
2
|
+
|
|
3
|
+
The membership lists under ``opencode_models/sources/`` are committed text,
|
|
4
|
+
hand-maintained, and they drift in both directions: a model the provider
|
|
5
|
+
unlisted stays in the list, and a newly offered model is missing from it. In
|
|
6
|
+
the live data 13 Go slugs were still being offered to the console after the
|
|
7
|
+
provider's own docs stopped listing them, two of them models we were actively
|
|
8
|
+
trying to benchmark.
|
|
9
|
+
|
|
10
|
+
``fetch`` already snapshots the tier documentation for prices, and every row
|
|
11
|
+
of that snapshot names the model the provider is offering. That snapshot is
|
|
12
|
+
the availability record, so it is what this module reads: the same source,
|
|
13
|
+
the same refresh, and current by construction.
|
|
14
|
+
|
|
15
|
+
A tier with no snapshot is *unknown*, not empty. Restricting the console to
|
|
16
|
+
an empty set because nobody has fetched a snapshot would hide everything, and
|
|
17
|
+
the shipped lists are still the best answer available offline - so the
|
|
18
|
+
callers treat unknown as "no restriction" and the console says so.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import sources
|
|
27
|
+
from .live import load_live
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _snapshot_ids(data_dir: Path, tier: str) -> set[str] | None:
|
|
31
|
+
"""Model ids the provider's tier snapshot lists, or None if unknown."""
|
|
32
|
+
path = Path(data_dir) / "tier_prices" / f"{tier}_prices.json"
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
rows = json.loads(path.read_text())
|
|
37
|
+
except (json.JSONDecodeError, OSError):
|
|
38
|
+
return None
|
|
39
|
+
if not isinstance(rows, list):
|
|
40
|
+
return None
|
|
41
|
+
return {
|
|
42
|
+
row["model_id"] for row in rows if isinstance(row, dict) and row.get("model_id")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_available(data_dir: Path) -> dict[str, set[str] | None]:
|
|
47
|
+
"""Model ids each plan offers, by tier; None where there is no snapshot.
|
|
48
|
+
|
|
49
|
+
None means "we have not fetched this tier", which is different from an
|
|
50
|
+
empty set, which means "the plan offers nothing". The live API snapshot
|
|
51
|
+
wins wherever it exists — it is what the next request is checked
|
|
52
|
+
against; the docs snapshot stays the offline fallback.
|
|
53
|
+
"""
|
|
54
|
+
live = load_live(data_dir)
|
|
55
|
+
tiers = set(sources.available_sources()) | set(live)
|
|
56
|
+
out = {}
|
|
57
|
+
for tier in sorted(tiers):
|
|
58
|
+
ids = live.get(tier)
|
|
59
|
+
out[tier] = ids if ids is not None else _snapshot_ids(data_dir, tier)
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def available_ids(data_dir: Path) -> set[str] | None:
|
|
64
|
+
"""Every model id any known plan offers, or None if nothing is known."""
|
|
65
|
+
known = [ids for ids in load_available(data_dir).values() if ids is not None]
|
|
66
|
+
return set().union(*known) if known else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def offered(model_id: str, ids: set[str]) -> bool:
|
|
70
|
+
"""Whether *model_id* is in the offered set *ids*.
|
|
71
|
+
|
|
72
|
+
The tier pages list free variants under their own slug
|
|
73
|
+
(``step-5-preview-free``) while the catalog carries one model under the
|
|
74
|
+
base slug (``stepfun/step-5-preview``) — the same reach
|
|
75
|
+
:func:`sources.members` implements. Exact matching drops such a model
|
|
76
|
+
from the pool entirely, which is how every free row once vanished from
|
|
77
|
+
the list view.
|
|
78
|
+
"""
|
|
79
|
+
slug = model_id.split("/")[-1]
|
|
80
|
+
return (
|
|
81
|
+
slug in ids
|
|
82
|
+
or model_id in ids
|
|
83
|
+
or f"{slug}-free" in ids
|
|
84
|
+
or f"{model_id}-free" in ids
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def unlisted(data_dir: Path) -> dict[str, set[str]]:
|
|
89
|
+
"""Slugs the shipped lists carry that the provider no longer offers.
|
|
90
|
+
|
|
91
|
+
Worth surfacing rather than silently dropping: a model that quietly
|
|
92
|
+
disappears from a plan is a change the console should report, not one a
|
|
93
|
+
reader has to notice is missing.
|
|
94
|
+
"""
|
|
95
|
+
dropped: dict[str, set[str]] = {}
|
|
96
|
+
for tier, ids in load_available(data_dir).items():
|
|
97
|
+
if ids is None:
|
|
98
|
+
continue # no snapshot: nothing was dropped
|
|
99
|
+
missing = sources.load_source(tier) - ids
|
|
100
|
+
if missing:
|
|
101
|
+
dropped[tier] = missing
|
|
102
|
+
return dropped
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Inventory of benchmarks present in a dataset, for the selector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
from .data import Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def available(models: list[Model]) -> list[tuple[str, int]]:
|
|
11
|
+
"""Return ``(benchmark, model_count)`` pairs.
|
|
12
|
+
|
|
13
|
+
Sorted by coverage (best-covered first), ties broken by name so the
|
|
14
|
+
listing is stable between runs.
|
|
15
|
+
"""
|
|
16
|
+
counts: Counter[str] = Counter()
|
|
17
|
+
for m in models:
|
|
18
|
+
counts.update(m.scores.keys())
|
|
19
|
+
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|