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,415 @@
1
+ """Tier price snapshots scraped from the OpenCode docs (Zen and Go).
2
+
3
+ The catalog carries one provider list price with no notion of Console tier,
4
+ so it cannot say what a model costs *here* — DeepSeek V4 Pro is $1.74 on Zen
5
+ but $0.66 off-peak on Go, and Gemini 3.6/3.7/3.8 Flash list at half the Zen
6
+ rate in the catalog. The docs' own pricing tables are the authority, so
7
+ ``refresh_prices`` snapshots them to ``<tier>_prices.json`` next to the tier
8
+ slug files and ``load_prices`` reads them back.
9
+
10
+ Two rules the docs force on us:
11
+
12
+ * Every priced **variant** (``DeepSeek V4 Pro (Off-Peak)`` / ``(Peak)``,
13
+ ``(<= 200K tokens)`` / ``(> 200K tokens)``) is its own line entry — one
14
+ model can legitimately appear several times at different rates.
15
+ * Rows reading ``Free`` are free (``free=True``, price 0), and Go rows carry
16
+ the model's included monthly usage limit in dollars.
17
+
18
+ The representative cost is a weighted mix of the three token classes
19
+ (20% input, 60% cached read, 20% output) rather than the bare input rate: an
20
+ agent re-sends the same context every turn, so cached reads dominate the
21
+ bill. Missing cached pricing means the provider offers no cache discount, so
22
+ those reads are billed at the input rate.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import html as _html
28
+ import json
29
+ import re
30
+ from dataclasses import asdict, dataclass, replace
31
+ from pathlib import Path
32
+
33
+ PRICING_URLS = {
34
+ "zen": "https://opencode.ai/docs/zen/",
35
+ "go": "https://opencode.ai/docs/go/",
36
+ }
37
+
38
+ #: Weight of each token class in the representative cost.
39
+ COST_WEIGHTS = {"input": 0.2, "cached": 0.6, "output": 0.2}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PriceEntry:
44
+ """One priced line from a tier's documentation table."""
45
+
46
+ tier: str
47
+ model_id: str | None
48
+ name: str
49
+ input: float
50
+ output: float
51
+ cached_read: float | None = None
52
+ limit: float | None = None
53
+ free: bool = False
54
+
55
+
56
+ def weighted_cost(
57
+ input_: float | None, output: float | None, cached_read: float | None
58
+ ) -> float | None:
59
+ """Representative $/1M across token classes (20/60/20).
60
+
61
+ ``None`` input means we know nothing about this model. An absent cached
62
+ rate means there is no cache discount (billed at the input rate); an
63
+ absent output rate drops out and the remaining weights renormalise.
64
+ """
65
+ if input_ is None:
66
+ return None
67
+ cached = input_ if cached_read is None else cached_read
68
+ parts = [(COST_WEIGHTS["input"], input_), (COST_WEIGHTS["cached"], cached)]
69
+ if output is not None:
70
+ parts.append((COST_WEIGHTS["output"], output))
71
+ total_weight = sum(w for w, _ in parts)
72
+ return sum(w * price for w, price in parts) / total_weight
73
+
74
+
75
+ _VARIANT_SUFFIX = re.compile(r"\s*(\((?:≤|>|Off-Peak|Peak|50% off)[^)]*\))\s*$")
76
+
77
+
78
+ #: Nominal $/1M rates for the free tiers, so the list view can divide by
79
+ #: something instead of pricing every free model alike.
80
+ #:
81
+ #: Order-of-magnitude, not metered: the free-tier cap is unpublished, so the
82
+ #: daily budget X is anchored on the observed Space-Bunny limit (662 requests
83
+ #: in a day, 2026-10-12) and normalised to X = $0.10/day. Each model's rate
84
+ #: is its share of that budget — its measured $/request over Space-Bunny's,
85
+ #: converted back to $/1M at its own traffic profile. Full derivation, the
86
+ #: Oct-9 contradiction, and every assumption in LIMITS.md ("Estimating the
87
+ #: free tier's size"). All of them sit below the cheapest paid rate
88
+ #: ($0.034/1M), so free still ranks first on value — just not all equal.
89
+ FREE_TIER_NOMINAL_RATES: dict[str, float] = {
90
+ "step-5-preview-free": 0.019,
91
+ "mimo-v2.5-free": 0.0056,
92
+ "longcat-2.5-preview-free": 0.0033,
93
+ "big-pickle": 0.0030,
94
+ "mimo-v2.6-flash-free": 0.0028,
95
+ "space-bunny-free": 0.0018,
96
+ "muse-spark-1.3-contributor-free": 0.0013,
97
+ }
98
+
99
+ #: The anchor measurement itself, for a free model nobody has mapped yet.
100
+ FREE_TIER_DEFAULT_RATE = 0.0018
101
+
102
+
103
+ def nominal_free_rate(model_id: str, rates=None) -> float:
104
+ """The nominal $/1M of a free model id, whatever names it.
105
+
106
+ Matches the slug with or without its ``-free`` suffix, since the catalog
107
+ record (``stepfun/step-5-preview``) and the tier line
108
+ (``step-5-preview-free``) spell it both ways for one model. *rates* is
109
+ the fetched snapshot when there is one; otherwise the committed table
110
+ below, which is only ever the offline fallback.
111
+ """
112
+ table = rates if rates is not None else FREE_TIER_NOMINAL_RATES
113
+ slug = model_id.split("/")[-1]
114
+ for key in (
115
+ slug,
116
+ f"{slug}-free",
117
+ slug[: -len("-free")] if slug.endswith("-free") else slug,
118
+ ):
119
+ if key in table:
120
+ return table[key]
121
+ return table.get("__default__", FREE_TIER_DEFAULT_RATE)
122
+
123
+
124
+ #: Where the usage export lives: per-day, per-model traffic for the whole
125
+ #: workspace, both products. Person data never leaves the response — the
126
+ #: refresh below aggregates the numeric columns and drops the rest.
127
+ USAGE_EXPORT_URL = "https://opencode.ai/console/api/v2/usage/export?range=30d"
128
+
129
+ #: The free-tier budget is anchored on an observed limit event, not chosen:
130
+ #: this workspace's Space-Bunny requests started failing after 662 in a day
131
+ #: (2026-10-12). Normalised to a nominal X per day so the number stays a
132
+ #: rationing estimate rather than a bill anyone could mistake for metered.
133
+ FREE_TIER_ANCHOR_MODEL = "space-bunny-free"
134
+ FREE_TIER_ANCHOR_REQUESTS_PER_DAY = 662
135
+ FREE_TIER_BUDGET_USD_PER_DAY = 0.10
136
+
137
+ #: A profile drawn from fewer requests is noise, not a measurement: it is
138
+ #: left out of the snapshot and falls back to the anchor rate at load.
139
+ MIN_PROFILE_REQUESTS = 20
140
+
141
+ #: Serving rates for a free model with no catalog price (the stealth ones).
142
+ #: Small-model rates, stated plainly in LIMITS.md rather than hidden.
143
+ PROXY_RATES = (0.16, 0.32, 0.004)
144
+
145
+ FREE_TIER_SNAPSHOT_NAME = "free_tier_nominals.json"
146
+
147
+
148
+ def _catalog_rates(catalog_dir: Path) -> dict[str, tuple[float, float, float]]:
149
+ """``{slug or id: (input, output, cached) $/1M}`` from catalog records."""
150
+ rates: dict[str, tuple[float, float, float]] = {}
151
+ for path in sorted(Path(catalog_dir).glob("*.json")):
152
+ try:
153
+ model = json.loads(path.read_text())["model"]
154
+ price = model.get("priceUsdPer1M") or {}
155
+ rates[model["id"]] = (
156
+ price.get("input"),
157
+ price.get("output"),
158
+ price.get("cachedInput"),
159
+ )
160
+ rates[model["id"].split("/")[-1]] = rates[model["id"]]
161
+ except (json.JSONDecodeError, KeyError, TypeError):
162
+ continue
163
+ return rates
164
+
165
+
166
+ def refresh_free_nominals(
167
+ usage_dir: Path, catalog_dir: Path, get, free_slugs: set[str]
168
+ ) -> dict[str, float] | None:
169
+ """Derive free-tier nominal rates from the fetched usage export.
170
+
171
+ Aggregates trailing-30d requests and token volumes per free model, prices
172
+ each request at catalog rates (proxy where the catalog has none), and
173
+ normalises against the anchor: the budget over the anchor's observed
174
+ daily requests, converted back to $/1M at each model's own traffic
175
+ profile. Writes ``usage/free_tier_nominals.json`` and returns the rates.
176
+
177
+ User columns are never stored — only per-model numeric totals reach the
178
+ file. Returns None (writing nothing) when the anchor itself is absent,
179
+ since there is then nothing to normalise against.
180
+ """
181
+ import csv
182
+ import io as _io
183
+ from datetime import datetime, timezone
184
+
185
+ traffic: dict[str, list[float]] = {}
186
+ for row in csv.DictReader(_io.StringIO(get(USAGE_EXPORT_URL))):
187
+ slug = (row.get("model") or "").strip()
188
+ if slug not in free_slugs:
189
+ continue
190
+ try:
191
+ values = [
192
+ float(row.get(k) or 0)
193
+ for k in (
194
+ "requests",
195
+ "input_tokens",
196
+ "output_tokens",
197
+ "cache_read_tokens",
198
+ )
199
+ ]
200
+ except ValueError:
201
+ continue
202
+ acc = traffic.setdefault(slug, [0.0, 0.0, 0.0, 0.0])
203
+ for i, value in enumerate(values):
204
+ acc[i] += value
205
+ catalog = _catalog_rates(catalog_dir)
206
+
207
+ def cost_per_request(slug: str, totals: list[float]) -> float | None:
208
+ n, inp, out, cached = totals
209
+ if n < MIN_PROFILE_REQUESTS:
210
+ return None
211
+ base = slug[: -len("-free")] if slug.endswith("-free") else slug
212
+ rates = catalog.get(base) or catalog.get(slug) or PROXY_RATES
213
+ rin, rout, rcache = (r if r is not None else 0.0 for r in rates)
214
+ if rcache == 0.0 and rates[2] is None:
215
+ rcache = rin # no cache discount: reads bill at the input rate
216
+ return ((inp / n) * rin + (out / n) * rout + (cached / n) * rcache) / 1e6
217
+
218
+ if FREE_TIER_ANCHOR_MODEL not in traffic:
219
+ return None
220
+ anchor = cost_per_request(FREE_TIER_ANCHOR_MODEL, traffic[FREE_TIER_ANCHOR_MODEL])
221
+ if not anchor:
222
+ return None
223
+ unit = FREE_TIER_BUDGET_USD_PER_DAY / FREE_TIER_ANCHOR_REQUESTS_PER_DAY
224
+ rates: dict[str, float] = {}
225
+ for slug, totals in sorted(traffic.items()):
226
+ each = cost_per_request(slug, totals)
227
+ if not each:
228
+ continue
229
+ n, inp, out, cached = totals
230
+ rep_tokens = 0.2 * inp / n + 0.6 * cached / n + 0.2 * out / n
231
+ rates[slug] = unit * (each / anchor) / (rep_tokens / 1e6)
232
+ usage_dir = Path(usage_dir)
233
+ usage_dir.mkdir(parents=True, exist_ok=True)
234
+ (usage_dir / FREE_TIER_SNAPSHOT_NAME).write_text(
235
+ json.dumps(
236
+ {
237
+ "meta": {
238
+ "window": "trailing 30d usage export",
239
+ "fetched_at": datetime.now(timezone.utc).isoformat(
240
+ timespec="seconds"
241
+ ),
242
+ "anchor_model": FREE_TIER_ANCHOR_MODEL,
243
+ "anchor_requests_per_day": FREE_TIER_ANCHOR_REQUESTS_PER_DAY,
244
+ "budget_usd_per_day": FREE_TIER_BUDGET_USD_PER_DAY,
245
+ "min_profile_requests": MIN_PROFILE_REQUESTS,
246
+ },
247
+ "rates": rates,
248
+ },
249
+ indent=2,
250
+ )
251
+ )
252
+ return rates
253
+
254
+
255
+ def load_free_nominals(data_dir: Path) -> dict[str, float] | None:
256
+ """Nominal rates from the fetch snapshot, or None when there is none.
257
+
258
+ None means "never fetched", which the callers treat as "use the
259
+ committed fallback", not as "free costs nothing".
260
+ """
261
+ path = Path(data_dir) / "usage" / FREE_TIER_SNAPSHOT_NAME
262
+ if not path.exists():
263
+ return None
264
+ try:
265
+ rates = json.loads(path.read_text()).get("rates")
266
+ except (json.JSONDecodeError, AttributeError):
267
+ return None
268
+ return rates if isinstance(rates, dict) and rates else None
269
+
270
+
271
+ def variant_of(name: str) -> str:
272
+ """The ``(Off-Peak)`` / ``(> 200K tokens)`` suffix of a documented line."""
273
+ match = _VARIANT_SUFFIX.search(name)
274
+ return match.group(1) if match else ""
275
+
276
+
277
+ def _cell_text(cell: str) -> str:
278
+ """Tags stripped, entities decoded, ``<del>`` original prices dropped."""
279
+ cell = re.sub(r"<del[^>]*>.*?</del>", " ", cell, flags=re.S)
280
+ cell = re.sub(r"<[^>]+>", " ", cell)
281
+ return re.sub(r"\s+", " ", _html.unescape(cell)).strip()
282
+
283
+
284
+ def _tables(doc: str):
285
+ for table in re.findall(r"<table[\s\S]*?</table>", doc):
286
+ rows = []
287
+ for tr in re.findall(r"<tr[\s\S]*?</tr>", table):
288
+ cells = [
289
+ _cell_text(c) for c in re.findall(r"<t[hd][^>]*>([\s\S]*?)</t[hd]>", tr)
290
+ ]
291
+ if cells:
292
+ rows.append(cells)
293
+ if rows:
294
+ yield rows
295
+
296
+
297
+ def _money(cell: str) -> float | None:
298
+ if not cell:
299
+ return None
300
+ if re.search(r"free", cell, re.I) and "$" not in cell:
301
+ return 0.0
302
+ amounts = re.findall(r"\$\s*([\d.]+)", cell)
303
+ return float(amounts[-1]) if amounts else None # last = discounted price
304
+
305
+
306
+ def _limit(cell: str) -> float | None:
307
+ if not cell or re.search(r"unlimited", cell, re.I):
308
+ return None
309
+ return _money(cell)
310
+
311
+
312
+ def parse_pricing(doc: str, tier: str) -> list[PriceEntry]:
313
+ """Every priced row of a tier doc page, variants kept as separate entries.
314
+
315
+ Model ids come from the page's endpoint table, matched on the display
316
+ name and, for variants, on the name with its ``(…)`` suffix stripped.
317
+ Rows that resolve to no id are kept as ``model_id=None`` rather than
318
+ dropped: an unrecognised model is a data problem, not a parse failure.
319
+ """
320
+ ids: dict[str, str] = {}
321
+ price_tables: list[tuple[list[str], list[list[str]]]] = []
322
+ for rows in _tables(doc):
323
+ header = [h.lower() for h in rows[0]]
324
+ if "model id" in header and "model" in header:
325
+ i_name, i_id = header.index("model"), header.index("model id")
326
+ for row in rows[1:]:
327
+ if len(row) > max(i_name, i_id):
328
+ ids[row[i_name]] = row[i_id]
329
+ elif "input" in header and "output" in header:
330
+ price_tables.append((header, rows[1:]))
331
+
332
+ entries: list[PriceEntry] = []
333
+ by_fingerprint: dict[tuple, int] = {}
334
+ for header, rows in price_tables:
335
+ i_in, i_out = header.index("input"), header.index("output")
336
+ i_cached = header.index("cached read") if "cached read" in header else None
337
+ i_limit = header.index("monthly limit") if "monthly limit" in header else None
338
+ for row in rows:
339
+ if len(row) <= max(i_in, i_out):
340
+ continue
341
+ name = row[0]
342
+ inp, out = _money(row[i_in]), _money(row[i_out])
343
+ if inp is None or out is None:
344
+ continue
345
+ cached = (
346
+ _money(row[i_cached])
347
+ if i_cached is not None and len(row) > i_cached
348
+ else None
349
+ )
350
+ limit = (
351
+ _limit(row[i_limit])
352
+ if i_limit is not None and len(row) > i_limit
353
+ else None
354
+ )
355
+ model_id = ids.get(name) or ids.get(_VARIANT_SUFFIX.sub("", name))
356
+ fingerprint = (name, inp, out, cached)
357
+ if fingerprint in by_fingerprint:
358
+ # the Go page repeats its table once per plan tab: identical
359
+ # token prices, a larger monthly limit under Go Plus — keep
360
+ # one line carrying the more generous allowance
361
+ old = entries[by_fingerprint[fingerprint]]
362
+ if limit is not None and (old.limit is None or limit > old.limit):
363
+ entries[by_fingerprint[fingerprint]] = replace(old, limit=limit)
364
+ continue
365
+ by_fingerprint[fingerprint] = len(entries)
366
+ entries.append(
367
+ PriceEntry(
368
+ tier=tier,
369
+ model_id=model_id,
370
+ name=name,
371
+ input=inp,
372
+ output=out,
373
+ cached_read=cached,
374
+ limit=limit,
375
+ free=inp == 0.0,
376
+ )
377
+ )
378
+ return entries
379
+
380
+
381
+ def refresh_prices(directory, get=None) -> list[str]:
382
+ """Snapshot every tier's pricing table; returns the files written."""
383
+ from .fetch import http_get
384
+
385
+ directory = Path(directory)
386
+ directory.mkdir(parents=True, exist_ok=True)
387
+ saved = []
388
+ for tier, url in PRICING_URLS.items():
389
+ entries = parse_pricing((get or http_get)(url), tier)
390
+ if not entries:
391
+ # docs moved or renamed a column: never clobber a good snapshot
392
+ continue
393
+ path = directory / f"{tier}_prices.json"
394
+ path.write_text(json.dumps([asdict(e) for e in entries], indent=1))
395
+ saved.append(path.name)
396
+ return saved
397
+
398
+
399
+ def load_prices(directory) -> dict[str, list[PriceEntry]]:
400
+ """Snapshots in *directory*, keyed by tier; absent files are not an error."""
401
+ prices: dict[str, list[PriceEntry]] = {}
402
+ for path in sorted(Path(directory).glob("*_prices.json")):
403
+ tier = path.name[: -len("_prices.json")]
404
+ try:
405
+ rows = json.loads(path.read_text())
406
+ except (json.JSONDecodeError, OSError):
407
+ continue
408
+ entries = []
409
+ for row in rows:
410
+ try:
411
+ entries.append(PriceEntry(**row))
412
+ except (TypeError, KeyError, ValueError):
413
+ continue
414
+ prices[tier] = entries
415
+ return prices