@seanyao/roll 3.609.2 → 3.610.2
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.
- package/CHANGELOG.md +44 -1
- package/README.md +5 -6
- package/dist/roll.mjs +16146 -16186
- package/package.json +3 -2
- package/skills/README.md +14 -1
- package/skills/docs/skill-authoring.md +66 -0
- package/skills/reports/skill-audit-summary.md +53 -0
- package/skills/roll-.changelog/SKILL.md +25 -443
- package/skills/roll-.changelog/references/full-contract.md +462 -0
- package/skills/roll-.clarify/SKILL.md +6 -4
- package/skills/roll-.dream/SKILL.md +26 -353
- package/skills/roll-.dream/references/full-contract.md +365 -0
- package/skills/roll-.echo/SKILL.md +6 -4
- package/skills/roll-.qa/SKILL.md +25 -236
- package/skills/roll-.qa/references/full-contract.md +256 -0
- package/skills/roll-.review/SKILL.md +6 -2
- package/skills/roll-brief/SKILL.md +6 -8
- package/skills/roll-build/SKILL.md +28 -864
- package/skills/roll-build/references/full-contract.md +883 -0
- package/skills/roll-debug/SKILL.md +26 -585
- package/skills/roll-debug/references/full-contract.md +607 -0
- package/skills/roll-design/SKILL.md +28 -903
- package/skills/roll-design/references/full-contract.md +923 -0
- package/skills/roll-doc/SKILL.md +25 -574
- package/skills/roll-doc/references/full-contract.md +594 -0
- package/skills/roll-doctor/SKILL.md +21 -2
- package/skills/roll-fix/SKILL.md +28 -621
- package/skills/roll-fix/references/full-contract.md +640 -0
- package/skills/roll-idea/SKILL.md +6 -2
- package/skills/roll-loop/SKILL.md +27 -543
- package/skills/roll-loop/references/full-contract.md +555 -0
- package/skills/roll-notes/SKILL.md +6 -2
- package/skills/roll-onboard/SKILL.md +6 -2
- package/skills/roll-peer/SKILL.md +27 -316
- package/skills/roll-peer/references/full-contract.md +329 -0
- package/skills/roll-propose/SKILL.md +6 -8
- package/skills/roll-review-pr/SKILL.md +6 -2
- package/skills/roll-sentinel/SKILL.md +26 -344
- package/skills/roll-sentinel/references/full-contract.md +363 -0
- package/skills/roll-spar/SKILL.md +27 -269
- package/skills/roll-spar/references/full-contract.md +288 -0
- package/skills/route-cases/skills.json +235 -0
- package/skills/scripts/audit-skills.mjs +272 -0
- package/skills/scripts/test-audit-skills.mjs +39 -0
- package/skills/tests/fixtures/skill-audit/block-skill/SKILL.md +12 -0
- package/skills/tests/fixtures/skill-audit/minimal-skill/SKILL.md +8 -0
- package/skills/tests/fixtures/skill-audit/quoted-skill/SKILL.md +10 -0
- package/skills/tests/fixtures/skill-audit/route-cases.json +21 -0
- package/skills/tests/fixtures/skill-audit/spoke-skill/SKILL.md +12 -0
- package/skills/tests/fixtures/skill-audit/spoke-skill/references/runbook.md +3 -0
- package/bin/roll +0 -15361
- package/lib/backfill-pi-usage.py +0 -243
- package/lib/changelog_audit.py +0 -149
- package/lib/changelog_generate.py +0 -470
- package/lib/consistency_check.py +0 -409
- package/lib/context_feed_budget.sh +0 -194
- package/lib/github_sync.py +0 -876
- package/lib/i18n/slides.sh +0 -3
- package/lib/i18n/slides_build.sh +0 -38
- package/lib/i18n/slides_delete.sh +0 -19
- package/lib/i18n/slides_list.sh +0 -14
- package/lib/i18n/slides_logs.sh +0 -12
- package/lib/i18n/slides_new.sh +0 -15
- package/lib/i18n/slides_preview.sh +0 -14
- package/lib/i18n/slides_templates.sh +0 -7
- package/lib/i18n.sh +0 -211
- package/lib/loop-exit-summary.py +0 -393
- package/lib/loop-fmt.py +0 -589
- package/lib/loop_pick_agent.py +0 -316
- package/lib/loop_result_eval.py +0 -469
- package/lib/loop_unstick.py +0 -180
- package/lib/model_prices.py +0 -194
- package/lib/prices_fetcher.py +0 -534
- package/lib/roll-backlog.py +0 -225
- package/lib/roll-brief.py +0 -286
- package/lib/roll-help.py +0 -158
- package/lib/roll-home.py +0 -556
- package/lib/roll-init.py +0 -156
- package/lib/roll-loop-status.py +0 -1691
- package/lib/roll-loop-story.py +0 -191
- package/lib/roll-peer.py +0 -252
- package/lib/roll-setup.py +0 -102
- package/lib/roll-status.py +0 -367
- package/lib/roll_git.py +0 -41
- package/lib/roll_render.py +0 -414
- package/lib/slides/components/README.md +0 -123
- package/lib/slides/components/cards-2.html +0 -9
- package/lib/slides/components/cards-3.html +0 -9
- package/lib/slides/components/cards-4.html +0 -9
- package/lib/slides/components/compare.html +0 -22
- package/lib/slides/components/highlight.html +0 -9
- package/lib/slides/components/pipeline.html +0 -12
- package/lib/slides/components/plain.html +0 -7
- package/lib/slides/components/quote.html +0 -4
- package/lib/slides/components/timeline.html +0 -9
- package/lib/slides/templates/introduction-v3.html +0 -571
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +0 -778
- package/lib/slides-validate.py +0 -357
- package/lib/test_quality_gate.py +0 -143
- package/skills/roll-deck/SKILL.md +0 -296
- /package/skills/roll-debug/{injectable-bb.js → assets/injectable-bb.js} +0 -0
- /package/skills/roll-design/{ENGINEERING_CHECKLIST.md → references/engineering-checklist.md} +0 -0
package/lib/prices_fetcher.py
DELETED
|
@@ -1,534 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
prices_fetcher — fetch + parse + diff + write multi-vendor pricing snapshots.
|
|
3
|
-
|
|
4
|
-
US-VIEW-013: replaces the hardcoded PRICES table in ``model_prices.py`` with
|
|
5
|
-
versioned JSON snapshots under ``lib/prices/``. The fetcher pulls the live
|
|
6
|
-
pricing docs page, extracts the model rate rows, and writes a new snapshot
|
|
7
|
-
only when the rates differ from the most recent one on disk.
|
|
8
|
-
|
|
9
|
-
US-VIEW-023: vendor-registry architecture — ``fetch``/``parse``/``refresh``
|
|
10
|
-
dispatch by vendor. Adding a new vendor is a registry entry, not a change to
|
|
11
|
-
the fetch/parse/refresh orchestration.
|
|
12
|
-
|
|
13
|
-
Design:
|
|
14
|
-
* ``fetch_pricing_html(url, timeout)`` — pure I/O, raises ``FetchError``
|
|
15
|
-
* ``parse_pricing_html(html, vendor)`` — dispatches to vendor parser,
|
|
16
|
-
raises ``ParseError``
|
|
17
|
-
* ``diff_prices(old, new)`` — pure diff, returns list of changes
|
|
18
|
-
* ``write_snapshot(prices, ...)`` — pure I/O, returns the path written
|
|
19
|
-
* ``refresh(...)`` — orchestrator; the only function with side effects on
|
|
20
|
-
both network and disk
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import datetime as _dt
|
|
26
|
-
import json
|
|
27
|
-
import os
|
|
28
|
-
import re
|
|
29
|
-
import sys
|
|
30
|
-
from dataclasses import dataclass
|
|
31
|
-
from html.parser import HTMLParser
|
|
32
|
-
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
33
|
-
from urllib.error import URLError
|
|
34
|
-
from urllib.request import Request, urlopen
|
|
35
|
-
|
|
36
|
-
DEFAULT_TIMEOUT = 15
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class FetchError(RuntimeError):
|
|
40
|
-
"""Raised when fetching the pricing page fails."""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class ParseError(ValueError):
|
|
44
|
-
"""Raised when the pricing HTML cannot be parsed into a prices map."""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# ─── Vendor registry ──────────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
@dataclass(frozen=True)
|
|
50
|
-
class VendorConfig:
|
|
51
|
-
"""Configuration for a single pricing vendor."""
|
|
52
|
-
|
|
53
|
-
name: str
|
|
54
|
-
source_url: str
|
|
55
|
-
currency: str
|
|
56
|
-
parse: Callable[[str], Dict[str, Dict[str, float]]]
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _parse_claude_html(html: str) -> Dict[str, Dict[str, float]]:
|
|
60
|
-
"""Parse Anthropic/Claude pricing HTML into a {model: rates} map."""
|
|
61
|
-
model_re = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
|
|
62
|
-
dollar_re = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
|
|
63
|
-
|
|
64
|
-
extractor = _TableTextExtractor()
|
|
65
|
-
extractor.feed(html)
|
|
66
|
-
|
|
67
|
-
prices: Dict[str, Dict[str, float]] = {}
|
|
68
|
-
for row in extractor.rows:
|
|
69
|
-
text = " ".join(row)
|
|
70
|
-
model_match = model_re.search(text)
|
|
71
|
-
if not model_match:
|
|
72
|
-
continue
|
|
73
|
-
model = model_match.group(0)
|
|
74
|
-
amounts = [float(m.group(1)) for m in dollar_re.finditer(text)]
|
|
75
|
-
if len(amounts) < 4:
|
|
76
|
-
continue
|
|
77
|
-
in_rate, cache_create, cache_read, out_rate = amounts[:4]
|
|
78
|
-
prices[model] = {
|
|
79
|
-
"in": in_rate,
|
|
80
|
-
"out": out_rate,
|
|
81
|
-
"cache_create": cache_create,
|
|
82
|
-
"cache_read": cache_read,
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if not prices:
|
|
86
|
-
raise ParseError("no price rows found in HTML; page layout may have changed")
|
|
87
|
-
return prices
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _parse_deepseek_html(html: str) -> Dict[str, Dict[str, float]]:
|
|
91
|
-
"""Parse DeepSeek pricing HTML into a {model: rates} map.
|
|
92
|
-
|
|
93
|
-
Handles both the Chinese (元) and English ($) pricing pages.
|
|
94
|
-
Extracts deepseek-v4-flash and deepseek-v4-pro rates, then adds
|
|
95
|
-
deepseek-chat and deepseek-reasoner as aliases for flash.
|
|
96
|
-
"""
|
|
97
|
-
extractor = _TableTextExtractor()
|
|
98
|
-
extractor.feed(html)
|
|
99
|
-
|
|
100
|
-
# Find the header row with model names.
|
|
101
|
-
model_names: List[str] = []
|
|
102
|
-
header_idx = -1
|
|
103
|
-
for i, row in enumerate(extractor.rows):
|
|
104
|
-
if any(k in ' '.join(row) for k in ('模型', 'MODEL')):
|
|
105
|
-
# Cells after the label are model names.
|
|
106
|
-
# Strip footnote markers like (1) and HTML tags.
|
|
107
|
-
names = [
|
|
108
|
-
re.sub(r'<[^>]+>', '', re.sub(r'\s*\(\d+\)', '', cell)).strip()
|
|
109
|
-
for cell in row[1:]
|
|
110
|
-
if cell.strip()
|
|
111
|
-
]
|
|
112
|
-
if len(names) >= 2:
|
|
113
|
-
model_names = names
|
|
114
|
-
header_idx = i
|
|
115
|
-
break
|
|
116
|
-
|
|
117
|
-
if len(model_names) < 2:
|
|
118
|
-
raise ParseError('no model header row found; page layout may have changed')
|
|
119
|
-
|
|
120
|
-
# Walk rows after header to find pricing data.
|
|
121
|
-
cache_hit: List[float] = []
|
|
122
|
-
cache_miss: List[float] = []
|
|
123
|
-
output: List[float] = []
|
|
124
|
-
|
|
125
|
-
for row in extractor.rows[header_idx + 1:]:
|
|
126
|
-
text = ' '.join(row)
|
|
127
|
-
# Skip non-pricing rows.
|
|
128
|
-
if not any(k in text for k in ('缓存命中', 'CACHE HIT', '缓存未命中', 'CACHE MISS', '输出', 'OUTPUT')):
|
|
129
|
-
continue
|
|
130
|
-
|
|
131
|
-
# Extract numeric values followed by 元 or $.
|
|
132
|
-
values: List[float] = []
|
|
133
|
-
for cell in row:
|
|
134
|
-
# Match numbers like 0.02元, $0.14, 1元, etc.
|
|
135
|
-
m = re.search(r'(?:\$)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:元|¥)?', cell)
|
|
136
|
-
if m:
|
|
137
|
-
values.append(float(m.group(1)))
|
|
138
|
-
|
|
139
|
-
if len(values) < len(model_names):
|
|
140
|
-
continue
|
|
141
|
-
|
|
142
|
-
if any(k in text for k in ('缓存命中', 'CACHE HIT')):
|
|
143
|
-
cache_hit = values[:len(model_names)]
|
|
144
|
-
elif any(k in text for k in ('缓存未命中', 'CACHE MISS')):
|
|
145
|
-
cache_miss = values[:len(model_names)]
|
|
146
|
-
elif any(k in text for k in ('输出', 'OUTPUT')):
|
|
147
|
-
output = values[:len(model_names)]
|
|
148
|
-
|
|
149
|
-
if not cache_miss or not output:
|
|
150
|
-
raise ParseError('no price rows found in HTML; page layout may have changed')
|
|
151
|
-
|
|
152
|
-
prices: Dict[str, Dict[str, float]] = {}
|
|
153
|
-
for idx, model in enumerate(model_names):
|
|
154
|
-
if model in ('deepseek-v4-flash', 'deepseek-v4-pro'):
|
|
155
|
-
prices[model] = {
|
|
156
|
-
'in': cache_miss[idx],
|
|
157
|
-
'out': output[idx],
|
|
158
|
-
'cache_create': cache_miss[idx],
|
|
159
|
-
'cache_read': cache_hit[idx] if cache_hit else 0.0,
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if not prices:
|
|
163
|
-
raise ParseError('no price rows found in HTML; page layout may have changed')
|
|
164
|
-
|
|
165
|
-
return prices
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def _try_parse_kimi_pricing(html: str) -> Optional[Dict[str, Dict[str, float]]]:
|
|
169
|
-
"""Try to parse Kimi pricing from HTML/MDX content.
|
|
170
|
-
|
|
171
|
-
Handles the JSX ``DocTable`` format used by Kimi's ``.md`` endpoints:
|
|
172
|
-
rows contain [model, unit, cache-hit, cache-miss, output, context].
|
|
173
|
-
"""
|
|
174
|
-
prices: Dict[str, Dict[str, float]] = {}
|
|
175
|
-
price_re = re.compile(r"¥\s*([0-9]+(?:\.[0-9]+)?)")
|
|
176
|
-
row_re = re.compile(
|
|
177
|
-
r'\[\s*"([^"]+)"\s*,\s*"[^"]+"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\]'
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
for m in row_re.finditer(html):
|
|
181
|
-
model, cache_hit_str, cache_miss_str, output_str = m.groups()
|
|
182
|
-
cache_hit_m = price_re.search(cache_hit_str)
|
|
183
|
-
cache_miss_m = price_re.search(cache_miss_str)
|
|
184
|
-
output_m = price_re.search(output_str)
|
|
185
|
-
if not all((cache_hit_m, cache_miss_m, output_m)):
|
|
186
|
-
continue
|
|
187
|
-
prices[model] = {
|
|
188
|
-
"in": float(cache_miss_m.group(1)),
|
|
189
|
-
"out": float(output_m.group(1)),
|
|
190
|
-
"cache_create": float(cache_miss_m.group(1)),
|
|
191
|
-
"cache_read": float(cache_hit_m.group(1)),
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return prices if prices else None
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def _parse_kimi_html(html: str) -> Dict[str, Dict[str, float]]:
|
|
198
|
-
"""Parse Kimi pricing HTML into a {model: rates} map.
|
|
199
|
-
|
|
200
|
-
Kimi pricing is split across sub-pages (``pricing/chat-k25``,
|
|
201
|
-
``pricing/chat-k26``). The parser first tries to extract prices from the
|
|
202
|
-
provided HTML; if none found, it fetches the ``.md`` sub-pages and parses
|
|
203
|
-
those.
|
|
204
|
-
"""
|
|
205
|
-
prices = _try_parse_kimi_pricing(html)
|
|
206
|
-
if prices:
|
|
207
|
-
if "kimi-k2.6" in prices:
|
|
208
|
-
prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
|
|
209
|
-
return prices
|
|
210
|
-
|
|
211
|
-
sub_urls = [
|
|
212
|
-
"https://platform.kimi.com/docs/pricing/chat-k25.md",
|
|
213
|
-
"https://platform.kimi.com/docs/pricing/chat-k26.md",
|
|
214
|
-
]
|
|
215
|
-
combined = html
|
|
216
|
-
for url in sub_urls:
|
|
217
|
-
try:
|
|
218
|
-
combined += "\n" + fetch_pricing_html(url)
|
|
219
|
-
except FetchError as exc:
|
|
220
|
-
raise ParseError(f"could not fetch kimi sub-page {url}: {exc}")
|
|
221
|
-
|
|
222
|
-
prices = _try_parse_kimi_pricing(combined)
|
|
223
|
-
if not prices:
|
|
224
|
-
raise ParseError("no price rows found in kimi pages")
|
|
225
|
-
|
|
226
|
-
if "kimi-k2.6" in prices:
|
|
227
|
-
prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
|
|
228
|
-
|
|
229
|
-
return prices
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
VENDOR_REGISTRY: Dict[str, VendorConfig] = {
|
|
233
|
-
"anthropic": VendorConfig(
|
|
234
|
-
name="anthropic",
|
|
235
|
-
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
236
|
-
currency="USD",
|
|
237
|
-
parse=_parse_claude_html,
|
|
238
|
-
),
|
|
239
|
-
"deepseek": VendorConfig(
|
|
240
|
-
name="deepseek",
|
|
241
|
-
source_url="https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
242
|
-
currency="CNY",
|
|
243
|
-
parse=_parse_deepseek_html,
|
|
244
|
-
),
|
|
245
|
-
"kimi": VendorConfig(
|
|
246
|
-
name="kimi",
|
|
247
|
-
source_url="https://platform.kimi.com/docs/pricing/chat",
|
|
248
|
-
currency="CNY",
|
|
249
|
-
parse=_parse_kimi_html,
|
|
250
|
-
),
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
# ─── Network I/O ──────────────────────────────────────────────────────────────
|
|
255
|
-
|
|
256
|
-
def fetch_pricing_html(url: str,
|
|
257
|
-
timeout: float = DEFAULT_TIMEOUT) -> str:
|
|
258
|
-
"""Fetch the pricing docs page and return its raw HTML."""
|
|
259
|
-
req = Request(url, headers={"User-Agent": "roll/prices_fetcher"})
|
|
260
|
-
try:
|
|
261
|
-
with urlopen(req, timeout=timeout) as resp:
|
|
262
|
-
data = resp.read()
|
|
263
|
-
charset = resp.headers.get_content_charset() or "utf-8"
|
|
264
|
-
return data.decode(charset, errors="replace")
|
|
265
|
-
except (URLError, OSError, TimeoutError) as exc:
|
|
266
|
-
raise FetchError(f"could not fetch {url}: {exc}") from exc
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
# ─── HTML parsing helpers ─────────────────────────────────────────────────────
|
|
270
|
-
|
|
271
|
-
class _TableTextExtractor(HTMLParser):
|
|
272
|
-
"""Walk an HTML document and yield <tr> cell-text lists per row."""
|
|
273
|
-
|
|
274
|
-
def __init__(self) -> None:
|
|
275
|
-
super().__init__()
|
|
276
|
-
self.rows: List[List[str]] = []
|
|
277
|
-
self._in_row = False
|
|
278
|
-
self._in_cell = False
|
|
279
|
-
self._cells: List[str] = []
|
|
280
|
-
self._cur: List[str] = []
|
|
281
|
-
|
|
282
|
-
def handle_starttag(self, tag: str, attrs): # noqa: ANN001
|
|
283
|
-
if tag == "tr":
|
|
284
|
-
self._in_row = True
|
|
285
|
-
self._cells = []
|
|
286
|
-
elif tag in ("td", "th") and self._in_row:
|
|
287
|
-
self._in_cell = True
|
|
288
|
-
self._cur = []
|
|
289
|
-
|
|
290
|
-
def handle_endtag(self, tag: str) -> None:
|
|
291
|
-
if tag in ("td", "th") and self._in_cell:
|
|
292
|
-
self._cells.append(" ".join(self._cur).strip())
|
|
293
|
-
self._in_cell = False
|
|
294
|
-
elif tag == "tr" and self._in_row:
|
|
295
|
-
if self._cells:
|
|
296
|
-
self.rows.append(self._cells)
|
|
297
|
-
self._in_row = False
|
|
298
|
-
|
|
299
|
-
def handle_data(self, data: str) -> None:
|
|
300
|
-
if self._in_cell:
|
|
301
|
-
self._cur.append(data)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
# ─── Parser dispatch ──────────────────────────────────────────────────────────
|
|
305
|
-
|
|
306
|
-
def parse_pricing_html(html: str, vendor: str = "anthropic") -> Dict[str, Dict[str, float]]:
|
|
307
|
-
"""Parse pricing docs HTML into a {model: rates} map.
|
|
308
|
-
|
|
309
|
-
Dispatches to the vendor-specific parser registered in ``VENDOR_REGISTRY``.
|
|
310
|
-
"""
|
|
311
|
-
config = VENDOR_REGISTRY.get(vendor)
|
|
312
|
-
if not config:
|
|
313
|
-
raise ParseError(
|
|
314
|
-
f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
|
|
315
|
-
)
|
|
316
|
-
return config.parse(html)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
# ─── Diff & formatting ────────────────────────────────────────────────────────
|
|
320
|
-
|
|
321
|
-
def diff_prices(old: Dict[str, Dict[str, float]],
|
|
322
|
-
new: Dict[str, Dict[str, float]]
|
|
323
|
-
) -> List[Tuple[str, str, str, Optional[float], Optional[float]]]:
|
|
324
|
-
"""Return a list of (kind, model, field, old_val, new_val) tuples.
|
|
325
|
-
|
|
326
|
-
kind is one of: ``added``, ``removed``, ``changed``. For added rows the
|
|
327
|
-
old_val is None; for removed, the new_val is None.
|
|
328
|
-
"""
|
|
329
|
-
changes: List[Tuple[str, str, str, Optional[float], Optional[float]]] = []
|
|
330
|
-
for model in sorted(set(old) | set(new)):
|
|
331
|
-
if model not in old:
|
|
332
|
-
for field, val in new[model].items():
|
|
333
|
-
changes.append(("added", model, field, None, val))
|
|
334
|
-
continue
|
|
335
|
-
if model not in new:
|
|
336
|
-
for field, val in old[model].items():
|
|
337
|
-
changes.append(("removed", model, field, val, None))
|
|
338
|
-
continue
|
|
339
|
-
for field in sorted(set(old[model]) | set(new[model])):
|
|
340
|
-
old_val = old[model].get(field)
|
|
341
|
-
new_val = new[model].get(field)
|
|
342
|
-
if old_val != new_val:
|
|
343
|
-
changes.append(("changed", model, field, old_val, new_val))
|
|
344
|
-
return changes
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def format_diff(changes: List[Tuple[str, str, str, Optional[float], Optional[float]]],
|
|
348
|
-
colored: bool = True) -> str:
|
|
349
|
-
"""Render diff_prices output as red-/green-coded lines."""
|
|
350
|
-
if not changes:
|
|
351
|
-
return ""
|
|
352
|
-
red = "\033[31m" if colored else ""
|
|
353
|
-
green = "\033[32m" if colored else ""
|
|
354
|
-
dim = "\033[2m" if colored else ""
|
|
355
|
-
reset = "\033[0m" if colored else ""
|
|
356
|
-
lines: List[str] = []
|
|
357
|
-
for kind, model, field, old, new in changes:
|
|
358
|
-
if kind == "added":
|
|
359
|
-
lines.append(f"{green}+ {model} {field} = {new}{reset}")
|
|
360
|
-
elif kind == "removed":
|
|
361
|
-
lines.append(f"{red}- {model} {field} = {old}{reset}")
|
|
362
|
-
else:
|
|
363
|
-
lines.append(f"{dim}~ {model} {field}{reset} {red}{old}{reset} → {green}{new}{reset}")
|
|
364
|
-
return "\n".join(lines)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
# ─── Snapshot I/O ─────────────────────────────────────────────────────────────
|
|
368
|
-
|
|
369
|
-
_SNAPSHOT_NAME_RE = re.compile(r"snapshot-(\d{4}-\d{2}-\d{2})(?:-([a-z]+))?\.json")
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def _extract_vendor_from_filename(name: str) -> Optional[str]:
|
|
373
|
-
"""Extract vendor from snapshot filename.
|
|
374
|
-
|
|
375
|
-
snapshot-2026-05-22.json → anthropic
|
|
376
|
-
snapshot-2026-05-22-deepseek.json → deepseek
|
|
377
|
-
snapshot-2026-06-02-kimi.json → kimi
|
|
378
|
-
"""
|
|
379
|
-
m = _SNAPSHOT_NAME_RE.match(name)
|
|
380
|
-
if not m:
|
|
381
|
-
return None
|
|
382
|
-
return m.group(2) or "anthropic"
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def _latest_snapshot_path(snapshot_dir: str, vendor: str = "anthropic") -> Optional[str]:
|
|
386
|
-
if not os.path.isdir(snapshot_dir):
|
|
387
|
-
return None
|
|
388
|
-
snaps = sorted(
|
|
389
|
-
os.path.join(snapshot_dir, n)
|
|
390
|
-
for n in os.listdir(snapshot_dir)
|
|
391
|
-
if _SNAPSHOT_NAME_RE.match(n) and _extract_vendor_from_filename(n) == vendor
|
|
392
|
-
)
|
|
393
|
-
return snaps[-1] if snaps else None
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def write_snapshot(prices: Dict[str, Dict[str, float]],
|
|
397
|
-
*,
|
|
398
|
-
snapshot_dir: str,
|
|
399
|
-
source_url: str,
|
|
400
|
-
vendor: str = "anthropic",
|
|
401
|
-
currency: str = "USD",
|
|
402
|
-
effective_at: Optional[str] = None,
|
|
403
|
-
default_model: Optional[str] = None,
|
|
404
|
-
notes: Optional[str] = None) -> str:
|
|
405
|
-
"""Write a new snapshot JSON and return its path."""
|
|
406
|
-
os.makedirs(snapshot_dir, exist_ok=True)
|
|
407
|
-
today = effective_at or _dt.date.today().isoformat()
|
|
408
|
-
payload: Dict[str, Any] = {
|
|
409
|
-
"version": today,
|
|
410
|
-
"effective_at": today,
|
|
411
|
-
"source_url": source_url,
|
|
412
|
-
"vendor": vendor,
|
|
413
|
-
"currency": currency,
|
|
414
|
-
"default_model": default_model or _pick_default(prices),
|
|
415
|
-
"prices": prices,
|
|
416
|
-
}
|
|
417
|
-
if notes:
|
|
418
|
-
payload["notes"] = notes
|
|
419
|
-
suffix = f"-{vendor}" if vendor != "anthropic" else ""
|
|
420
|
-
dest = os.path.join(snapshot_dir, f"snapshot-{today}{suffix}.json")
|
|
421
|
-
with open(dest, "w", encoding="utf-8") as f:
|
|
422
|
-
json.dump(payload, f, indent=2, sort_keys=False)
|
|
423
|
-
f.write("\n")
|
|
424
|
-
return dest
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def _pick_default(prices: Dict[str, Dict[str, float]]) -> str:
|
|
428
|
-
"""Pick a sensible fallback model: prefer the cheapest sonnet, else first key."""
|
|
429
|
-
for k in prices:
|
|
430
|
-
if "sonnet" in k:
|
|
431
|
-
return k
|
|
432
|
-
return next(iter(prices))
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
# ─── Orchestrator ─────────────────────────────────────────────────────────────
|
|
436
|
-
|
|
437
|
-
def refresh(*,
|
|
438
|
-
snapshot_dir: str,
|
|
439
|
-
vendor: str = "anthropic",
|
|
440
|
-
url: Optional[str] = None,
|
|
441
|
-
timeout: float = DEFAULT_TIMEOUT,
|
|
442
|
-
html: Optional[str] = None,
|
|
443
|
-
) -> Tuple[str, List[Tuple[str, str, str, Optional[float], Optional[float]]]]:
|
|
444
|
-
"""Fetch (or accept fixture HTML), parse, diff against latest snapshot, write.
|
|
445
|
-
|
|
446
|
-
Returns (action, changes) where action is one of:
|
|
447
|
-
``"unchanged"`` — no diff vs latest snapshot, nothing written
|
|
448
|
-
``"written:<path>"`` — new snapshot written at <path>
|
|
449
|
-
``"first:<path>"`` — no prior snapshot existed; baseline written
|
|
450
|
-
"""
|
|
451
|
-
config = VENDOR_REGISTRY.get(vendor)
|
|
452
|
-
if not config:
|
|
453
|
-
raise ParseError(
|
|
454
|
-
f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
source_url = url or config.source_url
|
|
458
|
-
if html is None:
|
|
459
|
-
html = fetch_pricing_html(source_url, timeout=timeout)
|
|
460
|
-
new_prices = parse_pricing_html(html, vendor=vendor)
|
|
461
|
-
|
|
462
|
-
latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
|
|
463
|
-
if latest is None:
|
|
464
|
-
dest = write_snapshot(
|
|
465
|
-
new_prices,
|
|
466
|
-
snapshot_dir=snapshot_dir,
|
|
467
|
-
source_url=source_url,
|
|
468
|
-
vendor=vendor,
|
|
469
|
-
currency=config.currency,
|
|
470
|
-
)
|
|
471
|
-
return f"first:{dest}", diff_prices({}, new_prices)
|
|
472
|
-
|
|
473
|
-
with open(latest, "r", encoding="utf-8") as f:
|
|
474
|
-
old = json.load(f).get("prices", {})
|
|
475
|
-
changes = diff_prices(old, new_prices)
|
|
476
|
-
if not changes:
|
|
477
|
-
return "unchanged", []
|
|
478
|
-
dest = write_snapshot(
|
|
479
|
-
new_prices,
|
|
480
|
-
snapshot_dir=snapshot_dir,
|
|
481
|
-
source_url=source_url,
|
|
482
|
-
vendor=vendor,
|
|
483
|
-
currency=config.currency,
|
|
484
|
-
)
|
|
485
|
-
return f"written:{dest}", changes
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
# ─── CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
|
|
489
|
-
# bin/roll is unavailable (e.g. running tests directly).
|
|
490
|
-
def _main(argv: List[str]) -> int:
|
|
491
|
-
snapshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prices")
|
|
492
|
-
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
493
|
-
print("usage: prices_fetcher.py refresh|show [--url URL] [--vendor VENDOR]")
|
|
494
|
-
return 0
|
|
495
|
-
cmd = argv[0]
|
|
496
|
-
url: Optional[str] = None
|
|
497
|
-
vendor = "anthropic"
|
|
498
|
-
i = 1
|
|
499
|
-
while i < len(argv):
|
|
500
|
-
if argv[i] == "--url" and i + 1 < len(argv):
|
|
501
|
-
url = argv[i + 1]
|
|
502
|
-
i += 2
|
|
503
|
-
elif argv[i] == "--vendor" and i + 1 < len(argv):
|
|
504
|
-
vendor = argv[i + 1]
|
|
505
|
-
i += 2
|
|
506
|
-
else:
|
|
507
|
-
i += 1
|
|
508
|
-
if cmd == "show":
|
|
509
|
-
latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
|
|
510
|
-
if not latest:
|
|
511
|
-
print("no snapshot found", file=sys.stderr)
|
|
512
|
-
return 1
|
|
513
|
-
with open(latest) as f:
|
|
514
|
-
print(f.read())
|
|
515
|
-
return 0
|
|
516
|
-
if cmd == "refresh":
|
|
517
|
-
try:
|
|
518
|
-
action, changes = refresh(snapshot_dir=snapshot_dir, vendor=vendor, url=url)
|
|
519
|
-
except FetchError as exc:
|
|
520
|
-
print(f"fetch failed: {exc}", file=sys.stderr)
|
|
521
|
-
return 2
|
|
522
|
-
except ParseError as exc:
|
|
523
|
-
print(f"parse failed: {exc}", file=sys.stderr)
|
|
524
|
-
return 3
|
|
525
|
-
print(action)
|
|
526
|
-
if changes:
|
|
527
|
-
print(format_diff(changes, colored=sys.stdout.isatty()))
|
|
528
|
-
return 0
|
|
529
|
-
print(f"unknown command: {cmd}", file=sys.stderr)
|
|
530
|
-
return 1
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if __name__ == "__main__": # pragma: no cover
|
|
534
|
-
sys.exit(_main(sys.argv[1:]))
|