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,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"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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())
@@ -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]))