@seanyao/roll 0.5.0 → 2.602.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 +736 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +15030 -814
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -0
- package/lib/i18n/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +194 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +15 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +733 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +579 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
_NO_CURRENCY_MATCH = "\x00__no_currency_match__\x00"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def currency_for(model: Optional[str]) -> str:
|
|
159
|
+
"""Return the native currency code (USD/CNY) for a model.
|
|
160
|
+
|
|
161
|
+
Falls back to 'USD' when the model isn't in any snapshot. FIX-162: resolve
|
|
162
|
+
with a sentinel default so a *genuinely unknown* model returns USD instead
|
|
163
|
+
of inheriting the global DEFAULT model's currency (which is a CNY kimi
|
|
164
|
+
entry — that would mislabel unrelated unknown models as CNY).
|
|
165
|
+
"""
|
|
166
|
+
name = _resolve_name(model, default=_NO_CURRENCY_MATCH)
|
|
167
|
+
if name == _NO_CURRENCY_MATCH:
|
|
168
|
+
return "USD"
|
|
169
|
+
return _CURRENCY.get(name, "USD")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def compute_list_cost(model: Optional[str],
|
|
173
|
+
*,
|
|
174
|
+
input_tokens: int = 0,
|
|
175
|
+
output_tokens: int = 0,
|
|
176
|
+
cache_creation_tokens: int = 0,
|
|
177
|
+
cache_read_tokens: int = 0,
|
|
178
|
+
prices: Optional[Dict[str, Dict[str, float]]] = None,
|
|
179
|
+
default: Optional[str] = None) -> float:
|
|
180
|
+
"""Return cost (in native currency) at list price for one cycle's token usage."""
|
|
181
|
+
p = _resolve(model, prices=prices, default=default)
|
|
182
|
+
total = (input_tokens * p["in"]
|
|
183
|
+
+ output_tokens * p["out"]
|
|
184
|
+
+ cache_creation_tokens * p["cache_create"]
|
|
185
|
+
+ cache_read_tokens * p["cache_read"]) / 1_000_000
|
|
186
|
+
return round(total, 4)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def total_tokens(*,
|
|
190
|
+
input_tokens: int = 0,
|
|
191
|
+
output_tokens: int = 0,
|
|
192
|
+
cache_creation_tokens: int = 0,
|
|
193
|
+
cache_read_tokens: int = 0) -> int:
|
|
194
|
+
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,15 @@
|
|
|
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": "CNY",
|
|
7
|
+
"default_model": "kimi-k2.5",
|
|
8
|
+
"notes": "Rates per million tokens (CNY). 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). kimi-for-coding is the kimi-code CLI's model id (alias of the current K2 line) — FIX-162 so usage events tagged `kimi-code/kimi-for-coding` resolve to a real CNY entry instead of falling back to USD.",
|
|
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
|
+
"kimi-for-coding": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -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:]))
|