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