@seanyao/roll 0.5.0 → 2.602.1

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.
Files changed (181) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +14897 -815
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +186 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +727 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +578 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,186 @@
1
+ """
2
+ model_prices — list-price table for AI model API pricing.
3
+
4
+ Pricing is per million tokens (MTok), in the vendor's native currency.
5
+ These are the public list rates; discounts (Pro subscription, prepay
6
+ credits, etc.) are intentionally not modeled — IDEA-025 is about
7
+ cross-account / cross-project comparable cost.
8
+
9
+ US-VIEW-013: prices are no longer hardcoded here. They live in versioned
10
+ snapshot files under ``lib/prices/snapshot-YYYY-MM-DD.json`` and are loaded
11
+ at module import time. ``roll prices refresh`` produces new snapshots; this
12
+ module never writes — it only loads all snapshots and merges them.
13
+
14
+ FIX-116: multi-vendor support — snapshots carry ``vendor`` and ``currency``
15
+ fields. All snapshots are loaded and merged into a single PRICES map, with
16
+ each model entry carrying its native ``currency``. Vendor-prefixed model
17
+ names (``deepseek/deepseek-chat``) are resolved by stripping the vendor
18
+ segment when no exact match exists.
19
+
20
+ Unknown models fall back to the snapshot's ``default_model`` with a stderr
21
+ warning so dashboards don't blank out.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+ from typing import Any, Dict, List, Optional, Tuple
28
+
29
+ _LIB_DIR = os.path.dirname(os.path.abspath(__file__))
30
+ SNAPSHOT_DIR = os.path.join(_LIB_DIR, "prices")
31
+
32
+
33
+ def list_snapshots(snapshot_dir: str = SNAPSHOT_DIR) -> List[str]:
34
+ """Return absolute paths of all snapshot files, sorted oldest → newest by filename."""
35
+ if not os.path.isdir(snapshot_dir):
36
+ return []
37
+ entries = [
38
+ os.path.join(snapshot_dir, name)
39
+ for name in os.listdir(snapshot_dir)
40
+ if name.startswith("snapshot-") and name.endswith(".json")
41
+ ]
42
+ return sorted(entries)
43
+
44
+
45
+ def load_snapshot(path: str) -> Dict[str, Any]:
46
+ """Load a snapshot file and validate its shape."""
47
+ with open(path, "r", encoding="utf-8") as f:
48
+ data = json.load(f)
49
+ for key in ("version", "effective_at", "source_url", "prices"):
50
+ if key not in data:
51
+ raise ValueError(f"snapshot {path!r} missing required key {key!r}")
52
+ if not isinstance(data["prices"], dict) or not data["prices"]:
53
+ raise ValueError(f"snapshot {path!r} has empty or invalid prices map")
54
+ data.setdefault("default_model", next(iter(data["prices"])))
55
+ data.setdefault("vendor", "anthropic")
56
+ data.setdefault("currency", "USD")
57
+ return data
58
+
59
+
60
+ def load_all_snapshots(snapshot_dir: str = SNAPSHOT_DIR) -> List[Dict[str, Any]]:
61
+ """Load all snapshots, sorted oldest → newest. Raises FileNotFoundError if none."""
62
+ snaps = list_snapshots(snapshot_dir)
63
+ if not snaps:
64
+ raise FileNotFoundError(
65
+ f"no price snapshots found in {snapshot_dir}; run `roll prices refresh`"
66
+ )
67
+ return [load_snapshot(p) for p in snaps]
68
+
69
+
70
+ _SNAPSHOTS: List[Dict[str, Any]] = load_all_snapshots()
71
+ _DEFAULT_SNAP: Dict[str, Any] = _SNAPSHOTS[-1]
72
+
73
+ # Merge PRICES from all snapshots, injecting currency per model.
74
+ # Later snapshots override earlier ones for the same model name.
75
+ PRICES: Dict[str, Dict[str, float]] = {}
76
+ _CURRENCY: Dict[str, str] = {}
77
+ for _snap in _SNAPSHOTS:
78
+ _snap_currency = _snap.get("currency", "USD")
79
+ for _model, _rates in _snap["prices"].items():
80
+ PRICES[_model] = dict(_rates)
81
+ PRICES[_model]["currency"] = _snap_currency
82
+ _CURRENCY[_model] = _snap_currency
83
+
84
+ DEFAULT: str = _DEFAULT_SNAP["default_model"]
85
+ VERSION: str = _DEFAULT_SNAP["version"]
86
+ EFFECTIVE_AT: str = _DEFAULT_SNAP["effective_at"]
87
+ SOURCE_URL: str = _DEFAULT_SNAP["source_url"]
88
+
89
+ _warned: set = set()
90
+
91
+
92
+ def snapshot_meta() -> Tuple[str, str, str]:
93
+ """Return (version, effective_at, source_url) of the active snapshot."""
94
+ return VERSION, EFFECTIVE_AT, SOURCE_URL
95
+
96
+
97
+ def _resolve(model: Optional[str], prices: Optional[Dict[str, Dict[str, float]]] = None,
98
+ default: Optional[str] = None) -> Dict[str, float]:
99
+ table = prices if prices is not None else PRICES
100
+ fallback = default if default is not None else DEFAULT
101
+ if not model:
102
+ return table[fallback]
103
+ base = model.split("[")[0].rstrip("0123456789-")
104
+
105
+ # Direct match: model starts with a known key
106
+ candidates = [k for k in table if model.startswith(k) or base.startswith(k)]
107
+ if candidates:
108
+ return table[max(candidates, key=len)]
109
+
110
+ # Vendor prefix: try stripping "vendor/" segment for proxy tools (pi, etc.)
111
+ if "/" in model:
112
+ inner = model.split("/", 1)[1]
113
+ inner_base = inner.split("[")[0].rstrip("0123456789-")
114
+ for k in table:
115
+ if inner == k or inner_base == k or inner.startswith(k) or inner_base.startswith(k):
116
+ return table[k]
117
+
118
+ if model not in _warned:
119
+ _warned.add(model)
120
+ print(f"[model_prices] warn: unknown model {model!r}, falling back to {fallback}",
121
+ file=sys.stderr)
122
+ return table[fallback]
123
+
124
+
125
+ def _resolve_name(model: Optional[str],
126
+ prices: Optional[Dict[str, Dict[str, float]]] = None,
127
+ default: Optional[str] = None) -> str:
128
+ """Return the canonical model name (key in PRICES) for a given model string.
129
+
130
+ Same resolution logic as _resolve, but returns the matched key name
131
+ instead of the rate dict. Used by currency_for() to find the currency.
132
+ """
133
+ table = prices if prices is not None else PRICES
134
+ fallback = default if default is not None else DEFAULT
135
+ if not model:
136
+ return fallback
137
+ base = model.split("[")[0].rstrip("0123456789-")
138
+
139
+ # Direct match: model starts with a known key
140
+ candidates = [k for k in table if model.startswith(k) or base.startswith(k)]
141
+ if candidates:
142
+ return max(candidates, key=len)
143
+
144
+ # Vendor prefix: try stripping "vendor/" segment
145
+ if "/" in model:
146
+ inner = model.split("/", 1)[1]
147
+ inner_base = inner.split("[")[0].rstrip("0123456789-")
148
+ for k in table:
149
+ if inner == k or inner_base == k or inner.startswith(k) or inner_base.startswith(k):
150
+ return k
151
+
152
+ return fallback
153
+
154
+
155
+ def currency_for(model: Optional[str]) -> str:
156
+ """Return the native currency code (USD/CNY) for a model.
157
+
158
+ Falls back to 'USD' when the model isn't in any snapshot.
159
+ """
160
+ name = _resolve_name(model)
161
+ return _CURRENCY.get(name, "USD")
162
+
163
+
164
+ def compute_list_cost(model: Optional[str],
165
+ *,
166
+ input_tokens: int = 0,
167
+ output_tokens: int = 0,
168
+ cache_creation_tokens: int = 0,
169
+ cache_read_tokens: int = 0,
170
+ prices: Optional[Dict[str, Dict[str, float]]] = None,
171
+ default: Optional[str] = None) -> float:
172
+ """Return cost (in native currency) at list price for one cycle's token usage."""
173
+ p = _resolve(model, prices=prices, default=default)
174
+ total = (input_tokens * p["in"]
175
+ + output_tokens * p["out"]
176
+ + cache_creation_tokens * p["cache_create"]
177
+ + cache_read_tokens * p["cache_read"]) / 1_000_000
178
+ return round(total, 4)
179
+
180
+
181
+ def total_tokens(*,
182
+ input_tokens: int = 0,
183
+ output_tokens: int = 0,
184
+ cache_creation_tokens: int = 0,
185
+ cache_read_tokens: int = 0) -> int:
186
+ return int(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens)
@@ -0,0 +1,35 @@
1
+ > **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
2
+
3
+ # lib/prices/ — Model price snapshots
4
+
5
+ Dated JSON snapshots of AI model list prices, used by `roll loop status` to compute per-cycle cost in both USD and native currency (CNY for pi/DeepSeek/Kimi).
6
+
7
+ ## Files
8
+
9
+ | File | Contents |
10
+ |------|---------|
11
+ | `snapshot-2026-05-22.json` | Multi-vendor snapshot (Claude, GPT, Gemini, DeepSeek, Kimi, pi) |
12
+ | `snapshot-2026-05-23-deepseek.json` | DeepSeek-specific refresh |
13
+ | `snapshot-2026-05-23-kimi.json` | Kimi-specific refresh |
14
+
15
+ ## Format
16
+
17
+ Each snapshot is a JSON object keyed by model ID:
18
+
19
+ ```json
20
+ {
21
+ "claude-opus-4-7": {
22
+ "input_per_mtok": 15.0,
23
+ "output_per_mtok": 75.0,
24
+ "cache_write_per_mtok": 18.75,
25
+ "cache_read_per_mtok": 1.5,
26
+ "currency": "USD"
27
+ }
28
+ }
29
+ ```
30
+
31
+ CNY-priced models (pi, DeepSeek, Kimi) use `"currency": "CNY"`.
32
+
33
+ ## Refresh
34
+
35
+ `prices_fetcher.py` fetches fresh snapshots from vendor pricing APIs and writes a new dated file here. Run via `roll prices refresh`.
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "2026-05-22",
3
+ "effective_at": "2026-05-22",
4
+ "source_url": "https://platform.claude.com/docs/en/about-claude/pricing",
5
+ "vendor": "anthropic",
6
+ "currency": "USD",
7
+ "default_model": "claude-sonnet-4-6",
8
+ "notes": "Rates per million tokens (USD). cache_create = 5-minute cache write (1.25x input). 1-hour cache writes (2x input) are not modeled — Roll loop uses the default 5m caching only. 2026-05 repricing: Opus 4.5+ moved to $5/$25 base (3x cheaper than Opus 4/4.1).",
9
+ "prices": {
10
+ "claude-opus-4-7": {"in": 5.00, "out": 25.00, "cache_create": 6.25, "cache_read": 0.50},
11
+ "claude-opus-4-6": {"in": 5.00, "out": 25.00, "cache_create": 6.25, "cache_read": 0.50},
12
+ "claude-opus-4-5": {"in": 5.00, "out": 25.00, "cache_create": 6.25, "cache_read": 0.50},
13
+ "claude-opus-4-1": {"in": 15.00, "out": 75.00, "cache_create": 18.75, "cache_read": 1.50},
14
+ "claude-opus-4": {"in": 15.00, "out": 75.00, "cache_create": 18.75, "cache_read": 1.50},
15
+ "claude-sonnet-4-6": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30},
16
+ "claude-sonnet-4-5": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30},
17
+ "claude-sonnet-4": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30},
18
+ "claude-haiku-4-5": {"in": 1.00, "out": 5.00, "cache_create": 1.25, "cache_read": 0.10},
19
+ "claude-haiku-3-5": {"in": 0.80, "out": 4.00, "cache_create": 1.00, "cache_read": 0.08},
20
+ "claude-3-5-sonnet": {"in": 3.00, "out": 15.00, "cache_create": 3.75, "cache_read": 0.30}
21
+ }
22
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": "2026-05-23",
3
+ "effective_at": "2026-05-23",
4
+ "source_url": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
5
+ "vendor": "deepseek",
6
+ "currency": "CNY",
7
+ "default_model": "deepseek-chat",
8
+ "notes": "Rates per million tokens in CNY (¥) — DeepSeek's native billing currency; we never convert to USD (the dashboard already shows the currency symbol). deepseek-chat and deepseek-reasoner are both deepseek-v4-flash with different thinking modes — same pricing. deepseek-v4-pro is under a 2.5折 (75% off) promo until Beijing time 2026-05-31 23:59 (normal: in 12 / out 24); after expiry, refresh this snapshot. cache_read is the official cache-hit input price (reduced to 1/10 of launch price since 2026-04-26). cache_create = cache-miss input rate: DeepSeek levies no separate cache-write surcharge, and pi reports cacheWrite cost as 0, so this rate only ever applies to (near-zero) cacheWrite tokens. pi's own per-message cost.total is computed in USD and is kept as cost_reported_usd for audit, NOT used for the authoritative cost.",
9
+ "prices": {
10
+ "deepseek-chat": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
11
+ "deepseek-reasoner": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
12
+ "deepseek-v4-flash": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
13
+ "deepseek-v4-pro": {"in": 3, "out": 6, "cache_create": 3, "cache_read": 0.025}
14
+ }
15
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": "2026-05-23",
3
+ "effective_at": "2026-05-23",
4
+ "source_url": "https://platform.kimi.com/docs/pricing/chat",
5
+ "vendor": "kimi",
6
+ "currency": "USD",
7
+ "default_model": "kimi-k2.5",
8
+ "notes": "Rates per million tokens (USD). cache_create estimated at 1.25x input. Prices from public Kimi API platform docs — verify with `roll prices refresh` if page layout changes. Model names: kimi-k2 (prior gen), kimi-k2.5 (current), kimi-k2.6 (latest).",
9
+ "prices": {
10
+ "kimi-k2": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25},
11
+ "kimi-k2.5": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25},
12
+ "kimi-k2.6": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25}
13
+ }
14
+ }
@@ -0,0 +1,285 @@
1
+ """
2
+ prices_fetcher — fetch + parse + diff + write Claude API 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
+ Design:
10
+ * ``fetch_pricing_html(url, timeout)`` — pure I/O, raises ``FetchError``
11
+ * ``parse_pricing_html(html)`` — pure parser, raises ``ParseError``
12
+ * ``diff_prices(old, new)`` — pure diff, returns list of changes
13
+ * ``write_snapshot(prices, ...)`` — pure I/O, returns the path written
14
+ * ``refresh(...)`` — orchestrator; the only function with side effects on
15
+ both network and disk
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime as _dt
21
+ import json
22
+ import os
23
+ import re
24
+ import sys
25
+ from html.parser import HTMLParser
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+ from urllib.error import URLError
28
+ from urllib.request import Request, urlopen
29
+
30
+ DEFAULT_SOURCE_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
31
+ DEFAULT_TIMEOUT = 15
32
+
33
+ _MODEL_RE = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
34
+ _DOLLAR_RE = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
35
+
36
+
37
+ class FetchError(RuntimeError):
38
+ """Raised when fetching the pricing page fails."""
39
+
40
+
41
+ class ParseError(ValueError):
42
+ """Raised when the pricing HTML cannot be parsed into a prices map."""
43
+
44
+
45
+ def fetch_pricing_html(url: str = DEFAULT_SOURCE_URL,
46
+ timeout: float = DEFAULT_TIMEOUT) -> str:
47
+ """Fetch the pricing docs page and return its raw HTML."""
48
+ req = Request(url, headers={"User-Agent": "roll/prices_fetcher"})
49
+ try:
50
+ with urlopen(req, timeout=timeout) as resp:
51
+ data = resp.read()
52
+ charset = resp.headers.get_content_charset() or "utf-8"
53
+ return data.decode(charset, errors="replace")
54
+ except (URLError, OSError, TimeoutError) as exc:
55
+ raise FetchError(f"could not fetch {url}: {exc}") from exc
56
+
57
+
58
+ class _TableTextExtractor(HTMLParser):
59
+ """Walk an HTML document and yield <tr> cell-text lists per row."""
60
+
61
+ def __init__(self) -> None:
62
+ super().__init__()
63
+ self.rows: List[List[str]] = []
64
+ self._in_row = False
65
+ self._in_cell = False
66
+ self._cells: List[str] = []
67
+ self._cur: List[str] = []
68
+
69
+ def handle_starttag(self, tag: str, attrs): # noqa: ANN001
70
+ if tag == "tr":
71
+ self._in_row = True
72
+ self._cells = []
73
+ elif tag in ("td", "th") and self._in_row:
74
+ self._in_cell = True
75
+ self._cur = []
76
+
77
+ def handle_endtag(self, tag: str) -> None:
78
+ if tag in ("td", "th") and self._in_cell:
79
+ self._cells.append(" ".join(self._cur).strip())
80
+ self._in_cell = False
81
+ elif tag == "tr" and self._in_row:
82
+ if self._cells:
83
+ self.rows.append(self._cells)
84
+ self._in_row = False
85
+
86
+ def handle_data(self, data: str) -> None:
87
+ if self._in_cell:
88
+ self._cur.append(data)
89
+
90
+
91
+ def parse_pricing_html(html: str) -> Dict[str, Dict[str, float]]:
92
+ """Parse pricing docs HTML into a {model: rates} map.
93
+
94
+ The parser is intentionally tolerant: it scans every table row, looks for
95
+ one ``claude-*`` model identifier and four dollar amounts on that row, and
96
+ treats them as ``in / cache_create / cache_read / out`` in the order they
97
+ appear. (Anthropic's table renders columns in that order.)
98
+ """
99
+ parser = _TableTextExtractor()
100
+ parser.feed(html)
101
+
102
+ prices: Dict[str, Dict[str, float]] = {}
103
+ for row in parser.rows:
104
+ text = " ".join(row)
105
+ model_match = _MODEL_RE.search(text)
106
+ if not model_match:
107
+ continue
108
+ model = model_match.group(0)
109
+ amounts = [float(m.group(1)) for m in _DOLLAR_RE.finditer(text)]
110
+ if len(amounts) < 4:
111
+ continue
112
+ in_rate, cache_create, cache_read, out_rate = amounts[:4]
113
+ prices[model] = {
114
+ "in": in_rate,
115
+ "out": out_rate,
116
+ "cache_create": cache_create,
117
+ "cache_read": cache_read,
118
+ }
119
+
120
+ if not prices:
121
+ raise ParseError("no price rows found in HTML; page layout may have changed")
122
+ return prices
123
+
124
+
125
+ def diff_prices(old: Dict[str, Dict[str, float]],
126
+ new: Dict[str, Dict[str, float]]
127
+ ) -> List[Tuple[str, str, str, Optional[float], Optional[float]]]:
128
+ """Return a list of (kind, model, field, old_val, new_val) tuples.
129
+
130
+ kind is one of: ``added``, ``removed``, ``changed``. For added rows the
131
+ old_val is None; for removed, the new_val is None.
132
+ """
133
+ changes: List[Tuple[str, str, str, Optional[float], Optional[float]]] = []
134
+ for model in sorted(set(old) | set(new)):
135
+ if model not in old:
136
+ for field, val in new[model].items():
137
+ changes.append(("added", model, field, None, val))
138
+ continue
139
+ if model not in new:
140
+ for field, val in old[model].items():
141
+ changes.append(("removed", model, field, val, None))
142
+ continue
143
+ for field in sorted(set(old[model]) | set(new[model])):
144
+ old_val = old[model].get(field)
145
+ new_val = new[model].get(field)
146
+ if old_val != new_val:
147
+ changes.append(("changed", model, field, old_val, new_val))
148
+ return changes
149
+
150
+
151
+ def format_diff(changes: List[Tuple[str, str, str, Optional[float], Optional[float]]],
152
+ colored: bool = True) -> str:
153
+ """Render diff_prices output as red-/green-coded lines."""
154
+ if not changes:
155
+ return ""
156
+ red = "\033[31m" if colored else ""
157
+ green = "\033[32m" if colored else ""
158
+ dim = "\033[2m" if colored else ""
159
+ reset = "\033[0m" if colored else ""
160
+ lines: List[str] = []
161
+ for kind, model, field, old, new in changes:
162
+ if kind == "added":
163
+ lines.append(f"{green}+ {model} {field} = {new}{reset}")
164
+ elif kind == "removed":
165
+ lines.append(f"{red}- {model} {field} = {old}{reset}")
166
+ else:
167
+ lines.append(f"{dim}~ {model} {field}{reset} {red}{old}{reset} → {green}{new}{reset}")
168
+ return "\n".join(lines)
169
+
170
+
171
+ def write_snapshot(prices: Dict[str, Dict[str, float]],
172
+ *,
173
+ snapshot_dir: str,
174
+ source_url: str = DEFAULT_SOURCE_URL,
175
+ effective_at: Optional[str] = None,
176
+ default_model: Optional[str] = None,
177
+ notes: Optional[str] = None) -> str:
178
+ """Write a new snapshot JSON and return its path."""
179
+ os.makedirs(snapshot_dir, exist_ok=True)
180
+ today = effective_at or _dt.date.today().isoformat()
181
+ payload: Dict[str, Any] = {
182
+ "version": today,
183
+ "effective_at": today,
184
+ "source_url": source_url,
185
+ "default_model": default_model or _pick_default(prices),
186
+ "prices": prices,
187
+ }
188
+ if notes:
189
+ payload["notes"] = notes
190
+ dest = os.path.join(snapshot_dir, f"snapshot-{today}.json")
191
+ with open(dest, "w", encoding="utf-8") as f:
192
+ json.dump(payload, f, indent=2, sort_keys=False)
193
+ f.write("\n")
194
+ return dest
195
+
196
+
197
+ def _pick_default(prices: Dict[str, Dict[str, float]]) -> str:
198
+ """Pick a sensible fallback model: prefer the cheapest sonnet, else first key."""
199
+ for k in prices:
200
+ if "sonnet" in k:
201
+ return k
202
+ return next(iter(prices))
203
+
204
+
205
+ def refresh(*,
206
+ snapshot_dir: str,
207
+ url: str = DEFAULT_SOURCE_URL,
208
+ timeout: float = DEFAULT_TIMEOUT,
209
+ html: Optional[str] = None,
210
+ ) -> Tuple[str, List[Tuple[str, str, str, Optional[float], Optional[float]]]]:
211
+ """Fetch (or accept fixture HTML), parse, diff against latest snapshot, write.
212
+
213
+ Returns (action, changes) where action is one of:
214
+ ``"unchanged"`` — no diff vs latest snapshot, nothing written
215
+ ``"written:<path>"`` — new snapshot written at <path>
216
+ ``"first:<path>"`` — no prior snapshot existed; baseline written
217
+ """
218
+ if html is None:
219
+ html = fetch_pricing_html(url, timeout=timeout)
220
+ new_prices = parse_pricing_html(html)
221
+
222
+ # Load latest if any
223
+ latest = _latest_snapshot_path(snapshot_dir)
224
+ if latest is None:
225
+ dest = write_snapshot(new_prices, snapshot_dir=snapshot_dir, source_url=url)
226
+ return f"first:{dest}", diff_prices({}, new_prices)
227
+
228
+ with open(latest, "r", encoding="utf-8") as f:
229
+ old = json.load(f).get("prices", {})
230
+ changes = diff_prices(old, new_prices)
231
+ if not changes:
232
+ return "unchanged", []
233
+ dest = write_snapshot(new_prices, snapshot_dir=snapshot_dir, source_url=url)
234
+ return f"written:{dest}", changes
235
+
236
+
237
+ def _latest_snapshot_path(snapshot_dir: str) -> Optional[str]:
238
+ if not os.path.isdir(snapshot_dir):
239
+ return None
240
+ snaps = sorted(
241
+ os.path.join(snapshot_dir, n)
242
+ for n in os.listdir(snapshot_dir)
243
+ if n.startswith("snapshot-") and n.endswith(".json")
244
+ )
245
+ return snaps[-1] if snaps else None
246
+
247
+
248
+ # CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
249
+ # bin/roll is unavailable (e.g. running tests directly).
250
+ def _main(argv: List[str]) -> int:
251
+ snapshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prices")
252
+ if not argv or argv[0] in ("-h", "--help", "help"):
253
+ print("usage: prices_fetcher.py refresh|show [--url URL]")
254
+ return 0
255
+ cmd = argv[0]
256
+ url = DEFAULT_SOURCE_URL
257
+ if "--url" in argv:
258
+ url = argv[argv.index("--url") + 1]
259
+ if cmd == "show":
260
+ latest = _latest_snapshot_path(snapshot_dir)
261
+ if not latest:
262
+ print("no snapshot found", file=sys.stderr)
263
+ return 1
264
+ with open(latest) as f:
265
+ print(f.read())
266
+ return 0
267
+ if cmd == "refresh":
268
+ try:
269
+ action, changes = refresh(snapshot_dir=snapshot_dir, url=url)
270
+ except FetchError as exc:
271
+ print(f"fetch failed: {exc}", file=sys.stderr)
272
+ return 2
273
+ except ParseError as exc:
274
+ print(f"parse failed: {exc}", file=sys.stderr)
275
+ return 3
276
+ print(action)
277
+ if changes:
278
+ print(format_diff(changes, colored=sys.stdout.isatty()))
279
+ return 0
280
+ print(f"unknown command: {cmd}", file=sys.stderr)
281
+ return 1
282
+
283
+
284
+ if __name__ == "__main__": # pragma: no cover
285
+ sys.exit(_main(sys.argv[1:]))